restructure
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, SharedString, Window, div, px};
|
||||
use gpui_component::clipboard::Clipboard;
|
||||
use gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex};
|
||||
use nostr::prelude::PublicKey;
|
||||
use signed_core::Announcement;
|
||||
use signed_state::ProfileStore;
|
||||
use signed_ui::{UserAvatar, middle_truncate};
|
||||
|
||||
/// Open the About dialog showing every field of the announcement event.
|
||||
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
|
||||
window.open_dialog(cx, move |dialog, _window, cx| {
|
||||
let announcement = announcement.clone();
|
||||
dialog
|
||||
.w(px(500.))
|
||||
.h(px(600.))
|
||||
.keyboard(true)
|
||||
.close_button(true)
|
||||
.title("About")
|
||||
.child(announcement_rows(&announcement, cx))
|
||||
});
|
||||
}
|
||||
|
||||
/// The announcement's fields as labeled rows.
|
||||
fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
||||
let mut rows: Vec<AnyElement> = Vec::new();
|
||||
|
||||
rows.push(row(
|
||||
"Name",
|
||||
text(
|
||||
announcement
|
||||
.name
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from("-")),
|
||||
),
|
||||
cx,
|
||||
));
|
||||
|
||||
rows.push(row(
|
||||
"Description",
|
||||
text(
|
||||
announcement
|
||||
.description
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from("-")),
|
||||
),
|
||||
cx,
|
||||
));
|
||||
|
||||
if !announcement.web.is_empty() {
|
||||
rows.push(row(
|
||||
"Web",
|
||||
list(
|
||||
"about-web",
|
||||
announcement.web.iter().map(|url| url.to_string()),
|
||||
cx,
|
||||
),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(euc) = &announcement.euc {
|
||||
rows.push(row(
|
||||
"Earliest Commit",
|
||||
copy_value("about-euc", euc.clone(), cx),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(upstream) = &announcement.upstream {
|
||||
rows.push(row("Upstream", text(upstream.display()), cx));
|
||||
}
|
||||
|
||||
if !announcement.hashtags.is_empty() {
|
||||
rows.push(row(
|
||||
"Hashtags",
|
||||
text(SharedString::from(announcement.hashtags.join(", "))),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if !announcement.clone.is_empty() {
|
||||
rows.push(row(
|
||||
"Clone URLs",
|
||||
list(
|
||||
"about-clone",
|
||||
announcement.clone.iter().map(|url| url.to_string()),
|
||||
cx,
|
||||
),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if !announcement.relays.is_empty() {
|
||||
rows.push(row(
|
||||
"Grasp Relays",
|
||||
list(
|
||||
"about-relays",
|
||||
announcement.relays.iter().map(|url| url.to_string()),
|
||||
cx,
|
||||
),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if !announcement.maintainers.is_empty() {
|
||||
rows.push(row(
|
||||
"Maintainers",
|
||||
maintainers(&announcement.maintainers, cx),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
v_flex().gap_3().w_full().children(rows).into_any_element()
|
||||
}
|
||||
|
||||
/// One info row with a small muted label above the value.
|
||||
fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(label),
|
||||
)
|
||||
.child(value)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Plain text value, wrapping within the dialog.
|
||||
fn text<T>(value: T) -> AnyElement
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
let value = value.into();
|
||||
|
||||
div()
|
||||
.text_sm()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.child(value)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// A mono-spaced value with a copy button, for hex identifiers.
|
||||
fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.min_w_0()
|
||||
.child(SharedString::from(value.clone())),
|
||||
)
|
||||
.child(Clipboard::new(id).tooltip("Copy").value(value))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// One row per maintainer with avatar and display name.
|
||||
/// The display name falls back to a shortened npub.
|
||||
///
|
||||
/// A copy button copies the full pubkey.
|
||||
fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.min_w_0()
|
||||
.children(maintainers.iter().map(|pubkey| {
|
||||
let profile = profile_store.read(cx).get(pubkey);
|
||||
let name = profile.name();
|
||||
let picture = profile.picture();
|
||||
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.min_w_0()
|
||||
.child(UserAvatar::new(name.clone()).picture(picture))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.text_sm()
|
||||
.child(name),
|
||||
)
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// One row per item of a multi-value tag.
|
||||
///
|
||||
/// The value is truncated to a single line, with a copy button for the full value.
|
||||
fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.min_w_0()
|
||||
.children(items.into_iter().enumerate().map(|(ix, item)| {
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.h_8()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.min_w_0()
|
||||
.bg(cx.theme().secondary)
|
||||
.hover(|this| this.bg(cx.theme().secondary_hover))
|
||||
.rounded(cx.theme().radius)
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.text_sm()
|
||||
.child(SharedString::from(middle_truncate(&item, 28, 16))),
|
||||
)
|
||||
.child(
|
||||
Clipboard::new(format!("{id}-{ix}"))
|
||||
.tooltip("Copy")
|
||||
.value(item),
|
||||
)
|
||||
.into_any_element()
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -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<Self>) {
|
||||
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<Self>,
|
||||
) {
|
||||
let Some(store) = self.store.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if store.read(cx).pushing {
|
||||
return;
|
||||
}
|
||||
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
|
||||
let task: gpui::Task<Result<(), Error>> = 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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Result<(), Error>> = 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<Self>) {
|
||||
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<DockArea>,
|
||||
announcement: &Announcement,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<RepoDetailView> {
|
||||
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<RepoStore> {
|
||||
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<DockArea>,
|
||||
announcement: &Announcement,
|
||||
item: RepoItem,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let panel: Arc<dyn PanelView> =
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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<CheckoutStatus> {
|
||||
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<CheckoutStatus> {
|
||||
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<Self>) -> Option<AnyElement> {
|
||||
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<Self>) -> Option<AnyElement> {
|
||||
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<Self>) -> Option<AnyElement> {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
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};
|
||||
use gpui_component::input::{Editor, EditorState};
|
||||
use gpui_component::list::ListItem;
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::text::{TextView, TextViewState};
|
||||
use gpui_component::tree::{TreeEntry, TreeState, tree};
|
||||
use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex};
|
||||
use signed_ui::{placeholder, tree_row};
|
||||
|
||||
use super::RepoDetailView;
|
||||
use crate::views::repo::helpers::{code_language, is_markdown_path};
|
||||
|
||||
/// Width of the file explorer column.
|
||||
const TREE_WIDTH: f32 = 240.;
|
||||
/// Files larger than this are not previewed.
|
||||
pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
|
||||
/// Preview cache caps, a file count and a text byte count.
|
||||
///
|
||||
/// The oldest previews are evicted beyond the caps.
|
||||
pub(super) const MAX_PREVIEWED_FILES: usize = 32;
|
||||
pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// Preview state of a browsed file.
|
||||
pub(super) enum FileContent {
|
||||
/// Decodable text content.
|
||||
Text(String),
|
||||
/// Not valid UTF-8.
|
||||
Binary,
|
||||
/// Bigger than [`MAX_PREVIEW_BYTES`].
|
||||
TooLarge,
|
||||
/// Reading failed.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// A markdown document loaded into a persistent [`TextViewState`].
|
||||
pub(super) struct MarkdownView {
|
||||
/// Source path, `None` means the repository README.
|
||||
pub(super) path: Option<SharedString>,
|
||||
pub(super) state: Entity<TextViewState>,
|
||||
/// 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`].
|
||||
pub(super) struct CodeView {
|
||||
/// Source path, relative to the worktree root.
|
||||
pub(super) path: SharedString,
|
||||
pub(super) state: Entity<EditorState>,
|
||||
/// 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.
|
||||
fn preview_spinner() -> AnyElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
impl RepoDetailView {
|
||||
/// One row of the file tree with icon and name, indented by depth.
|
||||
fn render_tree_item(
|
||||
ix: usize,
|
||||
entry: &TreeEntry,
|
||||
selected: bool,
|
||||
view: &WeakEntity<Self>,
|
||||
) -> ListItem {
|
||||
let view = view.clone();
|
||||
let id = entry.item().id.clone();
|
||||
|
||||
tree_row(ix, entry, selected, move |window, cx| {
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| this.open_file(&id, window, cx));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Left column showing the file tree.
|
||||
pub(super) fn render_tree_column(
|
||||
tree_state: Entity<TreeState>,
|
||||
view: WeakEntity<Self>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
v_flex()
|
||||
.h_full()
|
||||
.w(px(TREE_WIDTH))
|
||||
.p_2()
|
||||
.flex_none()
|
||||
.border_r_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(div().flex_1().min_h_0().child(tree(
|
||||
&tree_state,
|
||||
move |ix, entry, selected, _window, _cx| {
|
||||
Self::render_tree_item(ix, entry, selected, &view)
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
/// Right column, README, selected file preview or status text.
|
||||
pub(super) fn render_content_column(
|
||||
&self,
|
||||
pane_title: SharedString,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let loading = self.loading;
|
||||
let error = self.error.clone();
|
||||
let selected_file = self.selected_file.clone();
|
||||
|
||||
let body: AnyElement = if loading {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.child(Spinner::new().small())
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Cloning repository..."),
|
||||
)
|
||||
.into_any_element()
|
||||
} else if let Some(error) = error {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_4()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(error),
|
||||
)
|
||||
.into_any_element()
|
||||
} else if let Some(path) = selected_file {
|
||||
match self.files.get(path.as_ref()) {
|
||||
Some(FileContent::Text(_)) => {
|
||||
if is_markdown_path(path.as_ref()) {
|
||||
self.markdown_element(Some(path.as_ref()), cx)
|
||||
} else {
|
||||
self.code_element(path.as_ref(), cx)
|
||||
}
|
||||
}
|
||||
Some(FileContent::Binary) => placeholder("Binary file - preview not supported", cx),
|
||||
Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx),
|
||||
Some(FileContent::Failed(message)) => placeholder(message, cx),
|
||||
None => preview_spinner(),
|
||||
}
|
||||
} else if self.readme_name.is_some() {
|
||||
self.markdown_element(None, cx)
|
||||
} else {
|
||||
placeholder("No README found", cx)
|
||||
};
|
||||
|
||||
// Latest commit for the current pane, the selected file or the README.
|
||||
// Computed after the body above, which needs `&mut self`.
|
||||
let commit = match &self.selected_file {
|
||||
Some(path) => self.commits.get(path.as_ref()),
|
||||
None => self
|
||||
.readme_name
|
||||
.as_ref()
|
||||
.and_then(|name| self.commits.get(name.as_ref())),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.h_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_3()
|
||||
.h_9()
|
||||
.gap_2()
|
||||
.bg(cx.theme().muted)
|
||||
.border_b(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(pane_title),
|
||||
)
|
||||
.when_some(commit, |this, commit| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("commit")
|
||||
.xsmall()
|
||||
.text()
|
||||
.label(commit.id.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.max_w(px(250.))
|
||||
.text_xs()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(commit.summary.clone()),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(div().id("repo-content").flex_1().min_h_0().child(body))
|
||||
}
|
||||
|
||||
/// Load `text` into the persistent markdown TextView state.
|
||||
pub(super) fn set_markdown(
|
||||
&mut self,
|
||||
path: Option<SharedString>,
|
||||
text: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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,
|
||||
source_hash: hash,
|
||||
});
|
||||
}
|
||||
|
||||
/// The persistent markdown TextView for `path`, where `None` is the README.
|
||||
///
|
||||
/// Shows a spinner while the document is being loaded or parsed.
|
||||
fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(md) = &self.md else {
|
||||
return preview_spinner();
|
||||
};
|
||||
|
||||
let ready = match path {
|
||||
Some(path) => md.path.as_deref() == Some(path),
|
||||
None => md.path.is_none(),
|
||||
};
|
||||
|
||||
if !ready {
|
||||
return preview_spinner();
|
||||
}
|
||||
|
||||
TextView::new(&md.state)
|
||||
.selectable(true)
|
||||
.scrollable(true)
|
||||
.p_4()
|
||||
.text_sm()
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Load `text` into the persistent code editor state for `path`.
|
||||
///
|
||||
/// Code editor mode makes the Input render it read-only and highlighted.
|
||||
pub(super) fn set_code(
|
||||
&mut self,
|
||||
path: SharedString,
|
||||
text: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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)
|
||||
.language(language)
|
||||
.default_value(text)
|
||||
.line_number(true)
|
||||
.folding(true)
|
||||
});
|
||||
self.code = Some(CodeView {
|
||||
path,
|
||||
state,
|
||||
source_hash: hash,
|
||||
});
|
||||
}
|
||||
|
||||
/// The persistent code editor for `path`, or a spinner while the file loads or parses.
|
||||
fn code_element(&self, path: &str, _cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(code) = &self.code else {
|
||||
return preview_spinner();
|
||||
};
|
||||
if code.path.as_ref() != path {
|
||||
return preview_spinner();
|
||||
}
|
||||
|
||||
Editor::new(&code.state)
|
||||
.readonly(true)
|
||||
.bordered(false)
|
||||
.rounded_none()
|
||||
.h_full()
|
||||
.text_sm()
|
||||
.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>) {
|
||||
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<Result<(), Error>> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<RepoDetailView>) -> Option<AnyElement> {
|
||||
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()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
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};
|
||||
use gpui_component::menu::PopupMenu;
|
||||
use gpui_component::searchable_list::SearchableVec;
|
||||
use gpui_component::tag::Tag;
|
||||
use gpui_component::tree::TreeItem;
|
||||
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, FileCommit, FileDiff};
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{UserAvatar, menu_copy_row, middle_truncate};
|
||||
use utils::{relative_time, relative_time_secs};
|
||||
|
||||
pub(crate) struct TreeItemSeed {
|
||||
/// Path of the node, relative to the worktree root.
|
||||
id: String,
|
||||
/// File or directory name.
|
||||
label: String,
|
||||
children: Vec<TreeItemSeed>,
|
||||
}
|
||||
|
||||
pub(crate) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
|
||||
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
|
||||
let mut item = TreeItem::new(seed.id, seed.label);
|
||||
if expand_folders && !seed.children.is_empty() {
|
||||
item = item.expanded(true);
|
||||
}
|
||||
item.children = seed
|
||||
.children
|
||||
.into_iter()
|
||||
.map(|seed| convert(seed, expand_folders))
|
||||
.collect();
|
||||
item
|
||||
}
|
||||
|
||||
seeds
|
||||
.into_iter()
|
||||
.map(|seed| convert(seed, expand_folders))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build nested tree items from a flat entry list sorted dirs-first.
|
||||
pub(crate) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
||||
// Node indices by full path, so parents resolve in constant time while inserting.
|
||||
let mut index: HashMap<String, usize> = HashMap::new();
|
||||
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
|
||||
let mut roots: Vec<usize> = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
let mut parent: Option<usize> = None;
|
||||
let mut path = String::new();
|
||||
for part in entry.components() {
|
||||
let label = part.as_os_str().to_string_lossy().into_owned();
|
||||
path = if path.is_empty() {
|
||||
label.clone()
|
||||
} else {
|
||||
format!("{path}/{label}")
|
||||
};
|
||||
let ix = *index.entry(path.clone()).or_insert_with(|| {
|
||||
let ix = nodes.len();
|
||||
nodes.push((path.clone(), label.clone(), Vec::new()));
|
||||
match parent {
|
||||
Some(parent) => nodes[parent].2.push(ix),
|
||||
None => roots.push(ix),
|
||||
}
|
||||
ix
|
||||
});
|
||||
parent = Some(ix);
|
||||
}
|
||||
}
|
||||
|
||||
fn assemble(ix: usize, nodes: &[(String, String, Vec<usize>)]) -> TreeItemSeed {
|
||||
let (id, label, children) = &nodes[ix];
|
||||
TreeItemSeed {
|
||||
id: id.clone(),
|
||||
label: label.clone(),
|
||||
children: children
|
||||
.iter()
|
||||
.map(|child| assemble(*child, nodes))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
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<String> {
|
||||
let mut paths: Vec<String> = 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(crate) fn code_language(path: &str) -> Option<&'static str> {
|
||||
let name = Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Some common files are recognized by name rather than extension.
|
||||
match name {
|
||||
"Makefile" | "makefile" => return Some("make"),
|
||||
"CMakeLists.txt" => return Some("cmake"),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
|
||||
Some(match ext.as_str() {
|
||||
"rs" => "rust",
|
||||
"toml" => "toml",
|
||||
"json" | "jsonc" => "json",
|
||||
"py" => "python",
|
||||
"js" | "mjs" | "cjs" => "javascript",
|
||||
"ts" | "mts" | "cts" => "typescript",
|
||||
"tsx" | "jsx" => "tsx",
|
||||
"go" => "go",
|
||||
"c" | "h" => "c",
|
||||
"cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => "cpp",
|
||||
"cs" => "csharp",
|
||||
"java" => "java",
|
||||
"kt" | "kts" | "ktm" => "kotlin",
|
||||
"swift" => "swift",
|
||||
"php" | "phtml" => "php",
|
||||
"rb" => "ruby",
|
||||
"sh" | "bash" | "zsh" => "bash",
|
||||
"yml" | "yaml" => "yaml",
|
||||
"css" | "scss" | "sass" => "css",
|
||||
"html" | "htm" => "html",
|
||||
"lua" => "lua",
|
||||
"sql" => "sql",
|
||||
"proto" | "protobuf" => "proto",
|
||||
"cmake" => "cmake",
|
||||
"zig" => "zig",
|
||||
"ex" | "exs" => "elixir",
|
||||
"graphql" | "gql" => "graphql",
|
||||
"diff" | "patch" => "diff",
|
||||
"svelte" => "svelte",
|
||||
"astro" => "astro",
|
||||
"scala" => "scala",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a file path has a markdown extension.
|
||||
pub(crate) fn is_markdown_path(path: &str) -> bool {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| {
|
||||
matches!(
|
||||
ext.to_ascii_lowercase().as_str(),
|
||||
"md" | "markdown" | "mdown" | "mkdn"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) struct ShareTargets {
|
||||
/// NIP-19 `naddr1...` of the announcement, with its announced relays.
|
||||
pub(crate) naddr: String,
|
||||
/// Hex ID of the announcement event itself.
|
||||
pub(crate) event_id: String,
|
||||
/// NIP-34 coordinate `30617:<pubkey>:<repo-id>`.
|
||||
pub(crate) coordinate: String,
|
||||
/// `https://gitworkshop.dev/<naddr>`
|
||||
pub(crate) gitworkshop: String,
|
||||
/// `https://ditto.pub/<naddr>`
|
||||
pub(crate) ditto: String,
|
||||
}
|
||||
|
||||
impl ShareTargets {
|
||||
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())
|
||||
.to_bech32()
|
||||
.expect("a complete coordinate always encodes to naddr");
|
||||
|
||||
Self {
|
||||
naddr: naddr.clone(),
|
||||
event_id: announcement.event_id.to_bech32().unwrap(),
|
||||
coordinate,
|
||||
gitworkshop: format!("https://gitworkshop.dev/{naddr}"),
|
||||
ditto: format!("https://ditto.pub/{naddr}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The share dropdown menu, one row per target.
|
||||
///
|
||||
/// Each shows a compact label, the copy button and row click copy the full value.
|
||||
pub(crate) fn menu(&self, menu: PopupMenu) -> PopupMenu {
|
||||
menu.min_w(px(340.))
|
||||
.item(menu_copy_row(
|
||||
"copy-gitworkshop",
|
||||
"GitWorkshop",
|
||||
truncate_naddr_link(&self.gitworkshop, 4),
|
||||
self.gitworkshop.clone(),
|
||||
))
|
||||
.item(menu_copy_row(
|
||||
"copy-ditto",
|
||||
"Ditto",
|
||||
truncate_naddr_link(&self.ditto, 4),
|
||||
self.ditto.clone(),
|
||||
))
|
||||
.item(menu_copy_row(
|
||||
"copy-event-id",
|
||||
"Event ID",
|
||||
middle_truncate(&self.event_id, 10, 10),
|
||||
self.event_id.clone(),
|
||||
))
|
||||
.item(menu_copy_row(
|
||||
"copy-coordinate",
|
||||
"Coordinate",
|
||||
middle_truncate(&self.coordinate, 10, 10),
|
||||
self.coordinate.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Shorten an naddr link to `<url>/naddr1...[last tail chars]`.
|
||||
fn truncate_naddr_link(url: &str, tail: usize) -> String {
|
||||
let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else {
|
||||
return url.to_string();
|
||||
};
|
||||
if url.len() - end <= tail + 3 {
|
||||
return url.to_string();
|
||||
}
|
||||
format!("{}...{}", &url[..end], &url[url.len() - tail..])
|
||||
}
|
||||
|
||||
/// Width of one line-number gutter in a diff row.
|
||||
pub(crate) const GUTTER_WIDTH: f32 = 44.;
|
||||
/// Height of one row in a virtual diff list.
|
||||
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(crate) enum DiffRow {
|
||||
Hunk {
|
||||
old_start: u32,
|
||||
old_lines: u32,
|
||||
new_start: u32,
|
||||
new_lines: u32,
|
||||
},
|
||||
/// Line `line` of hunk `hunk` of the selected file's diff.
|
||||
Line { hunk: usize, line: usize },
|
||||
}
|
||||
|
||||
/// The rows of `file`'s diff, one header row per hunk then its lines.
|
||||
pub(crate) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
|
||||
let mut rows = Vec::new();
|
||||
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
|
||||
rows.push(DiffRow::Hunk {
|
||||
old_start: hunk.old_start,
|
||||
old_lines: hunk.old_lines,
|
||||
new_start: hunk.new_start,
|
||||
new_lines: hunk.new_lines,
|
||||
});
|
||||
rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line {
|
||||
hunk: hunk_ix,
|
||||
line,
|
||||
}));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// One row of the virtual diff list, a hunk header or a single line.
|
||||
pub(crate) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
|
||||
match row {
|
||||
DiffRow::Hunk {
|
||||
old_start,
|
||||
old_lines,
|
||||
new_start,
|
||||
new_lines,
|
||||
} => div()
|
||||
.px_2()
|
||||
.w_full()
|
||||
.h(px(DIFF_ROW_HEIGHT))
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.bg(cx.theme().muted)
|
||||
.border_y(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(format!(
|
||||
"@@ -{},{} +{},{} @@",
|
||||
old_start, old_lines, new_start, new_lines
|
||||
)))
|
||||
.into_any_element(),
|
||||
DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// One diff line, old and new line numbers in the gutters.
|
||||
///
|
||||
/// The content is tinted by kind, addition, deletion or context.
|
||||
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)),
|
||||
DiffLineKind::Context => None,
|
||||
};
|
||||
let gutter = cx.theme().muted_foreground;
|
||||
|
||||
// Fixed height and nowrap, the virtual list assumes every row has the same height.
|
||||
// Long lines are clipped instead of wrapped.
|
||||
h_flex()
|
||||
.w_full()
|
||||
.h(px(DIFF_ROW_HEIGHT))
|
||||
.items_center()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.when_some(bg, |this, bg| this.bg(bg))
|
||||
.child(
|
||||
div()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.flex_none()
|
||||
.pr_2()
|
||||
.text_right()
|
||||
.text_color(gutter)
|
||||
.child(line.old.map(|n| n.to_string()).unwrap_or_default()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.flex_none()
|
||||
.pr_2()
|
||||
.text_right()
|
||||
.text_color(gutter)
|
||||
.child(line.new.map(|n| n.to_string()).unwrap_or_default()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(line.text.clone()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Find a tree item by id, searching into nested children.
|
||||
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 {
|
||||
Some(item)
|
||||
} else {
|
||||
find_item(&item.children, Some(id))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The root issue events of a repo store, for the shared detail sections.
|
||||
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(crate) fn pr_roots(store: &RepoStore) -> &[Event] {
|
||||
&store.pull_requests
|
||||
}
|
||||
|
||||
/// The trigger body of the branch/tag selectors.
|
||||
///
|
||||
/// 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(crate) fn ref_selector_trigger(
|
||||
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
|
||||
icon: CustomIconName,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let muted = cx.theme().muted_foreground;
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(Icon::new(icon).small().flex_shrink_0())
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.when(ctx.selection().is_empty(), |this| this.text_color(muted))
|
||||
.child(
|
||||
ctx.selection()
|
||||
.first()
|
||||
.map(|(_, item)| item.clone())
|
||||
.or_else(|| ctx.placeholder().cloned())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
)
|
||||
.child(Caret::new(ctx.size()).text_color(muted))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Section heading of a detail sidebar, shared by the issue and PR panels.
|
||||
pub(crate) fn sidebar_title(text: &str, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(text.to_string())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Right sidebar with participants and labels of a root event, issue or PR.
|
||||
pub(crate) fn sidebar_section(
|
||||
store: &Entity<RepoStore>,
|
||||
id: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
top_gap: bool,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let store = store.read(cx);
|
||||
let Some(root) = roots(store).iter().find(|event| event.id == id) else {
|
||||
// The caller bails out when the root is missing.
|
||||
return div().into_any_element();
|
||||
};
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
|
||||
// Participants, the root author plus everyone who commented.
|
||||
let mut participants: Vec<PublicKey> = vec![root.pubkey];
|
||||
participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey));
|
||||
participants.sort_by_key(PublicKey::to_hex);
|
||||
participants.dedup();
|
||||
|
||||
// Labels are NIP-34 `t` hashtag tags on the event.
|
||||
let labels: Vec<String> = root.tags.hashtags().map(|tag| tag.to_string()).collect();
|
||||
|
||||
v_flex()
|
||||
.w(px(240.))
|
||||
.h_full()
|
||||
.flex_none()
|
||||
.px_4()
|
||||
.gap_4()
|
||||
.border_l(px(1.))
|
||||
.border_color(cx.theme().sidebar_border)
|
||||
.child(
|
||||
v_flex()
|
||||
.when(top_gap, |this| this.mt_4())
|
||||
.gap_2()
|
||||
.child(sidebar_title("Participants", cx))
|
||||
.children(participants.iter().map(|pubkey| {
|
||||
let profile = profile_store.read(cx).get(pubkey);
|
||||
let name = profile.name();
|
||||
let picture = profile.picture();
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(UserAvatar::new(name.clone()).picture(picture))
|
||||
.child(div().text_sm().truncate().text_ellipsis().child(name))
|
||||
.into_any_element()
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(sidebar_title("Labels", cx))
|
||||
.map(|this| {
|
||||
if labels.is_empty() {
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("None yet."),
|
||||
)
|
||||
} else {
|
||||
this.child(h_flex().gap_1().children({
|
||||
let mut items = vec![];
|
||||
|
||||
for label in labels.iter() {
|
||||
items.push(
|
||||
Tag::secondary()
|
||||
.outline()
|
||||
.xsmall()
|
||||
.child(SharedString::from(label)),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The comments on a root event, issue or PR, one card per comment.
|
||||
pub(crate) fn comments_section(store: &Entity<RepoStore>, 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()));
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(div().text_xs().font_semibold().child(title))
|
||||
.children(comments.iter().map(|comment| {
|
||||
let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey);
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
let age = relative_time(comment.created_at);
|
||||
let content = SharedString::from(comment.content.as_str());
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(UserAvatar::new(author.clone()).picture(picture))
|
||||
.child(author),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("commented"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.child(div().text_sm().child(content))
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// 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(crate) fn comment_form(
|
||||
store: &Entity<RepoStore>,
|
||||
root: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
comment_input: &Entity<TextareaState>,
|
||||
button_id: &'static str,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let comment_input = comment_input.clone();
|
||||
let store = store.clone();
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Textarea::new(&comment_input)
|
||||
.h_24()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.bg(cx.theme().muted),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Icon::new(CustomIconName::Markdown).small())
|
||||
.child("Markdown is supported"),
|
||||
)
|
||||
.child(
|
||||
Button::new(button_id)
|
||||
.primary()
|
||||
.label("Comment")
|
||||
.tooltip("Post comment")
|
||||
.on_click(move |_event, window, cx| {
|
||||
let content = comment_input.read(cx).value().trim().to_string();
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(root) = roots(store.read(cx))
|
||||
.iter()
|
||||
.find(|event| event.id == root)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
store.update(cx, |store, cx| {
|
||||
store.comment(&root, content, cx);
|
||||
});
|
||||
comment_input.update(cx, |input, cx| {
|
||||
input.set_value("", window, cx);
|
||||
});
|
||||
}),
|
||||
),
|
||||
)
|
||||
.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::*;
|
||||
|
||||
#[test]
|
||||
fn builds_nested_tree_from_flat_entries() {
|
||||
let entries = vec![
|
||||
PathBuf::from("src"),
|
||||
PathBuf::from("src/lib.rs"),
|
||||
PathBuf::from("README.md"),
|
||||
PathBuf::from("docs/guide.md"),
|
||||
];
|
||||
|
||||
let items = build_tree_items(&entries);
|
||||
|
||||
// Input order is preserved, dirs-first as produced by worktree_entries.
|
||||
assert_eq!(items.len(), 3);
|
||||
assert_eq!(items[0].label, "src");
|
||||
assert_eq!(items[0].id, "src");
|
||||
assert_eq!(items[0].children.len(), 1);
|
||||
assert_eq!(items[0].children[0].label, "lib.rs");
|
||||
assert_eq!(items[0].children[0].id, "src/lib.rs");
|
||||
|
||||
assert_eq!(items[1].label, "README.md");
|
||||
assert_eq!(items[1].id, "README.md");
|
||||
|
||||
assert_eq!(items[2].label, "docs");
|
||||
assert_eq!(items[2].children[0].label, "guide.md");
|
||||
assert_eq!(items[2].children[0].id, "docs/guide.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_builder_handles_deep_nesting() {
|
||||
let entries = vec![
|
||||
PathBuf::from("a"),
|
||||
PathBuf::from("a/b"),
|
||||
PathBuf::from("a/b/c.txt"),
|
||||
];
|
||||
|
||||
let items = build_tree_items(&entries);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].children[0].id, "a/b");
|
||||
assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_builder_merges_shared_prefixes() {
|
||||
// File children of a directory arrive after other directories' entries.
|
||||
// The worktree list is dirs-first globally.
|
||||
// The shared prefix must still resolve to one node.
|
||||
let entries = vec![
|
||||
PathBuf::from("a/x.txt"),
|
||||
PathBuf::from("b/y.txt"),
|
||||
PathBuf::from("a/z.txt"),
|
||||
];
|
||||
|
||||
let items = build_tree_items(&entries);
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].label, "a");
|
||||
assert_eq!(items[0].children.len(), 2);
|
||||
assert_eq!(items[1].label, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_seeds_convert_to_tree_items() {
|
||||
let entries = vec![
|
||||
PathBuf::from("src"),
|
||||
PathBuf::from("src/main.rs"),
|
||||
PathBuf::from("README.md"),
|
||||
];
|
||||
|
||||
let items: Vec<TreeItem> = tree_items(build_tree_items(&entries), false);
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].label, "src");
|
||||
assert_eq!(items[0].children.len(), 1);
|
||||
assert_eq!(items[0].children[0].label, "main.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_language_maps_extensions_and_names() {
|
||||
assert_eq!(code_language("src/main.rs"), Some("rust"));
|
||||
assert_eq!(code_language("Cargo.toml"), Some("toml"));
|
||||
assert_eq!(code_language("app.js"), Some("javascript"));
|
||||
assert_eq!(code_language("index.tsx"), Some("tsx"));
|
||||
assert_eq!(code_language("Makefile"), Some("make"));
|
||||
assert_eq!(code_language("CMakeLists.txt"), Some("cmake"));
|
||||
assert_eq!(code_language("data.csv"), None);
|
||||
assert_eq!(code_language("LICENSE"), None);
|
||||
assert_eq!(code_language("README.md"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naddr_link_keeps_url_and_tail() {
|
||||
assert_eq!(
|
||||
truncate_naddr_link("https://gitworkshop.dev/naddr1qqqxyzabc1234", 4),
|
||||
"https://gitworkshop.dev/naddr1...1234"
|
||||
);
|
||||
// Without the naddr1 prefix, unchanged.
|
||||
assert_eq!(
|
||||
truncate_naddr_link("https://example.com/x", 4),
|
||||
"https://example.com/x"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Self>) -> 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<Self>) {
|
||||
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<Self>) {
|
||||
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<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let rels: Vec<PathBuf> = 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<Self>) {
|
||||
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<Result<(), Error>> = 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<Self>,
|
||||
) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, WeakEntity, Window, px};
|
||||
use gpui_base::h_flex;
|
||||
use gpui_base::input::TextareaState;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Input, InputState, Textarea};
|
||||
use gpui_component::{ActiveTheme, Disableable, WindowExt};
|
||||
use settings::SettingsStore;
|
||||
use signed_state::Backend;
|
||||
|
||||
use super::RepoDetailView;
|
||||
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||
use crate::views::sidebar::grasp_servers::{
|
||||
GraspServersState, grasp_servers_field, load_user_grasp_servers,
|
||||
};
|
||||
|
||||
/// Shared state for the Init dialog, so async results can be rendered.
|
||||
pub type InitRepoState = DialogProgress;
|
||||
|
||||
/// Open the Init dialog for the local repository at `local_path`.
|
||||
pub fn open(
|
||||
local_path: PathBuf,
|
||||
view: WeakEntity<RepoDetailView>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let default_name = local_path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
let grasp_settings = SettingsStore::global(cx)
|
||||
.read(cx)
|
||||
.settings()
|
||||
.grasp_servers
|
||||
.clone();
|
||||
|
||||
let state = cx.new(|_| InitRepoState::default());
|
||||
let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings));
|
||||
|
||||
let relay_input = cx.new(|cx| InputState::new(window, cx).placeholder("relay.example.com"));
|
||||
let name_input = cx.new(|cx| InputState::new(window, cx).default_value(default_name));
|
||||
let desc_input = cx.new(|cx| {
|
||||
TextareaState::new(window, cx)
|
||||
.auto_grow(3, 5)
|
||||
.placeholder("Short description")
|
||||
});
|
||||
|
||||
// Load the user's grasp servers.
|
||||
load_user_grasp_servers(grasp_state.clone(), window, cx);
|
||||
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
const DESC: &str = "Publish this local repository to Nostr.";
|
||||
|
||||
let name_input = name_input.clone();
|
||||
let desc_input = desc_input.clone();
|
||||
let relay_input = relay_input.clone();
|
||||
let state = state.clone();
|
||||
let grasp_state = grasp_state.clone();
|
||||
let local_path = local_path.clone();
|
||||
let view = view.clone();
|
||||
|
||||
dialog
|
||||
.width(px(520.))
|
||||
.margin_top(px(50.))
|
||||
.content(move |content, _window, cx| {
|
||||
let busy = state.read(cx).busy;
|
||||
let error = state.read(cx).error.clone();
|
||||
|
||||
content
|
||||
.child(
|
||||
DialogHeader::new()
|
||||
.child(DialogTitle::new().child("Initialize repository"))
|
||||
.child(DialogDescription::new().child(DESC)),
|
||||
)
|
||||
.child(
|
||||
v_form()
|
||||
.child(
|
||||
field()
|
||||
.label("Repository name")
|
||||
.description("Max 100 characters")
|
||||
.required(true)
|
||||
.child(Input::new(&name_input).readonly(true)),
|
||||
)
|
||||
.child(
|
||||
field()
|
||||
.label("Description")
|
||||
.child(Textarea::new(&desc_input)),
|
||||
)
|
||||
.child(
|
||||
field()
|
||||
.label("Folder")
|
||||
.description("The local repository being published")
|
||||
.child(
|
||||
h_flex()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.bg(cx.theme().muted)
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(local_path.display().to_string()),
|
||||
),
|
||||
)
|
||||
.child(grasp_servers_field(&grasp_state, &relay_input, cx)),
|
||||
)
|
||||
.children(error_row(&error, cx))
|
||||
.child(
|
||||
DialogFooter::new().justify_end().child(
|
||||
Button::new("init")
|
||||
.primary()
|
||||
.label("Initialize")
|
||||
.icon(CustomIconName::Init)
|
||||
.tooltip("Publish to Nostr")
|
||||
.loading(busy)
|
||||
.disabled(busy)
|
||||
.on_click({
|
||||
let name_input = name_input.clone();
|
||||
let desc_input = desc_input.clone();
|
||||
let state = state.clone();
|
||||
let grasp_state = grasp_state.clone();
|
||||
let local_path = local_path.clone();
|
||||
let view = view.clone();
|
||||
|
||||
move |_ev, window, cx| {
|
||||
init_repository(
|
||||
local_path.clone(),
|
||||
(name_input.clone(), desc_input.clone()),
|
||||
state.clone(),
|
||||
grasp_state.clone(),
|
||||
view.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Run the init flow.
|
||||
///
|
||||
/// Closes the dialog and switches the repository into NIP-34 mode on success.
|
||||
fn init_repository(
|
||||
local_path: PathBuf,
|
||||
inputs: (Entity<InputState>, Entity<TextareaState>),
|
||||
state: Entity<InitRepoState>,
|
||||
grasp_state: Entity<GraspServersState>,
|
||||
view: WeakEntity<RepoDetailView>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let (name_input, desc_input) = inputs;
|
||||
let name = name_input.read(cx).value().trim().to_owned();
|
||||
let description = desc_input.read(cx).value().trim().to_owned();
|
||||
let servers = grasp_state.read(cx).grasp_servers.clone();
|
||||
|
||||
if name.is_empty() {
|
||||
state.update(cx, |state, _| state.fail("Repository name is required"));
|
||||
return;
|
||||
}
|
||||
|
||||
if servers.is_empty() {
|
||||
state.update(cx, |state, _| state.fail("Add at least one grasp server"));
|
||||
return;
|
||||
}
|
||||
|
||||
state.update(cx, |state, _| state.begin());
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
backend.publish_local_repo(local_path.clone(), &name, &description, servers, cx)
|
||||
});
|
||||
|
||||
let handle = window.window_handle();
|
||||
let state = state.clone();
|
||||
let view = view.clone();
|
||||
|
||||
cx.spawn(async move |cx| match task.await {
|
||||
Ok(announcement) => {
|
||||
cx.update_window(handle, |_, window, cx| {
|
||||
window.close_dialog(cx);
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| {
|
||||
this.apply_announcement(announcement, cx);
|
||||
});
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update_window(handle, |_, _window, cx| {
|
||||
state.update(cx, |state, _| state.fail(e.to_string()));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
@@ -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<TreeItemSeed>,
|
||||
/// Relative paths of the worktree entries, for [`RepoDetailView::worktree_paths`].
|
||||
entries: Vec<PathBuf>,
|
||||
readme_path: Option<PathBuf>,
|
||||
readme: Option<Vec<u8>>,
|
||||
worktree: Option<PathBuf>,
|
||||
branches: Vec<String>,
|
||||
tags: Vec<String>,
|
||||
current_branch: Option<String>,
|
||||
head_commit: Option<FileCommit>,
|
||||
}
|
||||
|
||||
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>) {
|
||||
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<Result<(), Error>> = 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<Url> = 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<Result<(), Error>> = 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<SharedString> = branches.iter().map(Into::into).collect();
|
||||
let tags: Vec<SharedString> = tags.iter().map(Into::into).collect();
|
||||
|
||||
let branches_changed = Self::sync_ref_selector(
|
||||
&this.branch_select,
|
||||
&mut this.ref_branches,
|
||||
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<Self>) {
|
||||
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<SharedString> = branches.into_iter().map(Into::into).collect();
|
||||
let tags: Vec<SharedString> = tags.into_iter().map(Into::into).collect();
|
||||
|
||||
Self::sync_ref_selector(
|
||||
&self.branch_select,
|
||||
&mut self.ref_branches,
|
||||
branches,
|
||||
current_branch.map(Into::into),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::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<ComboboxState<SearchableVec<SharedString>>>,
|
||||
cached: &mut Vec<SharedString>,
|
||||
items: Vec<SharedString>,
|
||||
selected: Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let items_changed = *cached != items;
|
||||
let selection_changed = selected
|
||||
.as_ref()
|
||||
.is_some_and(|value| select.read(cx).selected_value().as_ref() != Some(value));
|
||||
|
||||
if !items_changed && !selection_changed {
|
||||
return false;
|
||||
}
|
||||
|
||||
select.update(cx, |state, cx| {
|
||||
if items_changed {
|
||||
state.set_items(SearchableVec::from(items.clone()), window, cx);
|
||||
}
|
||||
if let Some(value) = selected
|
||||
&& (items_changed || selection_changed)
|
||||
{
|
||||
state.set_selected_values(std::slice::from_ref(&value), window, cx);
|
||||
}
|
||||
});
|
||||
*cached = items;
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// 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<Self>) {
|
||||
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<Result<(), Error>> = 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<RepoData, Error> {
|
||||
let entries = signed_git::worktree_entries(repo)?;
|
||||
let tree = build_tree_items(&entries);
|
||||
let readme_path = signed_git::find_readme(repo)?;
|
||||
let readme = match &readme_path {
|
||||
Some(path) => signed_git::worktree_read(repo, path)?,
|
||||
None => None,
|
||||
};
|
||||
let worktree = repo.workdir().map(Path::to_path_buf);
|
||||
// Ref listing is auxiliary UI.
|
||||
// A broken ref must not prevent the explorer from loading.
|
||||
// Failures degrade to empty selectors.
|
||||
let (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,
|
||||
})
|
||||
}
|
||||
@@ -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<DockArea>,
|
||||
/// Snapshot taken at open time.
|
||||
///
|
||||
/// `None` for local repositories that haven't been published yet.
|
||||
initial: Option<Announcement>,
|
||||
/// Per-repository nostr store, holding announcement, issues, PRs and statuses.
|
||||
///
|
||||
/// `None` until a local repository is initialized to NIP-34.
|
||||
store: Option<Entity<RepoStore>>,
|
||||
/// 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<PathBuf>,
|
||||
/// File explorer state, the worktree of the local clone.
|
||||
tree_state: Entity<TreeState>,
|
||||
/// Root of the local clone, for reading files on demand.
|
||||
worktree: Option<PathBuf>,
|
||||
/// 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<String>,
|
||||
/// Markdown document currently in the preview pane, README or a file.
|
||||
md: Option<MarkdownView>,
|
||||
/// Code file currently in the preview pane.
|
||||
code: Option<CodeView>,
|
||||
readme_name: Option<SharedString>,
|
||||
/// Currently previewed file, a relative path, and its contents.
|
||||
selected_file: Option<SharedString>,
|
||||
files: HashMap<String, FileContent>,
|
||||
/// Paths of cached previews, oldest first.
|
||||
/// Feeds the eviction caps in [`Self::evict_previews`].
|
||||
file_order: VecDeque<String>,
|
||||
/// Total text bytes held by [`Self::files`].
|
||||
preview_bytes: usize,
|
||||
/// Reads in flight, to avoid duplicate loads.
|
||||
loading_files: HashSet<String>,
|
||||
/// Latest commit touching a previewed file or the README, keyed by path.
|
||||
commits: HashMap<String, FileCommit>,
|
||||
/// Paths queued for the next batched commit query, see [`Self::load_commits`].
|
||||
pending_commits: Vec<String>,
|
||||
/// 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<CommitList>,
|
||||
/// Commit walk in flight.
|
||||
loading_all_commits: bool,
|
||||
/// Virtual list state of the Commits tab.
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// A clone/fetch is in flight.
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
/// Commit HEAD currently points to, shown in the header button.
|
||||
head_commit: Option<FileCommit>,
|
||||
/// Branch selector in the header, local branches, searchable.
|
||||
branch_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
/// Tag selector in the header, tags, searchable.
|
||||
tag_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
/// Branch names currently in `branch_select`, for cheap no-op detection.
|
||||
ref_branches: Vec<SharedString>,
|
||||
/// Tag names currently in `tag_select`, for cheap no-op detection.
|
||||
ref_tags: Vec<SharedString>,
|
||||
/// 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<Subscription>,
|
||||
/// `(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<String>,
|
||||
/// The global checkouts store's ready-to-contribute statuses of this
|
||||
/// repository, last seen when they drove a render.
|
||||
///
|
||||
/// The store notifies on any recompute pass; the observer re-renders this
|
||||
/// panel only when these slices changed.
|
||||
ready_statuses: Vec<CheckoutStatus>,
|
||||
/// The global checkouts store's ready-to-push statuses of this repository,
|
||||
/// last seen when they drove a render.
|
||||
push_statuses: Vec<CheckoutStatus>,
|
||||
/// Upstream repository, from this fork's `u` tag, the user asked to open.
|
||||
/// Its announcement is still being fetched.
|
||||
pending_upstream: Option<RepoAddr>,
|
||||
}
|
||||
|
||||
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<DockArea>,
|
||||
initial: Announcement,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<DockArea>,
|
||||
local_path: PathBuf,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<DockArea>,
|
||||
initial: Option<Announcement>,
|
||||
store: Option<Entity<RepoStore>>,
|
||||
local_path: Option<PathBuf>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<ComboboxState<SearchableVec<SharedString>>> = cx.new(|cx| {
|
||||
ComboboxState::new(
|
||||
SearchableVec::new(Vec::<SharedString>::new()),
|
||||
Vec::new(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.searchable(true)
|
||||
});
|
||||
let tag_select: Entity<ComboboxState<SearchableVec<SharedString>>> = cx.new(|cx| {
|
||||
ComboboxState::new(
|
||||
SearchableVec::new(Vec::<SharedString>::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<Self>) -> impl IntoElement {
|
||||
self.display_name(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> 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<Self>) -> 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)),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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<Self>,
|
||||
) {
|
||||
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<Result<(), Error>> = 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<ComboboxState<SearchableVec<SharedString>>>,
|
||||
previous: &Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
select.update(cx, |state, cx| match previous {
|
||||
Some(value) => state.set_selected_values(std::slice::from_ref(value), window, cx),
|
||||
None => state.clear_selection(cx),
|
||||
});
|
||||
}
|
||||
|
||||
/// 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<Self>) {
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: gpui::Task<Result<(), Error>> = 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<Self>) {
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: gpui::Task<Result<(), Error>> = 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<String> = snapshot
|
||||
.entries
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
|
||||
let mut previewed: Vec<String> = 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();
|
||||
}
|
||||
}
|
||||
@@ -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<Self>,
|
||||
) {
|
||||
// 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<RepoStore>, cx: &mut Context<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) -> 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user