update
This commit is contained in:
@@ -5,7 +5,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
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::event::IntoEventBuilder;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use signed_core::{
|
use signed_core::{
|
||||||
@@ -16,8 +16,10 @@ use signed_core::{
|
|||||||
use crate::backend::{
|
use crate::backend::{
|
||||||
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, user_grasp_list_servers,
|
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::git_store::GitStore;
|
||||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||||
|
use crate::repo_list::RepoListStore;
|
||||||
|
|
||||||
/// Delay between a refresh request and the actual re-query.
|
/// Delay between a refresh request and the actual re-query.
|
||||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
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.
|
/// Example, a PR published without its commit reaching a grasp server.
|
||||||
pub last_warning: Option<String>,
|
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.
|
/// Relays already asked to connect to, from this repository's NIP-34 `relays` tag.
|
||||||
///
|
///
|
||||||
/// Avoids re-subscribing and re-fetching on every refresh.
|
/// Avoids re-subscribing and re-fetching on every refresh.
|
||||||
@@ -127,6 +137,8 @@ impl RepoStore {
|
|||||||
version: 0,
|
version: 0,
|
||||||
last_error: None,
|
last_error: None,
|
||||||
last_warning: None,
|
last_warning: None,
|
||||||
|
pushing: false,
|
||||||
|
cloning: false,
|
||||||
repo_relays: HashSet::new(),
|
repo_relays: HashSet::new(),
|
||||||
root_fetches: HashSet::new(),
|
root_fetches: HashSet::new(),
|
||||||
refresh: RefreshGate::default(),
|
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.
|
/// Publish a kind-1631 Applied status event for `root` after a merge.
|
||||||
fn publish_applied_status(
|
fn publish_applied_status(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|||||||
@@ -163,10 +163,6 @@ pub struct RepoDetailView {
|
|||||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
/// A clone/fetch is in flight.
|
/// A clone/fetch is in flight.
|
||||||
loading: bool,
|
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>,
|
error: Option<SharedString>,
|
||||||
/// Commit HEAD currently points to, shown in the header button.
|
/// Commit HEAD currently points to, shown in the header button.
|
||||||
head_commit: Option<FileCommit>,
|
head_commit: Option<FileCommit>,
|
||||||
@@ -337,8 +333,6 @@ impl RepoDetailView {
|
|||||||
scroll_handle: VirtualListScrollHandle::new(),
|
scroll_handle: VirtualListScrollHandle::new(),
|
||||||
item_sizes: Rc::new(Vec::new()),
|
item_sizes: Rc::new(Vec::new()),
|
||||||
loading: true,
|
loading: true,
|
||||||
cloning: false,
|
|
||||||
pushing: false,
|
|
||||||
error: None,
|
error: None,
|
||||||
head_commit: None,
|
head_commit: None,
|
||||||
branch_select,
|
branch_select,
|
||||||
@@ -358,10 +352,9 @@ impl RepoDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load the repository and populate the file explorer.
|
/// Load the repository and populate the file explorer.
|
||||||
|
///
|
||||||
/// A local, not yet published, repository opens straight from disk.
|
/// A local, not yet published, repository opens straight from disk.
|
||||||
/// An announced repository's clone, if any, loads first without touching the network.
|
/// 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>) {
|
fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.loading = true;
|
self.loading = true;
|
||||||
self.error = None;
|
self.error = None;
|
||||||
@@ -386,6 +379,7 @@ impl RepoDetailView {
|
|||||||
this.loading = false;
|
this.loading = false;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -452,6 +446,7 @@ impl RepoDetailView {
|
|||||||
if !had_clone {
|
if !had_clone {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let refresh = {
|
let refresh = {
|
||||||
let cache = cache.clone();
|
let cache = cache.clone();
|
||||||
let addr = addr.clone();
|
let addr = addr.clone();
|
||||||
@@ -459,9 +454,11 @@ impl RepoDetailView {
|
|||||||
let Some(repo) = cache.open(&addr)? else {
|
let Some(repo) = cache.open(&addr)? else {
|
||||||
return Ok::<_, Error>(None);
|
return Ok::<_, Error>(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Best-effort, a fetch failure, e.g. offline, keeps the cached state.
|
// Best-effort, a fetch failure, e.g. offline, keeps the cached state.
|
||||||
// The state is already shown.
|
// The state is already shown.
|
||||||
signed_git::fetch_all(&repo).ok();
|
signed_git::fetch_all(&repo).ok();
|
||||||
|
|
||||||
let worktree = repo.workdir().map(Path::to_path_buf);
|
let worktree = repo.workdir().map(Path::to_path_buf);
|
||||||
// A fetch never moves a mirror's local branches.
|
// A fetch never moves a mirror's local branches.
|
||||||
// A push landing on the grasp servers would never show up.
|
// A push landing on the grasp servers would never show up.
|
||||||
@@ -474,6 +471,7 @@ impl RepoDetailView {
|
|||||||
}
|
}
|
||||||
None => false,
|
None => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let (branches, tags) = match &worktree {
|
let (branches, tags) = match &worktree {
|
||||||
Some(_) => (
|
Some(_) => (
|
||||||
signed_git::repo_branches(&repo).unwrap_or_default(),
|
signed_git::repo_branches(&repo).unwrap_or_default(),
|
||||||
@@ -481,8 +479,10 @@ impl RepoDetailView {
|
|||||||
),
|
),
|
||||||
None => (Vec::new(), Vec::new()),
|
None => (Vec::new(), Vec::new()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let current_branch = signed_git::current_branch(&repo).unwrap_or(None);
|
let current_branch = signed_git::current_branch(&repo).unwrap_or(None);
|
||||||
let head_commit = signed_git::head_commit(&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)))
|
Ok::<_, Error>(Some((moved, branches, tags, current_branch, head_commit)))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -539,8 +539,6 @@ impl RepoDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Apply the loaded repository data.
|
/// 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>) {
|
fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let RepoData {
|
let RepoData {
|
||||||
tree,
|
tree,
|
||||||
@@ -592,19 +590,16 @@ impl RepoDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Clone the repository into a user-chosen folder outside the cache.
|
/// 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>) {
|
fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if self.cloning {
|
let Some(store) = self.store.clone() else {
|
||||||
return;
|
return;
|
||||||
}
|
};
|
||||||
|
|
||||||
let (clone_urls, name, addr) = {
|
let name = {
|
||||||
let Some(announcement) = self.announcement(cx) else {
|
let Some(announcement) = self.announcement(cx) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let addr = announcement.addr();
|
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.
|
// Directory name, the display name falling back to the repo id.
|
||||||
// Both are sanitized to a safe single path component.
|
// Both are sanitized to a safe single path component.
|
||||||
let name = announcement
|
let name = announcement
|
||||||
@@ -614,17 +609,13 @@ impl RepoDetailView {
|
|||||||
.filter(|name| !name.trim().is_empty())
|
.filter(|name| !name.trim().is_empty())
|
||||||
.unwrap_or_else(|| addr.identifier.clone());
|
.unwrap_or_else(|| addr.identifier.clone());
|
||||||
let name = signed_git::sanitize_path_component(&name);
|
let name = signed_git::sanitize_path_component(&name);
|
||||||
let name = if name.is_empty() {
|
if name.is_empty() {
|
||||||
"repository".to_owned()
|
"repository".to_owned()
|
||||||
} else {
|
} else {
|
||||||
name
|
name
|
||||||
};
|
}
|
||||||
(clone_urls, name, addr)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
self.cloning = true;
|
|
||||||
cx.notify();
|
|
||||||
|
|
||||||
let prompt = cx.prompt_for_paths(PathPromptOptions {
|
let prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||||
files: false,
|
files: false,
|
||||||
directories: true,
|
directories: true,
|
||||||
@@ -640,39 +631,25 @@ impl RepoDetailView {
|
|||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let Some(folder) = picked else {
|
let Some(folder) = picked else {
|
||||||
this.update_in(cx, |this, _window, cx| {
|
|
||||||
this.cloning = false;
|
|
||||||
cx.notify();
|
|
||||||
})?;
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
let destination = folder.join(&name);
|
let destination = folder.join(&name);
|
||||||
let destination_for_open = destination.clone();
|
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| {
|
// The store owns the clone, its busy flag and error reporting.
|
||||||
this.cloning = false;
|
let clone = this.update_in(cx, |_this, _window, cx| {
|
||||||
match result {
|
store.update(cx, |store, cx| store.clone_to_folder(destination, cx))
|
||||||
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();
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// 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(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -726,6 +703,7 @@ impl RepoDetailView {
|
|||||||
|
|
||||||
self.loading_files.insert(path.to_string());
|
self.loading_files.insert(path.to_string());
|
||||||
let path = path.to_string();
|
let path = path.to_string();
|
||||||
|
|
||||||
self.load_commit(&path, cx);
|
self.load_commit(&path, cx);
|
||||||
let generation = self.ref_generation;
|
let generation = self.ref_generation;
|
||||||
|
|
||||||
@@ -906,8 +884,6 @@ impl RepoDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Open a new panel showing the diff of `commit_id`.
|
/// 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>) {
|
fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let Some(worktree) = self.worktree.clone() else {
|
let Some(worktree) = self.worktree.clone() else {
|
||||||
return;
|
return;
|
||||||
@@ -929,121 +905,63 @@ impl RepoDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Re-push the repository's refs to its announced grasp servers.
|
/// Re-push the repository's refs to its announced grasp servers.
|
||||||
/// The menu trigger shows a spinner while the push is in flight.
|
fn push_repository(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
/// Failures appear in the panel's error banner.
|
let Some(store) = self.store.clone() else {
|
||||||
fn push_repository(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
if self.pushing {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Some(announcement) = self.announcement(cx).cloned() else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
self.pushing = true;
|
|
||||||
self.error = None;
|
self.error = None;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
self.tasks
|
||||||
let task = backend.update(cx, |backend, cx| backend.push_repository(announcement, cx));
|
.push(store.update(cx, |store, cx| store.push_repository(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(())
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push the unpushed commits of the local checkout at `path`.
|
/// 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(
|
fn push_unpushed_checkout(
|
||||||
&mut self,
|
&mut self,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
if self.pushing {
|
let Some(store) = self.store.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if store.read(cx).pushing {
|
||||||
return;
|
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;
|
self.error = None;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let task = cx.spawn_in(window, async move |this, cx| {
|
||||||
let checkout = CheckoutsStore::global(cx);
|
// The store owns the push, its busy flag and error reporting.
|
||||||
|
let push = this.update_in(cx, |_this, _window, cx| {
|
||||||
let task = backend.update(cx, |backend, cx| {
|
store.update(cx, |store, cx| store.push_checkout(path.clone(), 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();
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// 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(())
|
Ok(())
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete the repository from nostr, announcement, state and activity.
|
/// Delete the repository from nostr, announcement, state and activity.
|
||||||
/// Only offered to the repository owner.
|
fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
/// The sidebar list updates when the deletion events arrive.
|
let Some(store) = self.store.clone() else {
|
||||||
fn delete_repository(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let Some(announcement) = self.announcement(cx).cloned() else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let backend = Backend::global(cx);
|
self.tasks
|
||||||
let task = backend.update(cx, |backend, cx| {
|
.push(store.update(cx, |store, cx| store.delete_repository(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(())
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the issues list panel in the dock area.
|
/// Open the issues list panel in the dock area.
|
||||||
@@ -1380,10 +1298,15 @@ impl RepoDetailView {
|
|||||||
let Some(store_entity) = self.store.as_ref() else {
|
let Some(store_entity) = self.store.as_ref() else {
|
||||||
return div().into_any_element();
|
return div().into_any_element();
|
||||||
};
|
};
|
||||||
|
|
||||||
let store = store_entity.read(cx);
|
let store = store_entity.read(cx);
|
||||||
let issue_count = SharedString::from(store.issue_count().to_string());
|
let issue_count = SharedString::from(store.issue_count().to_string());
|
||||||
let pr_count = SharedString::from(store.pull_request_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 {
|
let Some(source) = store.announcement.as_ref().or(self.initial.as_ref()) else {
|
||||||
return div().into_any_element();
|
return div().into_any_element();
|
||||||
};
|
};
|
||||||
@@ -1397,8 +1320,10 @@ impl RepoDetailView {
|
|||||||
.nip05
|
.nip05
|
||||||
.clone()
|
.clone()
|
||||||
.filter(|nip05| !nip05.trim().is_empty());
|
.filter(|nip05| !nip05.trim().is_empty());
|
||||||
|
|
||||||
let announcement = Rc::new(source.clone());
|
let announcement = Rc::new(source.clone());
|
||||||
let share = Rc::new(ShareTargets::from_announcement(&announcement));
|
let share = Rc::new(ShareTargets::from_announcement(&announcement));
|
||||||
|
|
||||||
let nostr_url = nostr_clone_url(&announcement, nip05.as_deref());
|
let nostr_url = nostr_clone_url(&announcement, nip05.as_deref());
|
||||||
let ngit_command = SharedString::from(format!("git clone {nostr_url}"));
|
let ngit_command = SharedString::from(format!("git clone {nostr_url}"));
|
||||||
let nak_command = SharedString::from(format!("nak git clone {nostr_url}"));
|
let nak_command = SharedString::from(format!("nak git clone {nostr_url}"));
|
||||||
@@ -1608,8 +1533,8 @@ impl RepoDetailView {
|
|||||||
.tooltip("Repository management")
|
.tooltip("Repository management")
|
||||||
.compact()
|
.compact()
|
||||||
.secondary()
|
.secondary()
|
||||||
.loading(self.pushing)
|
.loading(pushing)
|
||||||
.disabled(self.pushing)
|
.disabled(pushing)
|
||||||
.dropdown_menu(move |menu, _, cx| {
|
.dropdown_menu(move |menu, _, cx| {
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let current_user = backend.read(cx).current_user();
|
let current_user = backend.read(cx).current_user();
|
||||||
@@ -1660,8 +1585,8 @@ impl RepoDetailView {
|
|||||||
Button::new("clone")
|
Button::new("clone")
|
||||||
.icon(CustomIconName::GitClone)
|
.icon(CustomIconName::GitClone)
|
||||||
.tooltip("Clone")
|
.tooltip("Clone")
|
||||||
.loading(self.cloning)
|
.loading(cloning)
|
||||||
.disabled(self.cloning)
|
.disabled(cloning)
|
||||||
.primary(),
|
.primary(),
|
||||||
)
|
)
|
||||||
.content(move |_, _window, cx| {
|
.content(move |_, _window, cx| {
|
||||||
@@ -1983,6 +1908,11 @@ impl RepoDetailView {
|
|||||||
let status = self.push_suggestion(cx)?;
|
let status = self.push_suggestion(cx)?;
|
||||||
let key = (status.path.clone(), status.branch.clone());
|
let key = (status.path.clone(), status.branch.clone());
|
||||||
let path = status.path.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 {
|
let commits = if status.ahead == 1 {
|
||||||
SharedString::from("1 commit")
|
SharedString::from("1 commit")
|
||||||
@@ -2038,9 +1968,9 @@ impl RepoDetailView {
|
|||||||
.icon(IconName::ArrowUp)
|
.icon(IconName::ArrowUp)
|
||||||
.label("Push")
|
.label("Push")
|
||||||
.small()
|
.small()
|
||||||
.primary()
|
.info()
|
||||||
.loading(self.pushing)
|
.loading(pushing)
|
||||||
.disabled(self.pushing)
|
.disabled(pushing)
|
||||||
.on_click(cx.listener(move |this, _event, window, cx| {
|
.on_click(cx.listener(move |this, _event, window, cx| {
|
||||||
this.push_unpushed_checkout(path.clone(), window, cx);
|
this.push_unpushed_checkout(path.clone(), window, cx);
|
||||||
})),
|
})),
|
||||||
@@ -2051,7 +1981,7 @@ impl RepoDetailView {
|
|||||||
.tooltip("Dismiss")
|
.tooltip("Dismiss")
|
||||||
.small()
|
.small()
|
||||||
.ghost()
|
.ghost()
|
||||||
.disabled(self.pushing)
|
.disabled(pushing)
|
||||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
this.banner_dismissed.insert(key.clone());
|
this.banner_dismissed.insert(key.clone());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -2063,52 +1993,102 @@ impl RepoDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The ready-to-contribute banner of the repository panel.
|
/// 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> {
|
fn render_ready_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
|
||||||
let status = self.ready_suggestion(cx)?;
|
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 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(
|
Some(
|
||||||
h_flex()
|
h_flex()
|
||||||
|
.p_4()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.px_4()
|
|
||||||
.pt_1()
|
|
||||||
.w_full()
|
.w_full()
|
||||||
.items_center()
|
.items_center()
|
||||||
|
.justify_between()
|
||||||
|
.bg(cx.theme().muted)
|
||||||
.child(
|
.child(
|
||||||
Alert::info("repo-ready-to-contribute", message)
|
h_flex()
|
||||||
.banner()
|
.gap_2()
|
||||||
.flex_1()
|
.text_sm()
|
||||||
.on_close(move |_event, _window, cx| {
|
.text_color(cx.theme().info)
|
||||||
view.update(cx, |this, _| {
|
.child(
|
||||||
this.banner_dismissed.insert(key.clone());
|
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(
|
.child(
|
||||||
Button::new("create-pr-from-banner")
|
h_flex()
|
||||||
.small()
|
.gap_1()
|
||||||
.icon(IconName::Plus)
|
.child(
|
||||||
.label("Create pull request")
|
Button::new("create-pr-from-banner")
|
||||||
.on_click(cx.listener(|this, _event, window, cx| {
|
.icon(IconName::Plus)
|
||||||
if let Some(store) = this.store.clone() {
|
.label("Create")
|
||||||
open_new_pull_panel(this.dock_area.clone(), store, window, cx);
|
.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(),
|
.into_any_element(),
|
||||||
)
|
)
|
||||||
@@ -2331,18 +2311,29 @@ impl Render for RepoDetailView {
|
|||||||
.render_ready_banner(cx)
|
.render_ready_banner(cx)
|
||||||
.or_else(|| self.render_push_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()
|
v_flex()
|
||||||
.image_cache(gpui::retain_all("repo"))
|
.image_cache(gpui::retain_all("repo"))
|
||||||
.id("repo")
|
.id("repo")
|
||||||
.size_full()
|
.size_full()
|
||||||
.when_some(banner, |this, banner| this.child(banner))
|
.when_some(banner, |this, banner| this.child(banner))
|
||||||
.child(self.render_header(cx))
|
.child(self.render_header(cx))
|
||||||
.when_some(self.error.clone(), |this, error| {
|
.when_some(error, |this, error| {
|
||||||
this.child(
|
this.child(
|
||||||
Alert::error("repo-error", error)
|
Alert::error("repo-error", error)
|
||||||
.banner()
|
.banner()
|
||||||
.on_close(cx.listener(|this, _event, _window, cx| {
|
.on_close(cx.listener(|this, _event, _window, cx| {
|
||||||
this.error = None;
|
this.error = None;
|
||||||
|
if let Some(store) = this.store.clone() {
|
||||||
|
store.update(cx, |store, _| store.last_error = None);
|
||||||
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user