Files
signed/crates/workspace/src/views/repo/mod.rs
T
reya ec459d6507
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
.
2026-09-14 11:03:02 +07:00

2109 lines
82 KiB
Rust

use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use anyhow::Error;
use assets::CustomIconName;
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
use gix::Repository;
use gpui::prelude::*;
use gpui::{
Action, Anchor, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, PathPromptOptions, Render, SharedString, Subscription, Task, WeakEntity, Window,
div, px, relative, transparent_white,
};
use gpui_base::{Button as BaseButton, Disableable, Popover};
use gpui_component::alert::Alert;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::combobox::{Combobox, ComboboxEvent};
use gpui_component::menu::{DropdownMenu, PopupMenu};
use gpui_component::spinner::Spinner;
use gpui_component::{
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex,
};
use nostr::nips::nip19::Nip19Coordinate;
use nostr::prelude::{RelayUrl, ToBech32, Url};
use signed_core::{Announcement, RepoAddr, RepoStatus};
use signed_git::FileCommit;
use signed_state::{
Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind,
ProfileStore, RepoListStore, RepoStore, ensure_repo_mirror, open_repo_mirror,
pr_proposes_checkout,
};
use signed_ui::{
CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row, menu_copy_row, middle_truncate,
ref_selector_trigger,
};
mod about;
mod actions;
mod banners;
mod files;
mod history;
mod init_dialog;
mod refs;
pub(crate) use actions::{RepoItem, open_repo_item, open_repo_panel};
use self::banners::Banners;
use self::files::RepoFilesView;
use self::history::RepoHistoryView;
use self::refs::RefSwitcher;
use crate::views::issues::{IssuesView, open_new_issue_dialog};
use crate::views::pull_requests::PullRequestsView;
use crate::views::pull_requests::new::open_new_pull_panel;
use crate::views::repo::about::open_about_dialog;
use crate::views::send_patch::open_send_patch_panel;
use crate::views::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths};
#[derive(Clone, Copy, PartialEq, Eq)]
enum RefKind {
/// HEAD stays attached.
Branch,
/// HEAD becomes detached.
Tag,
}
#[derive(Clone, Action, PartialEq, Eq)]
#[action(namespace = repo, no_json)]
pub(crate) enum RepoAction {
NewIssue,
NewPR,
SendPatch,
About,
Push,
Delete,
}
pub struct RepoDetailView {
focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
/// A repository opened by address alone starts without an announcement.
repo_started: bool,
/// The Files tab, which owns the explorer, previews and the per-file commit map.
files: Entity<RepoFilesView>,
/// The checked-out worktree path, shared by the Files tab and the commit list.
worktree: Option<PathBuf>,
/// The active tab, either 0 (Files tree) or 1 (Commits).
active_tab: usize,
history: Entity<RepoHistoryView>,
loading: bool,
error: Option<SharedString>,
head_commit: Option<FileCommit>,
refs: RefSwitcher,
banners: Banners,
tasks: Vec<Task<Result<(), Error>>>,
_subscriptions: Vec<Subscription>,
}
struct RepoData {
tree: Vec<TreeItemSeed>,
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 {
pub fn new(
dock_area: WeakEntity<DockArea>,
addr: RepoAddr,
hint: Option<Announcement>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let store = cx.new(|cx| RepoStore::new(addr, hint, cx));
Self::new_common(dock_area, store, window, cx)
}
pub fn new_local(
dock_area: WeakEntity<DockArea>,
local_path: PathBuf,
nip34: Option<Nip34Binding>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let store = cx.new(move |_cx| RepoStore::new_local(local_path, nip34));
Self::new_common(dock_area, store, window, cx)
}
/// A local repository whose detected binding matches an announcement.
///
/// Opens as the announced repository with the local worktree attached.
pub fn new_local_announced(
dock_area: WeakEntity<DockArea>,
announcement: Announcement,
local_path: PathBuf,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let addr = announcement.addr();
let store = cx.new(move |cx| RepoStore::from_worktree(addr, announcement, local_path, cx));
Self::new_common(dock_area, store, window, cx)
}
fn new_common(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let checkouts = CheckoutsStore::global(cx);
let files = cx.new(RepoFilesView::new);
let history = cx.new(|_cx| RepoHistoryView::new(store.clone(), dock_area.clone()));
let refs = RefSwitcher::new(window, cx);
let mut subscriptions = vec![
cx.subscribe_in(
&refs.branch_select,
window,
|this, _state, event, window, cx| {
if let ComboboxEvent::Change(values) = event
&& let Some(name) = values.first()
{
this.switch_ref(RefKind::Branch, name.clone(), window, cx);
}
},
),
cx.subscribe_in(
&refs.tag_select,
window,
|this, _state, event, window, cx| {
if let ComboboxEvent::Change(values) = event
&& let Some(name) = values.first()
{
this.switch_ref(RefKind::Tag, name.clone(), window, cx);
}
},
),
];
// The ready-to-contribute and ready-to-push banners are driven by the global checkouts store.
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);
});
let mut view = Self {
dock_area,
store: store.clone(),
repo_started: false,
files,
worktree: None,
active_tab: 0,
history,
loading: true,
error: None,
head_commit: None,
refs,
tasks: Vec::new(),
banners: Banners::default(),
focus_handle: cx.focus_handle(),
_subscriptions: subscriptions,
};
view.attach_store(&store, window, cx);
view
}
pub(crate) fn apply_announcement(
&mut self,
announcement: Announcement,
cx: &mut Context<Self>,
) {
let path = self.store.read(cx).path.clone();
if let Some(path) = path {
LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx));
}
self.store
.update(cx, |store, cx| store.announce(announcement, cx));
// The new address needs its ready-to-contribute statuses requested.
self.refresh_ready_statuses(cx);
cx.notify();
}
pub(super) fn attach_store(
&mut self,
store: &Entity<RepoStore>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self._subscriptions
.push(cx.observe_in(store, window, |this, store, window, cx| {
this.refresh_ready_statuses(cx);
if !this.repo_started && store.read(cx).announcement.is_some() {
this.load_repo(window, cx);
}
cx.notify();
}));
self.refresh_ready_statuses(cx);
}
fn refresh_ready_statuses(&mut self, cx: &mut Context<Self>) {
let Some(addr) = self.store.read(cx).addr().cloned() else {
return;
};
let head = self.store.read(cx).head.clone();
let (requested, requested_head) = self.banners.ready_requested_at();
if requested && requested_head == &head {
return;
}
self.banners.mark_ready_requested(head.clone());
let backend = Backend::global(cx);
let checkout = CheckoutsStore::global(cx);
let owned = backend
.read(cx)
.current_user()
.is_some_and(|user| self.store.read(cx).is_author(&user));
checkout.update(cx, |store, cx| {
// The ready statuses keep the fast poll running while the panel is open.
// The sidebar's push watch alone polls slower.
store.request_statuses(&addr, head, cx);
if owned {
store.request_push_statuses(&addr, cx);
}
});
}
pub(super) fn refresh_statuses(&mut self, cx: &mut Context<Self>) -> bool {
let Some(addr) = self.store.read(cx).addr().cloned() else {
return false;
};
let checkouts = CheckoutsStore::global(cx).read(cx);
let ready_statuses = checkouts.ready_statuses_of(&addr);
let push_statuses = checkouts.push_statuses_of(&addr);
self.banners.set_statuses(ready_statuses, push_statuses)
}
/// The latest announcement of the repository,
///
/// `None` while local-only or until the store's first pass loads it.
fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> {
self.store.read(cx).announcement.as_ref()
}
/// Load the repository and populate the file explorer.
pub(super) fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true;
self.error = None;
cx.notify();
let (announcement, local_path) = {
let store = self.store.read(cx);
(store.announcement.clone(), store.path.clone())
};
// A repository with a local worktree shows it directly. An announced one
// still loads its announcement and activity from the store, which is
// subscribed to the relays independently.
if let Some(local_path) = local_path {
self.repo_started = true;
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(())
});
self.tasks.push(task);
return;
}
let Some(announcement) = announcement else {
return;
};
self.repo_started = true;
let addr = announcement.addr();
let clone_urls: Vec<Url> = announcement.clone.clone();
let disk = {
let addr = addr.clone();
cx.background_spawn(async move {
match open_repo_mirror(&addr)? {
Some(repo) => Ok(Some(load_repo_data(&repo)?)),
None => Ok(None),
}
})
};
let task: Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
let disk = disk.await;
let had_clone = matches!(&disk, Ok(Some(_)));
let data = match disk {
Ok(Some(data)) => Ok(data),
Ok(None) => {
let addr = addr.clone();
let clone_urls = clone_urls.clone();
cx.background_spawn(async move {
let repo = ensure_repo_mirror(&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.
if !had_clone {
return Ok(());
}
let refresh = {
let addr = addr.clone();
cx.background_spawn(async move {
let Some(repo) = open_repo_mirror(&addr)? else {
return Ok::<_, Error>(None);
};
// Best-effort, a fetch failure, e.g. offline, keeps the cached state.
signed_git::fetch_all(&repo).ok();
let worktree = repo.workdir().map(Path::to_path_buf);
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 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 = this.refs.set_branches(
branches,
current_branch.map(Into::into),
window,
cx,
);
let tags_changed = this.refs.set_tags(tags, window, cx);
let new_head_commit = head_commit.as_ref().map(|c| &c.id);
let current_head_commit = this.head_commit.as_ref().map(|c| &c.id);
let head_changed = new_head_commit != current_head_commit;
this.head_commit = head_commit;
if head_changed {
this.history.update(cx, |history, cx| history.reload(cx));
}
if moved {
this.catch_up_worktree(cx);
}
if branches_changed || tags_changed || head_changed {
cx.notify();
}
}
})?;
Ok(())
});
self.tasks.push(task);
}
fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context<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.clone());
self.head_commit = head_commit;
self.files.update(cx, |files, cx| {
files.set_worktree(worktree.clone());
files.apply_entries(tree, sorted_worktree_paths(&entries), cx);
files.set_readme(readme_path, readme, cx);
});
self.history.update(cx, |history, cx| {
history.set_worktree(Some(worktree));
history.reload(cx);
});
let branches: Vec<SharedString> = branches.into_iter().map(Into::into).collect();
let tags: Vec<SharedString> = tags.into_iter().map(Into::into).collect();
self.refs
.set_branches(branches, current_branch.map(Into::into), window, cx);
self.refs.set_tags(tags, window, cx);
}
pub(super) fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let store = self.store.clone();
let name = {
let Some(announcement) = self.announcement(cx) else {
return;
};
let addr = announcement.addr();
// Directory name, the display name falling back to the repo id.
let name = announcement
.name
.as_ref()
.map(|name| name.to_string())
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| addr.identifier.clone());
let name = signed_git::sanitize_path_component(&name);
if name.is_empty() {
"repository".to_owned()
} else {
name
}
};
let prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Clone".into()),
});
let task: gpui::Task<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(())
});
self.tasks.push(task);
}
fn switch_ref<T>(&mut self, kind: RefKind, name: T, window: &mut Window, cx: &mut Context<Self>)
where
T: Into<SharedString>,
{
if self.refs.switching_ref {
return;
}
let Some(worktree) = self.worktree.clone() else {
return;
};
let name = name.into();
let previous_branch = self.refs.branch_select.read(cx).selected_value();
let previous_tag = self.refs.tag_select.read(cx).selected_value();
match kind {
RefKind::Branch => {
self.refs
.tag_select
.update(cx, |state, cx| state.clear_selection(cx));
}
RefKind::Tag => {
self.refs
.branch_select
.update(cx, |state, cx| state.clear_selection(cx));
}
}
self.refs.switching_ref = true;
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.refs.switching_ref = false;
this.refs.restore_selection(
&this.refs.branch_select,
&previous_branch,
window,
cx,
);
this.refs.restore_selection(
&this.refs.tag_select,
&previous_tag,
window,
cx,
);
}
}
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
fn reload_worktree(&mut self, cx: &mut Context<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.refs.switching_ref = false;
match result {
Ok((snapshot, tree, paths)) => {
this.head_commit = snapshot.head_commit;
let readme_path = snapshot.readme_path;
let readme = snapshot.readme;
this.files.update(cx, |files, cx| {
files.clear_previews();
files.apply_entries(tree, paths, cx);
files.set_readme(readme_path, readme, cx);
});
this.history.update(cx, |history, cx| history.reload(cx));
}
Err(error) => {
this.error = Some(error.to_string().into());
this.head_commit = None;
// The tree may show files that no longer exist.
this.files.update(cx, |files, cx| {
files.apply_entries(Vec::new(), Vec::new(), cx);
});
}
}
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
/// Refresh the file explorer, previews and commit list after the mirror caught up with the remote.
pub(super) fn catch_up_worktree(&mut self, cx: &mut Context<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);
let files_changed = this
.files
.update(cx, |files, cx| files.catch_up(&snapshot, tree, paths, cx));
// A fast-forward of a branch other than the checked-out
// one leaves the worktree untouched. Rebuilding the tree
// and re-parsing the README would flash the panel for
// nothing, so it is a no-op.
if !head_changed && !files_changed {
log::debug!("repo detail catch_up_worktree: no-op");
return;
}
this.head_commit = snapshot.head_commit;
if head_changed {
this.history.update(cx, |history, cx| history.reload(cx));
}
cx.notify();
}
Err(error) => {
this.error = Some(error.to_string().into());
cx.notify();
}
}
})?;
Ok(())
});
self.tasks.push(task);
}
pub(super) fn push_repository(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.error = None;
cx.notify();
let task = self.store.update(cx, |store, cx| store.push_repository(cx));
self.tasks.push(task);
}
pub(super) fn push_unpushed_checkout(
&mut self,
path: PathBuf,
window: &mut Window,
cx: &mut Context<Self>,
) {
let store = self.store.clone();
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| {
let push = this.update_in(cx, |_this, _window, cx| {
store.update(cx, |store, cx| store.push_checkout(path.clone(), cx))
})?;
if let Ok(()) = push.await {
this.update_in(cx, |this, window, cx| {
this.load_repo(window, cx);
})?;
}
Ok(())
});
self.tasks.push(task);
}
/// Delete the repository from nostr, announcement, state and activity.
pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let task = self
.store
.update(cx, |store, cx| store.delete_repository(cx));
self.tasks.push(task);
}
pub(super) fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.store.read(cx).addr().is_none() {
return;
}
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
let store = self.store.clone();
let panel = cx.new(|cx| IssuesView::new(self.dock_area.clone(), store, window, cx));
dock_area.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel_handle(panel), window, cx);
});
}
pub(super) fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.store.read(cx).addr().is_none() {
return;
}
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
let store = self.store.clone();
let panel = cx.new(|cx| PullRequestsView::new(self.dock_area.clone(), store, window, cx));
dock_area.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel_handle(panel), window, cx);
});
}
/// Open the upstream repository, the `u` tag of this fork's announcement.
///
/// The announcement may not be in the local database yet. The panel opens
/// from the address and fills in when the store loads it; the store's
/// `subscribe_remote` fetches it from the bootstrap relays.
pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(addr) = self
.announcement(cx)
.and_then(|announcement| announcement.upstream.as_ref())
.and_then(|upstream| upstream.addr.clone())
else {
return;
};
open_repo_panel(&self.dock_area, &addr, None, window, &mut *cx);
}
/// Open the dialog that publishes the local repository to NIP-34.
pub(super) fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(local_path) = self.store.read(cx).path.clone() else {
return;
};
let view = cx.entity().downgrade();
init_dialog::open(local_path, view, window, cx);
}
pub(super) fn render_header(&mut self, cx: &mut Context<Self>) -> AnyElement {
if self.store.read(cx).addr().is_none() {
return self.render_local_header(cx);
}
let store = self.store.read(cx);
let issue_count = SharedString::from(store.issue_count().to_string());
let pr_count = SharedString::from(store.pull_request_count().to_string());
// Busy flags are owned by the store; observers re-render on their changes.
let pushing = store.pushing;
let cloning = store.cloning;
let Some(source) = store.announcement.as_ref() else {
return div().into_any_element();
};
let nip05 = ProfileStore::global(cx)
.read(cx)
.get(&source.owner)
.metadata()
.nip05
.clone()
.filter(|nip05| !nip05.trim().is_empty());
let announcement = Rc::new(source.clone());
let share = Rc::new(ShareTargets::from_announcement(&announcement));
let nostr_url = nostr_clone_url(&announcement, nip05.as_deref());
let ngit_command = SharedString::from(format!("git clone {nostr_url}"));
let nak_command = SharedString::from(format!("nak git clone {nostr_url}"));
let git_commands = Rc::new(announcement.clone_urls());
let name = repo_display_name(self.store.read(cx));
let description = announcement.description();
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
v_flex()
.on_action(
cx.listener(|this, action: &RepoAction, window, cx| match action {
RepoAction::NewIssue => {
open_new_issue_dialog(this.store.clone(), window, cx);
}
RepoAction::NewPR => {
open_new_pull_panel(this.dock_area.clone(), this.store.clone(), window, cx);
}
RepoAction::SendPatch => {
open_send_patch_panel(
this.dock_area.clone(),
this.store.clone(),
window,
cx,
);
}
RepoAction::About => {
if let Some(announcement) = this.announcement(cx) {
open_about_dialog(announcement.clone(), window, cx);
}
}
RepoAction::Push => this.push_repository(window, cx),
RepoAction::Delete => this.delete_repository(window, cx),
}),
)
.p_4()
.w_full()
.gap_8()
.border_b_1()
.border_color(cx.theme().border)
.child(
h_flex()
.w_full()
.gap_4()
.items_start()
.justify_between()
.child(
v_flex()
.flex_1()
.min_w_0()
.gap_1()
.child(
h_flex()
.gap_2()
.min_h_8()
.font_semibold()
.child(avatar.size_6())
.child(name),
)
.child(
div()
.min_w_0()
.text_sm()
.text_color(cx.theme().muted_foreground)
.line_clamp(2)
.line_height(relative(1.25))
.text_ellipsis()
.child(description),
)
.when_some(fork_row(&announcement, cx), |this, row| this.child(row))
.child(
h_flex()
.mt_2()
.w_full()
.gap_0p5()
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.font_semibold()
.child("Maintainers:"),
)
.child(self.render_maintainers(cx)),
),
)
.child(
h_flex()
.flex_none()
.gap_2()
.justify_end()
.child(
DropdownButton::new("issues")
.action(
BaseButton::new("issues-open")
.child(
h_flex()
.h_8()
.px_2()
.gap_1()
.rounded(cx.theme().radius)
.bg(cx.theme().secondary)
.hover(|this| {
this.bg(cx.theme().secondary_hover)
})
.text_sm()
.text_color(cx.theme().secondary_foreground)
.child(Icon::new(CustomIconName::GitIssueDone))
.child("Issues")
.child(
div()
.mx_1()
.h_5()
.w_px()
.bg(cx.theme().border.darken(0.1)),
)
.child(issue_count),
)
.on_click(cx.listener(|this, _event, window, cx| {
this.open_issue_detail(window, cx);
})),
)
.dropdown_menu(|menu, _, _| {
menu.menu_element(Box::new(RepoAction::NewIssue), |_, _| {
h_flex()
.gap_2()
.text_sm()
.child(Icon::new(IconName::Plus))
.child("New issue")
})
}),
)
.child(
DropdownButton::new("prs")
.action(
BaseButton::new("prs-open")
.child(
h_flex()
.h_8()
.px_2()
.gap_1()
.rounded(cx.theme().radius)
.bg(cx.theme().secondary)
.hover(|this| {
this.bg(cx.theme().secondary_hover)
})
.text_sm()
.text_color(cx.theme().secondary_foreground)
.child(Icon::new(
CustomIconName::GitPullRequest,
))
.child("Pull Requests")
.child(
div()
.mx_1()
.h_5()
.w_px()
.bg(cx.theme().border.darken(0.1)),
)
.child(pr_count),
)
.on_click(cx.listener(|this, _event, window, cx| {
this.open_pull_request_detail(window, cx);
})),
)
.dropdown_menu(|menu, _, _| {
menu.menu_element(Box::new(RepoAction::NewPR), |_, _| {
h_flex()
.gap_2()
.text_sm()
.child(Icon::new(IconName::Plus))
.child("New Pull Request")
})
.menu_element(
Box::new(RepoAction::SendPatch),
|_, _| {
h_flex()
.gap_2()
.text_sm()
.child(Icon::new(IconName::File))
.child("Send Patch")
},
)
}),
)
.child(
DropdownButton::new("share")
.action(
Button::new("link")
.icon(IconName::Copy)
.tooltip("Copy ID")
.secondary()
.on_click({
let naddr = share.naddr.clone();
move |_, _, cx| {
cx.write_to_clipboard(
ClipboardItem::new_string(naddr.clone()),
);
}
}),
)
.dropdown_menu(move |menu, _, _| share.menu(menu)),
)
.child(
Button::new("repo-menu-open")
.icon(IconName::EllipsisVertical)
.tooltip("Repository management")
.compact()
.secondary()
.loading(pushing)
.disabled(pushing)
.dropdown_menu(move |menu, _, cx| {
let backend = Backend::global(cx);
let current_user = backend.read(cx).current_user();
let owner = current_user == Some(announcement.owner);
let menu = menu.menu_element(
Box::new(RepoAction::About),
|_, _| {
h_flex()
.gap_2()
.text_sm()
.child(Icon::new(IconName::Info))
.child("About")
},
);
if owner {
menu.menu_element(Box::new(RepoAction::Push), |_, _| {
h_flex()
.gap_2()
.text_sm()
.child(Icon::new(CustomIconName::Init))
.child("Republish")
})
.separator()
.menu_element(Box::new(RepoAction::Delete), |_, cx| {
h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().danger)
.child(Icon::new(IconName::Delete))
.child("Delete")
})
} else {
menu
}
}),
)
.child({
let view = cx.entity();
let ngit_command = ngit_command.clone();
let nak_command = nak_command.clone();
let git_commands = git_commands.clone();
Popover::new("clone")
.anchor(Anchor::TopRight)
.trigger(
Button::new("clone")
.icon(CustomIconName::GitClone)
.tooltip("Clone")
.loading(cloning)
.disabled(cloning)
.primary(),
)
.content(move |_, _window, cx| {
let state = cx.entity();
let ngit_row = copy_row("copy-ngit", &ngit_command, cx);
let nak_row = copy_row("copy-nak", &nak_command, cx);
v_flex()
.w(px(440.))
.mt_1()
.p_3()
.gap_4()
.popover_style(cx)
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Clone with ngit"),
)
.child(ngit_row),
)
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Clone with nak"),
)
.child(nak_row),
)
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Grasp Servers"),
)
.when(!git_commands.is_empty(), |this| {
this.children(
git_commands.iter().enumerate().map(
|(ix, cmd)| {
copy_row(
format!("copy-git-{ix}"),
cmd,
cx,
)
},
),
)
})
.when(git_commands.is_empty(), |this| {
this.child(
div()
.text_xs()
.child("No git clone urls."),
)
}),
)
.child(div().h_px().w_full().bg(cx.theme().border))
.child(
h_flex().gap_1().justify_end().child(
Button::new("download")
.icon(CustomIconName::GitClone)
.label("Download")
.primary()
.on_click(move |_event, window, cx| {
state.update(cx, |state, cx| {
state.dismiss(window, cx);
});
view.update(cx, |this, cx| {
this.clone_to_folder(window, cx);
});
}),
),
)
})
}),
),
)
.child(self.render_header_tabs(cx))
.into_any_element()
}
fn render_local_header(&self, cx: &mut Context<Self>) -> AnyElement {
let name = repo_display_name(self.store.read(cx));
let path = self
.store
.read(cx)
.path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_default();
let avatar = PixelAvatar::new(path.clone());
// A repository already bound to a coordinate is not offered for publishing again.
let bound = self.store.read(cx).nip34.clone();
let action = match bound
.as_ref()
.filter(|binding| binding.kind == Nip34Kind::Initialized)
{
Some(binding) => bound_repo_label(binding, cx),
None => 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);
}))
.into_any_element(),
};
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(action),
)
.child(self.render_header_tabs(cx))
.into_any_element()
}
fn render_header_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
let commits_count = self.history.read(cx).commit_count();
let worktree_empty = self.refs.switching_ref || self.worktree.is_none();
h_flex()
.items_center()
.gap_2()
.child(
BaseButton::new("files-tab")
.flex()
.items_center()
.h_8()
.px_2()
.gap_2()
.child(
h_flex()
.gap_1()
.text_sm()
.child(Icon::new(CustomIconName::GitFile).small())
.child("Files"),
)
.text_color(cx.theme().button_foreground)
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().button_hover))
.active(|this| this.bg(cx.theme().button_active))
.selected(self.active_tab == 0)
.when(self.active_tab == 0, |this| {
this.bg(cx.theme().button_active)
})
.on_click(cx.listener(|this, _event, _window, cx| {
this.active_tab = 0;
cx.notify();
})),
)
.child(
BaseButton::new("commits-tab")
.flex()
.items_center()
.h_8()
.px_2()
.gap_2()
.child(
h_flex()
.gap_1()
.text_sm()
.child(Icon::new(CustomIconName::GitCommit).small())
.child("Commits"),
)
.when_some(commits_count, |this, count| {
this.child(CountBadge::new(count))
})
.text_color(cx.theme().button_foreground)
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().button_hover))
.active(|this| this.bg(cx.theme().button_active))
.selected(self.active_tab == 1)
.when(self.active_tab == 1, |this| {
this.bg(cx.theme().button_active)
})
.on_click(cx.listener(|this, _event, _window, cx| {
this.active_tab = 1;
cx.notify();
})),
)
.child(
h_flex()
.flex_1()
.gap_2()
.justify_end()
.child(
Button::new("enc")
.ghost()
.when_some(self.head_commit.as_ref(), |this, commit| {
this.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(&commit.id)),
)
.child(
div()
.max_w(px(200.))
.overflow_hidden()
.text_ellipsis()
.whitespace_nowrap()
.text_xs()
.child(SharedString::from(&commit.summary)),
)
})
.tooltip(
self.head_commit
.as_ref()
.map_or_else(SharedString::default, |commit| {
commit.summary.clone().into()
}),
)
.on_click(cx.listener(|this, _event, window, cx| {
if let Some(commit) = &this.head_commit {
let id = commit.id.clone();
this.history.update(cx, |history, cx| {
history.open_commit_diff(&id, window, cx)
});
}
})),
)
.child(
div().w(px(120.)).child(
Combobox::new(&self.refs.branch_select)
.placeholder("Branch")
.appearance(false)
.menu_width(px(200.))
.disabled(worktree_empty)
.bg(cx.theme().muted)
.rounded(cx.theme().radius)
.render_trigger(|ctx, _window, cx| {
ref_selector_trigger(ctx, CustomIconName::GitBranch, cx)
}),
),
)
.child(
div().w(px(120.)).child(
Combobox::new(&self.refs.tag_select)
.placeholder("Tag")
.appearance(false)
.menu_width(px(200.))
.disabled(worktree_empty)
.bg(cx.theme().muted)
.rounded(cx.theme().radius)
.render_trigger(|ctx, _window, cx| {
ref_selector_trigger(ctx, CustomIconName::Tag, cx)
}),
),
),
)
.into_any_element()
}
fn render_maintainers(&self, cx: &mut Context<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()
}
fn ready_suggestion(&self, cx: &App) -> Option<CheckoutStatus> {
let store = self.store.read(cx);
let addr = store.addr()?;
let user = Backend::global(cx).read(cx).current_user()?;
if store.is_author(&user) {
return None;
}
let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(addr);
'status: for status in statuses {
if self.banners.dismissal(&status) {
continue;
}
for pr in &store.pull_requests {
if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status)
{
continue 'status;
}
}
return Some(status);
}
None
}
/// The first checkout of this owned repository with unpushed commits.
///
/// Not dismissed in this panel.
fn push_suggestion(&self, cx: &App) -> Option<CheckoutStatus> {
let store = self.store.read(cx);
let addr = store.addr()?;
let user = Backend::global(cx).read(cx).current_user()?;
if !store.is_author(&user) {
return None;
}
let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(addr);
statuses
.into_iter()
.find(|status| !self.banners.dismissal(status))
}
pub(super) fn render_push_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let status = self.push_suggestion(cx)?;
let path = status.path.clone();
let branch = status.branch.clone();
// The push busy flag lives on the store; it disables the banner's triggers.
let pushing = self.store.read(cx).pushing;
let commits = if status.ahead == 1 {
SharedString::from("1 commit")
} else {
SharedString::from(format!("{} commits", status.ahead))
};
Some(
h_flex()
.p_4()
.gap_2()
.w_full()
.items_center()
.justify_between()
.bg(cx.theme().muted)
.child(
h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().info)
.child(
h_flex()
.px_1()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().info)
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(branch),
)
.child("has")
.child(
h_flex()
.px_1()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().info)
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(commits),
)
.child("ready to push"),
)
.child(
h_flex()
.gap_1()
.child(
Button::new("push-checkout-banner")
.icon(IconName::ArrowUp)
.label("Push")
.small()
.info()
.loading(pushing)
.disabled(pushing)
.on_click(cx.listener(move |this, _event, window, cx| {
this.push_unpushed_checkout(path.clone(), window, cx);
})),
)
.child(
Button::new("close-repo")
.icon(IconName::Close)
.tooltip("Dismiss")
.small()
.ghost()
.disabled(pushing)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.banners.dismiss(&status);
cx.notify();
})),
),
)
.into_any_element(),
)
}
pub(super) fn render_push_warning_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let store = self.store.read(cx);
let warning = store.last_push_warning.clone()?;
let pushing = store.pushing;
Some(
h_flex()
.p_4()
.gap_2()
.w_full()
.items_start()
.justify_between()
.bg(cx.theme().warning.mix_oklab(transparent_white(), 0.08))
.child(
h_flex()
.gap_2()
.min_w_0()
.flex_1()
.items_start()
.child(Icon::new(IconName::TriangleAlert).small().flex_shrink_0())
.child(
div()
.flex_1()
.min_w_0()
.text_sm()
.text_color(cx.theme().warning)
.child(SharedString::from(warning)),
),
)
.child(
h_flex()
.gap_1()
.flex_shrink_0()
.child(
Button::new("republish-after-partial-push")
.icon(CustomIconName::Init)
.label("Republish")
.small()
.info()
.loading(pushing)
.disabled(pushing)
.on_click(cx.listener(|this, _event, window, cx| {
this.push_repository(window, cx);
})),
)
.child(
Button::new("dismiss-push-warning")
.icon(IconName::Close)
.tooltip("Dismiss")
.small()
.ghost()
.disabled(pushing)
.on_click(cx.listener(|this, _ev, _window, cx| {
this.store.update(cx, |store, _| {
store.last_push_warning = None;
});
cx.notify();
})),
),
)
.into_any_element(),
)
}
pub(super) fn render_ready_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let status = self.ready_suggestion(cx)?;
let branch = status.branch.clone();
let base = status.base.clone();
let commits = if status.ahead == 1 {
SharedString::from("1 commit")
} else {
SharedString::from(format!("{} commits", status.ahead))
};
Some(
h_flex()
.p_4()
.gap_2()
.w_full()
.items_center()
.justify_between()
.bg(cx.theme().muted)
.child(
h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().info)
.child(
h_flex()
.px_1()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().info)
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(branch),
)
.child("is")
.child(
h_flex()
.px_1()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().info)
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(commits),
)
.child("ahead of")
.child(
h_flex()
.px_1()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().info)
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(base),
),
)
.child(
h_flex()
.gap_1()
.child(
Button::new("create-pr-from-banner")
.icon(IconName::Plus)
.label("Create")
.small()
.info()
.on_click(cx.listener(|this, _event, window, cx| {
open_new_pull_panel(
this.dock_area.clone(),
this.store.clone(),
window,
cx,
);
})),
)
.child(
Button::new("dismiss-ready-banner")
.icon(IconName::Close)
.tooltip("Dismiss")
.small()
.ghost()
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.banners.dismiss(&status);
cx.notify();
})),
),
)
.into_any_element(),
)
}
/// The Files tab body, or the clone/initial-load spinner.
fn render_files_tab(&self, cx: &mut Context<Self>) -> AnyElement {
if self.loading {
return v_flex()
.flex_1()
.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();
}
self.files.clone().into_any_element()
}
}
fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString {
let owner = announcement.owner;
let user = nip05
.map(str::to_owned)
.unwrap_or_else(|| owner.to_bech32().unwrap());
let mut url = format!("nostr://{user}");
if let Some(hint) = announcement.relays.first().and_then(RelayUrl::domain) {
url.push('/');
url.push_str(hint);
}
url.push('/');
url.push_str(&announcement.id);
SharedString::from(url)
}
fn fork_row(announcement: &Announcement, cx: &mut Context<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()
})
}
struct ShareTargets {
/// NIP-19 `naddr1...` of the announcement, with its announced relays.
naddr: String,
/// Hex ID of the announcement event itself.
event_id: String,
/// NIP-34 coordinate `30617:<pubkey>:<repo-id>`.
coordinate: String,
/// `https://gitworkshop.dev/<naddr>`
gitworkshop: String,
/// `https://ditto.pub/<naddr>`
ditto: String,
}
impl ShareTargets {
fn from_announcement(announcement: &Announcement) -> Self {
let addr = announcement.addr();
let coordinate = addr.to_string();
let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned())
.to_bech32()
.expect("a complete coordinate always encodes to naddr");
Self {
naddr: naddr.clone(),
event_id: announcement.event_id.to_bech32().unwrap(),
coordinate,
gitworkshop: format!("https://gitworkshop.dev/{naddr}"),
ditto: format!("https://ditto.pub/{naddr}"),
}
}
fn menu(&self, menu: PopupMenu) -> PopupMenu {
menu.min_w(px(340.))
.item(menu_copy_row(
"copy-gitworkshop",
"GitWorkshop",
truncate_naddr_link(&self.gitworkshop, 4),
self.gitworkshop.clone(),
))
.item(menu_copy_row(
"copy-ditto",
"Ditto",
truncate_naddr_link(&self.ditto, 4),
self.ditto.clone(),
))
.item(menu_copy_row(
"copy-event-id",
"Event ID",
middle_truncate(&self.event_id, 10, 10),
self.event_id.clone(),
))
.item(menu_copy_row(
"copy-coordinate",
"Coordinate",
middle_truncate(&self.coordinate, 10, 10),
self.coordinate.clone(),
))
}
}
fn truncate_naddr_link(url: &str, tail: usize) -> String {
let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else {
return url.to_string();
};
if url.len() - end <= tail + 3 {
return url.to_string();
}
format!("{}...{}", &url[..end], &url[url.len() - tail..])
}
fn load_repo_data(repo: &Repository) -> Result<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);
let head_commit = signed_git::head_commit(repo).unwrap_or(None);
let (branches, tags, current_branch) = match &worktree {
Some(_) => (
signed_git::repo_branches(repo).unwrap_or_default(),
signed_git::repo_tags(repo).unwrap_or_default(),
signed_git::current_branch(repo).unwrap_or(None),
),
None => (Vec::new(), Vec::new(), None),
};
Ok(RepoData {
tree,
entries,
readme_path,
readme,
worktree,
branches,
tags,
current_branch,
head_commit,
})
}
/// The announcement's name or ID for announced repositories, the directory name for local ones.
pub(super) fn repo_display_name(store: &RepoStore) -> SharedString {
if store.addr().is_none() {
return store
.path
.as_ref()
.map(|path| {
SharedString::from(
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string()),
)
})
.unwrap_or_default();
}
store
.announcement
.as_ref()
.map(|announcement| {
announcement
.name
.as_deref()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
})
.unwrap_or_default()
}
/// The owner and identifier a local repository is already bound to.
fn bound_repo_label(binding: &Nip34Binding, cx: &App) -> AnyElement {
let store = ProfileStore::global(cx);
let profile = binding.owner.map(|pk| store.read(cx).get(&pk));
h_flex()
.flex_shrink_0()
.gap_2()
.text_sm()
.child("Initialized by")
.text_color(cx.theme().muted_foreground)
.when_some(profile, |this, profile| {
this.child(
h_flex()
.gap_1()
.text_color(cx.theme().foreground)
.child(UserAvatar::new(profile.name()).picture(profile.picture()))
.child(profile.name()),
)
})
.when_some(binding.identifier.as_deref(), |this, ident| {
this.child(
div()
.text_xs()
.font_semibold()
.child(SharedString::from(format!("/{ident}"))),
)
})
.into_any_element()
}
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 {
repo_display_name(self.store.read(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 banner = self
.render_ready_banner(cx)
.or_else(|| self.render_push_banner(cx));
let error = self.error.clone().or_else(|| {
self.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;
this.store.update(cx, |store, _| store.last_error = None);
cx.notify();
})),
)
})
.map(|this| match self.active_tab {
0 => this.child(self.render_files_tab(cx)),
_ => this.child(self.history.clone()),
})
}
}