feat: push checkout #14
@@ -5,7 +5,7 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
||||
use gpui::{AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity};
|
||||
use gpui::{App, AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity};
|
||||
use nostr::event::IntoEventBuilder;
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_core::{
|
||||
@@ -16,8 +16,10 @@ use signed_core::{
|
||||
use crate::backend::{
|
||||
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, user_grasp_list_servers,
|
||||
};
|
||||
use crate::checkouts::CheckoutsStore;
|
||||
use crate::git_store::GitStore;
|
||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||
use crate::repo_list::RepoListStore;
|
||||
|
||||
/// Delay between a refresh request and the actual re-query.
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
@@ -57,6 +59,14 @@ pub struct RepoStore {
|
||||
///
|
||||
/// Example, a PR published without its commit reaching a grasp server.
|
||||
pub last_warning: Option<String>,
|
||||
/// A republish or a checkout push is in flight.
|
||||
///
|
||||
/// Views show a spinner and disable their push triggers while it is set.
|
||||
pub pushing: bool,
|
||||
/// A clone-into-a-folder operation is in flight.
|
||||
///
|
||||
/// Views show a spinner and disable the clone trigger while it is set.
|
||||
pub cloning: bool,
|
||||
/// Relays already asked to connect to, from this repository's NIP-34 `relays` tag.
|
||||
///
|
||||
/// Avoids re-subscribing and re-fetching on every refresh.
|
||||
@@ -127,6 +137,8 @@ impl RepoStore {
|
||||
version: 0,
|
||||
last_error: None,
|
||||
last_warning: None,
|
||||
pushing: false,
|
||||
cloning: false,
|
||||
repo_relays: HashSet::new(),
|
||||
root_fetches: HashSet::new(),
|
||||
refresh: RefreshGate::default(),
|
||||
@@ -1048,6 +1060,201 @@ impl RepoStore {
|
||||
}));
|
||||
}
|
||||
|
||||
/// The latest announcement of this repository,
|
||||
/// for operations that need its clone URLs and relays.
|
||||
fn action_announcement(&self, cx: &App) -> Option<Announcement> {
|
||||
self.announcement.clone().or_else(|| {
|
||||
RepoListStore::global(cx)
|
||||
.read(cx)
|
||||
.announcements
|
||||
.iter()
|
||||
.find(|announcement| announcement.addr() == self.addr)
|
||||
.cloned()
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-push the repository's refs to its announced grasp servers, republish.
|
||||
pub fn push_repository(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
|
||||
if self.pushing {
|
||||
return Task::ready(Err(anyhow::anyhow!(
|
||||
"A push to this repository is already in progress"
|
||||
)));
|
||||
}
|
||||
let Some(announcement) = self.action_announcement(cx) else {
|
||||
return self.action_error("Repository announcement is not loaded yet", cx);
|
||||
};
|
||||
|
||||
self.pushing = true;
|
||||
self.last_error = None;
|
||||
cx.notify();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let push = backend.update(cx, |backend, cx| backend.push_repository(announcement, cx));
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = push.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.pushing = false;
|
||||
|
||||
if let Err(e) = &result {
|
||||
this.last_error = Some(format!("Push failed: {e}"));
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
/// Push the unpushed commits of the local checkout at `path`.
|
||||
pub fn push_checkout(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<(), Error>> {
|
||||
if self.pushing {
|
||||
return Task::ready(Err(anyhow::anyhow!(
|
||||
"A push to this repository is already in progress"
|
||||
)));
|
||||
}
|
||||
|
||||
let Some(announcement) = self.action_announcement(cx) else {
|
||||
return self.action_error("Repository announcement is not loaded yet", cx);
|
||||
};
|
||||
|
||||
// The state event's `HEAD` stays the announced default branch.
|
||||
// The checkout may be on a side branch.
|
||||
let head = self.head.clone();
|
||||
let addr = self.addr.clone();
|
||||
|
||||
self.pushing = true;
|
||||
self.last_error = None;
|
||||
cx.notify();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let push = backend.update(cx, |backend, cx| {
|
||||
backend.push_checkout(announcement, path.clone(), head, cx)
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = push.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.pushing = false;
|
||||
|
||||
match &result {
|
||||
Ok(()) => {
|
||||
// The remote moved, so recompute the ready-to-push statuses.
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
checkouts.update(cx, |store, cx| {
|
||||
store.request_push_statuses(&addr, cx);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
this.last_error = Some(format!("Push failed: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete the repository from nostr, announcement, state and activity.
|
||||
///
|
||||
/// Only the repository owner may delete it. The lists update when the
|
||||
/// deletion events arrive.
|
||||
pub fn delete_repository(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
|
||||
let addr = self.addr.clone();
|
||||
self.last_error = None;
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let delete = backend.update(cx, |backend, cx| backend.delete_repository(addr, cx));
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = delete.await;
|
||||
this.update(cx, |this, cx| {
|
||||
if let Err(e) = &result {
|
||||
this.last_error = Some(format!("Delete failed: {e}"));
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
/// Clone the repository into `destination`, a user-chosen folder outside
|
||||
/// the cache, and remember the clone as a checkout of this repository.
|
||||
pub fn clone_to_folder(
|
||||
&mut self,
|
||||
destination: PathBuf,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<(), Error>> {
|
||||
if self.cloning {
|
||||
return Task::ready(Err(anyhow::anyhow!(
|
||||
"A clone of this repository is already in progress"
|
||||
)));
|
||||
}
|
||||
let Some(announcement) = self.action_announcement(cx) else {
|
||||
return self.action_error("Repository announcement is not loaded yet", cx);
|
||||
};
|
||||
|
||||
let clone_urls: Vec<String> = announcement.clone.iter().map(ToString::to_string).collect();
|
||||
let addr = self.addr.clone();
|
||||
|
||||
self.cloning = true;
|
||||
self.last_error = None;
|
||||
cx.notify();
|
||||
|
||||
let clone = {
|
||||
let destination = destination.clone();
|
||||
cx.background_spawn(async move { signed_git::clone_repo(&clone_urls, &destination) })
|
||||
};
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = clone.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.cloning = false;
|
||||
|
||||
match &result {
|
||||
Ok(()) => {
|
||||
// Remember the clone as a checkout of this repository.
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
checkouts.update(cx, |store, cx| {
|
||||
store.record(destination.clone(), addr.clone(), cx);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
this.last_error = Some(format!("Failed to clone: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
/// Fail an operation whose announcement is not loaded yet.
|
||||
fn action_error(
|
||||
&mut self,
|
||||
message: impl Into<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<(), Error>> {
|
||||
let message = message.into();
|
||||
self.last_error = Some(message.clone());
|
||||
cx.notify();
|
||||
|
||||
Task::ready(Err(anyhow::anyhow!("{message}")))
|
||||
}
|
||||
|
||||
/// Publish a kind-1631 Applied status event for `root` after a merge.
|
||||
fn publish_applied_status(
|
||||
&mut self,
|
||||
|
||||
@@ -163,10 +163,6 @@ pub struct RepoDetailView {
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// A clone/fetch is in flight.
|
||||
loading: bool,
|
||||
/// The header clone button is cloning into a user-chosen folder.
|
||||
cloning: bool,
|
||||
/// A push to the grasp servers is in flight.
|
||||
pushing: bool,
|
||||
error: Option<SharedString>,
|
||||
/// Commit HEAD currently points to, shown in the header button.
|
||||
head_commit: Option<FileCommit>,
|
||||
@@ -337,8 +333,6 @@ impl RepoDetailView {
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
loading: true,
|
||||
cloning: false,
|
||||
pushing: false,
|
||||
error: None,
|
||||
head_commit: None,
|
||||
branch_select,
|
||||
@@ -358,10 +352,9 @@ 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.
|
||||
/// An unreachable server can't block the panel.
|
||||
/// A background fetch then refreshes the refs and commit list.
|
||||
fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
@@ -386,6 +379,7 @@ impl RepoDetailView {
|
||||
this.loading = false;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
@@ -452,6 +446,7 @@ impl RepoDetailView {
|
||||
if !had_clone {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let refresh = {
|
||||
let cache = cache.clone();
|
||||
let addr = addr.clone();
|
||||
@@ -459,9 +454,11 @@ impl RepoDetailView {
|
||||
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.
|
||||
@@ -474,6 +471,7 @@ impl RepoDetailView {
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
|
||||
let (branches, tags) = match &worktree {
|
||||
Some(_) => (
|
||||
signed_git::repo_branches(&repo).unwrap_or_default(),
|
||||
@@ -481,8 +479,10 @@ impl RepoDetailView {
|
||||
),
|
||||
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)))
|
||||
})
|
||||
}
|
||||
@@ -539,8 +539,6 @@ impl RepoDetailView {
|
||||
}
|
||||
|
||||
/// Apply the loaded repository data.
|
||||
/// Sets the explorer tree, README preview, ref selectors and HEAD commit.
|
||||
/// Then starts the commit-list walk.
|
||||
fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let RepoData {
|
||||
tree,
|
||||
@@ -592,19 +590,16 @@ impl RepoDetailView {
|
||||
}
|
||||
|
||||
/// Clone the repository into a user-chosen folder outside the cache.
|
||||
/// Then open the new clone in the system file manager.
|
||||
fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.cloning {
|
||||
let Some(store) = self.store.clone() else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (clone_urls, name, addr) = {
|
||||
let name = {
|
||||
let Some(announcement) = self.announcement(cx) else {
|
||||
return;
|
||||
};
|
||||
let addr = announcement.addr();
|
||||
let clone_urls: Vec<String> =
|
||||
announcement.clone.iter().map(ToString::to_string).collect();
|
||||
// Directory name, the display name falling back to the repo id.
|
||||
// Both are sanitized to a safe single path component.
|
||||
let name = announcement
|
||||
@@ -614,17 +609,13 @@ impl RepoDetailView {
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or_else(|| addr.identifier.clone());
|
||||
let name = signed_git::sanitize_path_component(&name);
|
||||
let name = if name.is_empty() {
|
||||
if name.is_empty() {
|
||||
"repository".to_owned()
|
||||
} else {
|
||||
name
|
||||
};
|
||||
(clone_urls, name, addr)
|
||||
}
|
||||
};
|
||||
|
||||
self.cloning = true;
|
||||
cx.notify();
|
||||
|
||||
let prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
@@ -640,39 +631,25 @@ impl RepoDetailView {
|
||||
_ => None,
|
||||
};
|
||||
let Some(folder) = picked else {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.cloning = false;
|
||||
cx.notify();
|
||||
})?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let destination = folder.join(&name);
|
||||
let destination_for_open = destination.clone();
|
||||
let clone_target = destination_for_open.clone();
|
||||
let result = cx
|
||||
.background_spawn(async move { signed_git::clone_repo(&clone_urls, &clone_target) })
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.cloning = false;
|
||||
match result {
|
||||
Ok(_) => {
|
||||
cx.open_with_system(&destination_for_open);
|
||||
// Remember the clone as a checkout of this repository.
|
||||
// The New PR panel pre-fills it.
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
checkouts.update(cx, |store, cx| {
|
||||
store.record(destination, addr, cx);
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
this.error = Some(format!("Failed to clone: {error}").into());
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
// 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(())
|
||||
});
|
||||
|
||||
@@ -726,6 +703,7 @@ impl RepoDetailView {
|
||||
|
||||
self.loading_files.insert(path.to_string());
|
||||
let path = path.to_string();
|
||||
|
||||
self.load_commit(&path, cx);
|
||||
let generation = self.ref_generation;
|
||||
|
||||
@@ -906,8 +884,6 @@ impl RepoDetailView {
|
||||
}
|
||||
|
||||
/// Open a new panel showing the diff of `commit_id`.
|
||||
/// All files it changed, with the line diff of each.
|
||||
/// Called from the Commits tab rows and the latest-commit button.
|
||||
fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
@@ -929,121 +905,63 @@ impl RepoDetailView {
|
||||
}
|
||||
|
||||
/// Re-push the repository's refs to its announced grasp servers.
|
||||
/// The menu trigger shows a spinner while the push is in flight.
|
||||
/// Failures appear in the panel's error banner.
|
||||
fn push_repository(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.pushing {
|
||||
return;
|
||||
}
|
||||
let Some(announcement) = self.announcement(cx).cloned() else {
|
||||
fn push_repository(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(store) = self.store.clone() else {
|
||||
return;
|
||||
};
|
||||
self.pushing = true;
|
||||
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let task = backend.update(cx, |backend, cx| backend.push_repository(announcement, cx));
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
let result = task.await;
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
if let Err(error) = result {
|
||||
this.error = Some(format!("Push failed: {error}").into());
|
||||
}
|
||||
this.pushing = false;
|
||||
cx.notify();
|
||||
})?;
|
||||
Ok(())
|
||||
}));
|
||||
self.tasks
|
||||
.push(store.update(cx, |store, cx| store.push_repository(cx)));
|
||||
}
|
||||
|
||||
/// Push the unpushed commits of the local checkout at `path`.
|
||||
/// The checkout is an owned repository's working copy.
|
||||
/// Failures appear in the panel's error banner.
|
||||
/// On success the push statuses are recomputed so the banner clears.
|
||||
fn push_unpushed_checkout(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.pushing {
|
||||
let Some(store) = self.store.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if store.read(cx).pushing {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(announcement) = self.announcement(cx).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// The state event's `HEAD` stays the announced default branch.
|
||||
// The checkout may be on a side branch.
|
||||
let head = self
|
||||
.store
|
||||
.as_ref()
|
||||
.and_then(|store| store.read(cx).head.clone());
|
||||
let addr = announcement.addr();
|
||||
|
||||
self.pushing = true;
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let checkout = CheckoutsStore::global(cx);
|
||||
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
backend.push_checkout(announcement, path.clone(), head, cx)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
let result = task.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
// The remote moved, so recompute the push statuses.
|
||||
// The banner disappears.
|
||||
// Refresh the mirror, fetch, fast-forward and an explorer reload.
|
||||
// The pushed commits then show in the panel.
|
||||
checkout.update(cx, |store, cx| {
|
||||
store.request_push_statuses(&addr, cx);
|
||||
});
|
||||
this.load_repo(window, cx);
|
||||
}
|
||||
Err(error) => {
|
||||
this.error = Some(format!("Push failed: {error}").into());
|
||||
}
|
||||
}
|
||||
this.pushing = false;
|
||||
cx.notify();
|
||||
let task = cx.spawn_in(window, async move |this, cx| {
|
||||
// The store owns the push, its busy flag and error reporting.
|
||||
let push = this.update_in(cx, |_this, _window, cx| {
|
||||
store.update(cx, |store, cx| store.push_checkout(path.clone(), cx))
|
||||
})?;
|
||||
|
||||
// The remote moved, refresh the mirror browsing.
|
||||
// Failures already surfaced in the store's error banner.
|
||||
if let Ok(()) = push.await {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.load_repo(window, cx);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Delete the repository from nostr, announcement, state and activity.
|
||||
/// Only offered to the repository owner.
|
||||
/// The sidebar list updates when the deletion events arrive.
|
||||
fn delete_repository(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(announcement) = self.announcement(cx).cloned() else {
|
||||
fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(store) = self.store.clone() else {
|
||||
return;
|
||||
};
|
||||
let backend = Backend::global(cx);
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
backend.delete_repository(announcement.addr(), cx)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
let result = task.await;
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
if let Err(error) = result {
|
||||
this.error = Some(format!("Delete failed: {error}").into());
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
Ok(())
|
||||
}));
|
||||
self.tasks
|
||||
.push(store.update(cx, |store, cx| store.delete_repository(cx)));
|
||||
}
|
||||
|
||||
/// Open the issues list panel in the dock area.
|
||||
@@ -1380,10 +1298,15 @@ impl RepoDetailView {
|
||||
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();
|
||||
};
|
||||
@@ -1397,8 +1320,10 @@ impl RepoDetailView {
|
||||
.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}"));
|
||||
@@ -1608,8 +1533,8 @@ impl RepoDetailView {
|
||||
.tooltip("Repository management")
|
||||
.compact()
|
||||
.secondary()
|
||||
.loading(self.pushing)
|
||||
.disabled(self.pushing)
|
||||
.loading(pushing)
|
||||
.disabled(pushing)
|
||||
.dropdown_menu(move |menu, _, cx| {
|
||||
let backend = Backend::global(cx);
|
||||
let current_user = backend.read(cx).current_user();
|
||||
@@ -1660,8 +1585,8 @@ impl RepoDetailView {
|
||||
Button::new("clone")
|
||||
.icon(CustomIconName::GitClone)
|
||||
.tooltip("Clone")
|
||||
.loading(self.cloning)
|
||||
.disabled(self.cloning)
|
||||
.loading(cloning)
|
||||
.disabled(cloning)
|
||||
.primary(),
|
||||
)
|
||||
.content(move |_, _window, cx| {
|
||||
@@ -1983,6 +1908,11 @@ impl RepoDetailView {
|
||||
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")
|
||||
@@ -2038,9 +1968,9 @@ impl RepoDetailView {
|
||||
.icon(IconName::ArrowUp)
|
||||
.label("Push")
|
||||
.small()
|
||||
.primary()
|
||||
.loading(self.pushing)
|
||||
.disabled(self.pushing)
|
||||
.info()
|
||||
.loading(pushing)
|
||||
.disabled(pushing)
|
||||
.on_click(cx.listener(move |this, _event, window, cx| {
|
||||
this.push_unpushed_checkout(path.clone(), window, cx);
|
||||
})),
|
||||
@@ -2051,7 +1981,7 @@ impl RepoDetailView {
|
||||
.tooltip("Dismiss")
|
||||
.small()
|
||||
.ghost()
|
||||
.disabled(self.pushing)
|
||||
.disabled(pushing)
|
||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||
this.banner_dismissed.insert(key.clone());
|
||||
cx.notify();
|
||||
@@ -2063,52 +1993,102 @@ impl RepoDetailView {
|
||||
}
|
||||
|
||||
/// The ready-to-contribute banner of the repository panel.
|
||||
/// A message, a Create action opening the prefilled New PR panel.
|
||||
/// Plus a dismiss control.
|
||||
///
|
||||
/// A checkout has commits ahead of its base branch, with a Create action
|
||||
/// opening the prefilled New PR panel, and a dismiss control.
|
||||
fn render_ready_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
|
||||
let status = self.ready_suggestion(cx)?;
|
||||
let commits = if status.ahead == 1 {
|
||||
"1 commit".to_owned()
|
||||
} else {
|
||||
format!("{} commits", status.ahead)
|
||||
};
|
||||
let message = SharedString::from(format!(
|
||||
"{} is {} ahead of {} in {}",
|
||||
status.branch,
|
||||
commits,
|
||||
status.base,
|
||||
status.path.display()
|
||||
));
|
||||
let key = (status.path.clone(), status.branch.clone());
|
||||
let view = cx.entity().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()
|
||||
.px_4()
|
||||
.pt_1()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.bg(cx.theme().muted)
|
||||
.child(
|
||||
Alert::info("repo-ready-to-contribute", message)
|
||||
.banner()
|
||||
.flex_1()
|
||||
.on_close(move |_event, _window, cx| {
|
||||
view.update(cx, |this, _| {
|
||||
this.banner_dismissed.insert(key.clone());
|
||||
});
|
||||
}),
|
||||
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(
|
||||
Button::new("create-pr-from-banner")
|
||||
.small()
|
||||
.icon(IconName::Plus)
|
||||
.label("Create pull request")
|
||||
.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);
|
||||
}
|
||||
})),
|
||||
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(),
|
||||
)
|
||||
@@ -2331,18 +2311,29 @@ impl Render for RepoDetailView {
|
||||
.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))
|
||||
.child(self.render_header(cx))
|
||||
.when_some(self.error.clone(), |this, error| {
|
||||
.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();
|
||||
})),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user