diff --git a/Cargo.lock b/Cargo.lock
index ab991d8..545d8de 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7924,6 +7924,7 @@ dependencies = [
"nostr",
"nostr-connect",
"nostr-sdk",
+ "paths",
"rustls",
"signed_core",
"signed_git",
diff --git a/crates/assets/assets/icons/init.svg b/crates/assets/assets/icons/init.svg
new file mode 100644
index 0000000..060c043
--- /dev/null
+++ b/crates/assets/assets/icons/init.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/crates/assets/assets/icons/refresh.svg b/crates/assets/assets/icons/refresh.svg
new file mode 100644
index 0000000..b7ec11e
--- /dev/null
+++ b/crates/assets/assets/icons/refresh.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/crates/assets/assets/icons/settings.svg b/crates/assets/assets/icons/settings.svg
index 5ba9d43..cb2a6ac 100644
--- a/crates/assets/assets/icons/settings.svg
+++ b/crates/assets/assets/icons/settings.svg
@@ -1 +1,3 @@
-
\ No newline at end of file
+
+
+
diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs
index 7e6fc8a..d6d44b4 100644
--- a/crates/assets/src/lib.rs
+++ b/crates/assets/src/lib.rs
@@ -74,7 +74,9 @@ pub enum CustomIconName {
Share,
Trending,
Recent,
+ Refresh,
Grid,
+ Init,
}
impl IconNamed for CustomIconName {
@@ -101,8 +103,10 @@ impl IconNamed for CustomIconName {
CustomIconName::Markdown => "icons/markdown.svg",
CustomIconName::Share => "icons/share.svg",
CustomIconName::Trending => "icons/trending.svg",
+ CustomIconName::Refresh => "icons/refresh.svg",
CustomIconName::Recent => "icons/recent.svg",
CustomIconName::Grid => "icons/grid.svg",
+ CustomIconName::Init => "icons/init.svg",
}
.into()
}
diff --git a/crates/paths/src/lib.rs b/crates/paths/src/lib.rs
index 2de8abf..bc3fe6d 100644
--- a/crates/paths/src/lib.rs
+++ b/crates/paths/src/lib.rs
@@ -41,6 +41,12 @@ pub fn desktop_dir() -> PathBuf {
dirs::desktop_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
}
+/// Returns the current user's Documents folder, falling back to the home
+/// directory (or an empty path) when it can't be determined.
+pub fn documents_dir() -> PathBuf {
+ dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
+}
+
/// Sets a custom directory for all user data, overriding the default data
/// directory. Must be called before any other path operation. The directory
/// is created if it doesn't exist and canonicalized to an absolute path.
diff --git a/crates/signed_core/src/addr.rs b/crates/signed_core/src/addr.rs
index 0a7bc51..8617de8 100644
--- a/crates/signed_core/src/addr.rs
+++ b/crates/signed_core/src/addr.rs
@@ -2,12 +2,38 @@ use nostr::prelude::*;
/// Address of a NIP-34 repository announcement: `30617::`.
///
-/// The Rust Nostr SDK's [`Coordinate`] already provides parsing, formatting
-/// and hashing for this; the alias keeps the repository-specific vocabulary
-/// while reusing the SDK type.
+/// The Rust Nostr SDK's [`Coordinate`] already provides parsing, formatting and hashing for this.
+/// The alias keeps the repository-specific vocabulary while reusing the SDK type.
pub type RepoAddr = Coordinate;
/// Build the address of a NIP-34 repository announcement.
pub fn repo_addr(owner: PublicKey, id: impl Into) -> RepoAddr {
Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(id)
}
+
+/// Derive a repository identifier from a display name
+pub fn identifier_from_name(name: &str) -> String {
+ name.chars()
+ .map(|c| {
+ if c.is_ascii_alphanumeric() || c == '/' {
+ c
+ } else {
+ '-'
+ }
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn identifier_from_name_slugs_like_gitworkshop() {
+ assert_eq!(identifier_from_name("My Repo"), "My-Repo");
+ assert_eq!(identifier_from_name("my-repo"), "my-repo");
+ assert_eq!(identifier_from_name("Foo_Bar!"), "Foo-Bar-");
+ assert_eq!(identifier_from_name("a/b"), "a/b");
+ assert_eq!(identifier_from_name("Café"), "Caf-");
+ }
+}
diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs
index a19d715..b784804 100644
--- a/crates/signed_core/src/lib.rs
+++ b/crates/signed_core/src/lib.rs
@@ -8,7 +8,7 @@ pub mod model;
pub mod state;
pub mod status;
-pub use addr::{RepoAddr, repo_addr};
+pub use addr::{RepoAddr, identifier_from_name, repo_addr};
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
pub use clone_url::{CloneTarget, parse_clone_url};
pub use comments::{CommentThread, comment_threads};
diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs
index e7bef21..33a2ab2 100644
--- a/crates/signed_git/src/lib.rs
+++ b/crates/signed_git/src/lib.rs
@@ -1,7 +1,3 @@
-//! Blocking local git operations against GRASP servers.
-//!
-//! All functions may block; call them inside `cx.background_spawn`.
-
use std::collections::HashSet;
use std::io::Write;
use std::path::{Path, PathBuf};
@@ -63,6 +59,65 @@ impl GitCache {
}
}
+/// Maximum directory nesting depth when scanning for local repositories,
+/// so pathological trees can't stall the scan.
+const SCAN_MAX_DEPTH: usize = 12;
+
+/// Directories never descended into during a scan: dependency caches that
+/// can be enormous without ever containing user repositories.
+const SCAN_SKIPPED_DIRS: [&str; 1] = ["node_modules"];
+
+/// Walk `root` recursively and collect the paths of git repositories
+/// (directories containing a `.git` entry) below it.
+///
+/// Hidden entries and symlinks are skipped, and directories that are
+/// themselves repositories are not descended into (so nested repositories,
+/// like submodule worktrees, are not reported). Results are canonicalized,
+/// deduplicated and sorted by path.
+pub fn find_git_repos(root: &Path) -> Vec {
+ let mut repos = Vec::new();
+ if !root.is_dir() {
+ return repos;
+ }
+
+ let mut stack = vec![(root.to_path_buf(), 0usize)];
+ while let Some((dir, depth)) = stack.pop() {
+ if depth > SCAN_MAX_DEPTH {
+ continue;
+ }
+ // A directory containing a `.git` entry is a repository (a linked
+ // worktree has a `.git` file instead of a directory); don't descend.
+ if dir.join(".git").exists() {
+ if let Ok(path) = dir.canonicalize() {
+ repos.push(path);
+ }
+ continue;
+ }
+
+ let Ok(entries) = std::fs::read_dir(&dir) else {
+ continue;
+ };
+ for entry in entries.flatten() {
+ let Ok(file_type) = entry.file_type() else {
+ continue;
+ };
+ if !file_type.is_dir() || file_type.is_symlink() {
+ continue;
+ }
+ let file_name = entry.file_name();
+ let name = file_name.to_string_lossy();
+ if name.starts_with('.') || SCAN_SKIPPED_DIRS.contains(&name.as_ref()) {
+ continue;
+ }
+ stack.push((entry.path(), depth + 1));
+ }
+ }
+
+ repos.sort();
+ repos.dedup();
+ repos
+}
+
/// Clone a repository into `path` from the first working URL in
/// `clone_urls` (the announcement's `clone` tag), then fetch the
/// `refs/nostr/*` PR refs like the cache clone does. The destination must
@@ -197,16 +252,15 @@ pub fn init_repository(path: &Path, name: &str, description: &str) -> Result Result<()> {
let url = format!("{base_url}/{owner}/{repo_id}.git");
@@ -230,6 +284,58 @@ pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -
Ok(())
}
+/// Push every local branch and tag of the repository at `repo_path` to a grasp server,
+/// so an initialized repository's whole history is mirrored.
+pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
+ let url = format!("{base_url}/{owner}/{repo_id}.git");
+
+ let output = Command::new("git")
+ .arg("-C")
+ .arg(repo_path)
+ .args(["push"])
+ .arg(&url)
+ .args(["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"])
+ .env("GIT_TERMINAL_PROMPT", "0")
+ .stderr(Stdio::piped())
+ .output()
+ .context("failed to spawn `git push`")?;
+
+ if !output.status.success() {
+ bail!(
+ "git push to {base_url} failed: {}",
+ String::from_utf8_lossy(&output.stderr).trim()
+ );
+ }
+
+ Ok(())
+}
+
+/// The earliest unique commit of the repository at `repo_path` (a root
+/// commit, like `git rev-list --max-parents=0 HEAD`), used as the NIP-34
+/// announcement's `euc` marker. `None` for a repository without commits.
+pub fn root_commit(repo_path: &Path) -> Result> {
+ let output = Command::new("git")
+ .arg("-C")
+ .arg(repo_path)
+ .args(["rev-list", "--max-parents=0", "HEAD"])
+ .env("GIT_TERMINAL_PROMPT", "0")
+ .stderr(Stdio::piped())
+ .output()
+ .context("failed to spawn `git rev-list`")?;
+
+ // An unborn HEAD (no commits yet) makes `rev-list` fail,
+ // there is no unique commit to report then.
+ if !output.status.success() {
+ return Ok(None);
+ }
+
+ Ok(String::from_utf8_lossy(&output.stdout)
+ .lines()
+ .next()
+ .map(str::to_owned)
+ .filter(|id| id.len() == 40))
+}
+
/// Add `origin` pointing at `url` when the repository has no remote yet.
/// No-op if `origin` already exists.
pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> {
@@ -1552,6 +1658,135 @@ mod tests {
);
}
+ #[test]
+ fn find_git_repos_discovers_repositories_recursively() {
+ let temp = tempfile::tempdir().unwrap();
+ let root = temp.path();
+
+ // Repositories are found at any depth; a linked worktree (a `.git`
+ // file instead of a directory) counts too.
+ let nested = root.join("a/b/project");
+ std::fs::create_dir_all(nested.join(".git")).unwrap();
+ let worktree = root.join("wt");
+ std::fs::create_dir_all(&worktree).unwrap();
+ std::fs::write(
+ worktree.join(".git"),
+ "gitdir: ../a/b/project/.git/worktrees/wt",
+ )
+ .unwrap();
+
+ // Plain directories are not repositories.
+ std::fs::create_dir_all(root.join("plain")).unwrap();
+
+ // Hidden entries and dependency caches are skipped.
+ std::fs::create_dir_all(root.join(".hidden/repo/.git")).unwrap();
+ std::fs::create_dir_all(root.join("node_modules/pkg/.git")).unwrap();
+
+ // A repository is not descended into, so repositories inside it
+ // (submodule worktrees) are not reported.
+ let outer = root.join("outer");
+ std::fs::create_dir_all(outer.join(".git")).unwrap();
+ std::fs::create_dir_all(outer.join("sub/other/.git")).unwrap();
+
+ let mut found = find_git_repos(root);
+ found.sort();
+
+ let mut expected = vec![
+ nested.canonicalize().unwrap(),
+ worktree.canonicalize().unwrap(),
+ outer.canonicalize().unwrap(),
+ ];
+ expected.sort();
+ assert_eq!(found, expected);
+ }
+
+ #[test]
+ fn root_commit_reports_the_first_ancestor() {
+ let (dir, repo) = fixture(&[("a.txt", b"one")]);
+ commit_all(&repo, "initial");
+
+ let dir = dir.path();
+ let root = root_commit(dir).expect("root").expect("commit");
+ assert_eq!(root.len(), 40);
+
+ // The root commit does not change when history grows.
+ std::fs::write(dir.join("b.txt"), b"two").expect("write");
+ commit_all(&repo, "second");
+ assert_eq!(
+ root_commit(dir).expect("root").as_deref(),
+ Some(root.as_str())
+ );
+ }
+
+ #[test]
+ fn root_commit_is_none_without_commits() {
+ let (_dir, repo) = fixture(&[("a.txt", b"one")]);
+ let workdir = repo.workdir().expect("workdir");
+ assert_eq!(root_commit(workdir).expect("root"), None);
+ }
+
+ #[test]
+ fn push_all_mirrors_branches_and_tags() {
+ // A bare "server" repository reachable via a `file://` URL, like a
+ // grasp server's `{base}/{owner}/{repo-id}.git` layout.
+ let server = tempfile::tempdir().unwrap();
+ let server_repo = server.path().join("npub1test").join("my-repo.git");
+ std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap();
+ let init_status = Command::new("git")
+ .args(["init", "--bare", "-q"])
+ .arg(&server_repo)
+ .status()
+ .expect("spawn git init --bare");
+ assert!(init_status.success());
+
+ let (dir, repo) = fixture(&[("a.txt", b"one")]);
+ commit_all(&repo, "initial");
+ let dir = dir.path();
+
+ // Two branches plus a tag are all mirrored.
+ git_run(dir, &["checkout", "-b", "feature"]);
+ std::fs::write(dir.join("b.txt"), b"two").expect("write");
+ commit_all(&repo, "feature work");
+ git_run(dir, &["checkout", "-"]);
+ git_run(dir, &["tag", "v1.0"]);
+
+ let base_url = format!("file://{}", server.path().display());
+ push_all(dir, &base_url, "npub1test", "my-repo").expect("push");
+
+ let refs = git_in(&server_repo, &["show-ref"]).expect("server refs");
+ assert!(refs.contains("refs/heads/main"));
+ assert!(refs.contains("refs/heads/feature"));
+ assert!(refs.contains("refs/tags/v1.0"));
+ }
+
+ #[test]
+ fn push_all_tolerates_a_missing_ref_kind() {
+ // A repository with only tags (no branches) still pushes: wildcard
+ // refspecs without a local match are ignored.
+ let server = tempfile::tempdir().unwrap();
+ let server_repo = server.path().join("npub1test").join("my-repo.git");
+ std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap();
+ let init_status = Command::new("git")
+ .args(["init", "--bare", "-q"])
+ .arg(&server_repo)
+ .status()
+ .expect("spawn git init --bare");
+ assert!(init_status.success());
+
+ let (dir, repo) = fixture(&[("a.txt", b"one")]);
+ commit_all(&repo, "initial");
+ let dir = dir.path();
+ git_run(dir, &["tag", "v1.0"]);
+ git_run(dir, &["update-ref", "-d", "refs/heads/main"]);
+
+ let base_url = format!("file://{}", server.path().display());
+ push_all(dir, &base_url, "npub1test", "my-repo").expect("push");
+
+ let refs = git_in(&server_repo, &["show-ref"]).expect("server refs");
+ assert!(refs.contains("refs/tags/v1.0"));
+ assert!(!refs.contains("refs/heads/"));
+ }
+
#[test]
fn repo_ref_state_lists_branches_tags_and_head() {
let (_dir, repo) = fixture(&[("a.txt", b"hello")]);
diff --git a/crates/signed_state/Cargo.toml b/crates/signed_state/Cargo.toml
index 0bd56f0..6706315 100644
--- a/crates/signed_state/Cargo.toml
+++ b/crates/signed_state/Cargo.toml
@@ -23,3 +23,4 @@ log.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rustls = "0.23"
+paths = { path = "../paths" }
diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs
index 9036190..d6d31d6 100644
--- a/crates/signed_state/src/backend.rs
+++ b/crates/signed_state/src/backend.rs
@@ -1,4 +1,5 @@
use std::collections::HashMap;
+use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Duration;
@@ -9,7 +10,7 @@ use nostr::event::IntoEventBuilder;
use nostr_connect::prelude::*;
use nostr_sdk::client::SyncSummary;
use nostr_sdk::prelude::*;
-use signed_core::{Announcement, build_state, filters, repo_addr};
+use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_name, repo_addr};
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
use crate::git_store::GitStore;
@@ -496,15 +497,30 @@ impl Backend {
})?
.await?;
- this.update(cx, |this, cx| {
- let builder = build_state(
- &repo_id,
- &[("refs/heads/main".to_owned(), commit)],
- Some("main"),
- );
- this.send(builder, cx)
- })?
- .await?;
+ let state_event = match this
+ .update(cx, |this, cx| {
+ let builder = build_state(
+ &repo_id,
+ &[("refs/heads/main".to_owned(), commit)],
+ Some("main"),
+ );
+ this.send(builder, cx)
+ })?
+ .await
+ {
+ Ok(state_event) => state_event,
+ Err(e) => {
+ this.update(cx, |this, cx| {
+ this.retract_events(std::slice::from_ref(&event), cx);
+ })
+ .ok();
+
+ return Err(e.context(
+ "The repository was announced, but its state could not be published. \
+ The announcement has been retracted",
+ ));
+ }
+ };
// 4. Push the initial commit to every grasp server. A server
// that fails to accept the push is logged, but the creation
@@ -514,38 +530,285 @@ impl Backend {
let owner = owner.clone();
let repo_id = repo_id.clone();
let servers = servers.clone();
+ push_to_grasp_servers(path, owner, repo_id, servers, signed_git::push_main)
+ });
+ if let Err(e) = push.await {
+ // The events are already published; retract them so the
+ // repository doesn't remain announced without content.
+ this.update(cx, |this, cx| {
+ this.retract_events(&[event.clone(), state_event.clone()], cx);
+ })
+ .ok();
+ return Err(e.context(
+ "The repository was announced, but the push to every grasp server failed. \
+ The announcement has been retracted",
+ ));
+ }
+
+ Announcement::from_event(&event).ok_or_else(|| anyhow!("failed to parse announcement"))
+ })
+ }
+
+ /// Publish an existing local repository to NIP-34: read its current
+ /// branches, tags and HEAD, publish the announcement and the repository
+ /// state to the grasp relays, then push every branch and tag to each
+ /// grasp server. Also points `origin` at the first grasp server.
+ ///
+ /// The events must reach the grasp servers *before* the push, like
+ /// [`Self::create_repository`]: GRASP servers hold the signed state
+ /// event in "purgatory" and only accept a push while that
+ /// authorization is pending.
+ ///
+ /// The git work (ref listing, push) runs on background threads. The
+ /// returned task yields the published announcement on success, so
+ /// callers can switch the repository into its NIP-34 mode.
+ pub fn publish_local_repo(
+ &mut self,
+ path: PathBuf,
+ name: &str,
+ description: &str,
+ grasp_servers: Vec,
+ cx: &mut Context,
+ ) -> Task> {
+ let name = name.trim().to_owned();
+ let description = description.trim().to_owned();
+
+ if name.is_empty() {
+ return Task::ready(Err(anyhow!("Repository name is required")));
+ }
+
+ if grasp_servers.is_empty() {
+ return Task::ready(Err(anyhow!("Add at least one grasp server")));
+ }
+
+ let Some(public_key) = self.current_user else {
+ return Task::ready(Err(anyhow!("Sign in to publish a repository")));
+ };
+
+ // The repository identifier is derived from the name, like
+ // [`Self::create_repository`]: spaces become hyphens, other
+ // non-alphanumeric characters (except `/`) become hyphens.
+ let repo_id = identifier_from_name(&name);
+
+ if repo_id.is_empty() || repo_id.len() > 100 {
+ return Task::ready(Err(anyhow!(
+ "Repository name must produce an identifier of 1-100 characters"
+ )));
+ }
+
+ if !repo_id.chars().any(|c| c.is_ascii_alphanumeric()) {
+ return Task::ready(Err(anyhow!(
+ "Repository name must contain at least one alphanumeric character"
+ )));
+ }
+
+ let owner = public_key.to_bech32().unwrap();
+ let servers = grasp_servers.clone();
+
+ cx.spawn(async move |this, cx| {
+ // 1. Read the local repository's refs (branches, tags, HEAD)
+ // and its root commit on a background thread.
+ let work = cx.background_spawn({
+ let path = path.clone();
async move {
- let mut failures = Vec::new();
- let mut pushed = 0;
- for relay in &servers {
- let Some(base_url) = grasp_base_url(relay) else {
- failures.push(format!("{relay}: no domain"));
- continue;
- };
- match signed_git::push_main(&path, &base_url, &owner, &repo_id) {
- Ok(()) => pushed += 1,
- Err(e) => failures.push(format!("{relay}: {e}")),
- }
- }
-
- if pushed == 0 {
- bail!(
- "could not push the repository to any grasp server: {}",
- failures.join("; ")
- );
- }
- for failure in failures {
- log::warn!("grasp push failed: {failure}");
- }
-
- Ok::<_, Error>(())
+ let state = signed_git::worktree_ref_state(&path)?;
+ let euc = signed_git::root_commit(&path)?;
+ Ok::<_, Error>((state, euc))
}
});
- push.await?;
+ let (state, euc) = work.await?;
- Announcement::from_event(&event)
- .ok_or_else(|| anyhow!("failed to parse the published announcement"))
+ // 2. Ensure the grasp servers are in the relay pool; the nostr
+ // client queues events until each relay is connected.
+ this.update(cx, |this, cx| {
+ let urls: Vec = servers.iter().map(ToString::to_string).collect();
+ this.add_relays(urls, cx);
+ })?;
+
+ // 3. Publish the announcement, then the state event, to the
+ // grasp relays. The state event is the push authorization
+ // ("purgatory"), so it must be accepted before step 4.
+ let announcement = GitRepositoryAnnouncement {
+ id: repo_id.clone(),
+ name: Some(name.clone()),
+ description: (!description.is_empty()).then_some(description.clone()),
+ web: Vec::new(),
+ clone: servers
+ .iter()
+ .filter_map(|relay| grasp_clone_url(relay, &owner, &repo_id))
+ .collect(),
+ relays: servers.clone(),
+ euc: euc.and_then(|commit| Sha1Hash::from_str(&commit).ok()),
+ maintainers: Vec::new(),
+ };
+
+ let event = this
+ .update(cx, |this, cx| {
+ this.send(announcement.into_event_builder(), cx)
+ })?
+ .await?;
+
+ let refs = state.refs.clone();
+ let head = state.head.clone();
+ let state_event = match this
+ .update(cx, |this, cx| {
+ let builder = build_state(&repo_id, &refs, head.as_deref());
+ this.send(builder, cx)
+ })?
+ .await
+ {
+ Ok(state_event) => state_event,
+ Err(e) => {
+ this.update(cx, |this, cx| {
+ this.retract_events(std::slice::from_ref(&event), cx);
+ })
+ .ok();
+
+ return Err(e.context(
+ "The repository was announced, but its state could not be published. \
+ The announcement has been retracted",
+ ));
+ }
+ };
+
+ // 4. Push every branch and tag to each grasp server. A server
+ // that fails to accept the push is logged, but the init only
+ // fails when no server accepted it. An empty repository
+ // (no refs yet) has nothing to push.
+ if !refs.is_empty() {
+ let push = cx.background_spawn({
+ let path = path.clone();
+ let owner = owner.clone();
+ let repo_id = repo_id.clone();
+ let servers = servers.clone();
+ push_to_grasp_servers(path, owner, repo_id, servers, signed_git::push_all)
+ });
+ if let Err(e) = push.await {
+ this.update(cx, |this, cx| {
+ this.retract_events(&[event.clone(), state_event.clone()], cx);
+ })
+ .ok();
+
+ return Err(e.context(
+ "The repository was announced, but the push to every grasp server failed. \
+ The announcement has been retracted",
+ ));
+ }
+ }
+
+ // 5. Point `origin` at the first grasp server so later pushes
+ // have a target, like the create flow.
+ if let Some(base) = servers.first().and_then(grasp_base_url) {
+ let url = format!("{base}/{owner}/{repo_id}.git");
+ let path = path.clone();
+ cx.background_spawn(async move {
+ signed_git::ensure_origin(&path, &url).ok();
+ })
+ .await;
+ }
+
+ Announcement::from_event(&event).ok_or_else(|| anyhow!("failed to parse announcement"))
+ })
+ }
+
+ /// Re-push the repository's current refs to the grasp servers announced
+ /// in its `relays` tag: publishes a fresh state event (the push
+ /// authorization), then pushes every branch and tag, like the init
+ /// flow. The repository must have a local clone in the cache.
+ pub fn push_repository(
+ &mut self,
+ announcement: Announcement,
+ cx: &mut Context,
+ ) -> Task> {
+ let addr = announcement.addr();
+ let cache = GitStore::global(cx).cache().clone();
+ let path = cache.repo_path(&addr);
+ let owner = announcement
+ .owner
+ .to_bech32()
+ .unwrap_or_else(|_| announcement.owner.to_hex());
+ let repo_id = announcement.id.clone();
+ let relays = announcement.relays.clone();
+
+ cx.spawn(async move |this, cx| {
+ // 1. Read the current refs of the local clone.
+ let work = cx.background_spawn({
+ let path = path.clone();
+ async move { signed_git::worktree_ref_state(&path) }
+ });
+ let state = work.await?;
+
+ // 2. Publish a fresh state event; grasp servers authorize a
+ // push by the state they have seen.
+ let refs = state.refs.clone();
+ let head = state.head.clone();
+ this.update(cx, |this, cx| {
+ let builder = build_state(&repo_id, &refs, head.as_deref());
+ this.send(builder, cx)
+ })?
+ .await?;
+
+ // 3. Push every branch and tag to the announced grasp servers.
+ if !refs.is_empty() {
+ let push = cx.background_spawn({
+ let path = path.clone();
+ let owner = owner.clone();
+ let repo_id = repo_id.clone();
+ let relays = relays.clone();
+ async move {
+ push_to_grasp_servers(path, owner, repo_id, relays, signed_git::push_all)
+ .await
+ }
+ });
+ push.await?;
+ }
+
+ Ok(())
+ })
+ }
+
+ /// Delete the repository from nostr: publish NIP-09 deletions for its
+ /// announcement, state and activity events (issues, pull requests,
+ /// patches, statuses, comments). Only the repository owner may delete
+ /// it.
+ pub fn delete_repository(
+ &mut self,
+ addr: RepoAddr,
+ cx: &mut Context,
+ ) -> Task> {
+ let Some(public_key) = self.current_user else {
+ return Task::ready(Err(anyhow!("Sign in to delete a repository")));
+ };
+ if public_key != addr.public_key {
+ return Task::ready(Err(anyhow!("Only the repository owner can delete it")));
+ }
+
+ let client = self.client.clone();
+ let addr = addr.clone();
+
+ cx.spawn(async move |this, cx| {
+ // Collect every event of the repository from the local database.
+ let events = cx.background_spawn(async move {
+ let db = client.database();
+ let mut events = Vec::new();
+ for filter in [
+ filters::announcement(&addr),
+ filters::state(&addr),
+ filters::activity(&addr),
+ ] {
+ events.extend(db.query(filter).await?);
+ }
+ Ok::<_, Error>(events)
+ });
+ let events = events.await?;
+
+ this.update(cx, |this, cx| {
+ this.retract_events(&events, cx);
+ })
+ .ok();
+
+ Ok(())
})
}
@@ -578,9 +841,7 @@ impl Backend {
/// Login with an `nsec1...` secret key. The credential is verified by
/// the signer flow and persisted in the keyring.
pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context) {
- let nsec = nsec.trim().to_owned();
-
- let keys = match SecretKey::parse(&nsec) {
+ let keys = match SecretKey::parse(nsec) {
Ok(secret) => Keys::new(secret),
Err(e) => {
cx.emit(BackendEvent::error(e.to_string()));
@@ -588,15 +849,15 @@ impl Backend {
}
};
- let write =
- cx.write_credentials(USER_KEYRING, &keys.public_key().to_hex(), nsec.as_bytes());
+ let nsec = nsec.trim().to_owned();
+ let pubkey = keys.public_key().to_hex();
+ let write = cx.write_credentials(USER_KEYRING, &pubkey, nsec.as_bytes());
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(e) = write.await {
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
return Ok(());
}
-
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
Ok(())
}));
@@ -1040,6 +1301,32 @@ impl Backend {
Ok(())
}));
}
+
+ /// Publish a NIP-09 deletion event for `events` (best-effort), so a
+ /// publish that fails midway can retract the events that were already
+ /// broadcast to relays. Failures are logged, not surfaced: the caller's
+ /// error already told the user what happened.
+ fn retract_events(&mut self, events: &[Event], cx: &mut Context) {
+ if events.is_empty() {
+ return;
+ }
+
+ let mut tags: Vec = Vec::with_capacity(events.len() * 2);
+
+ for event in events {
+ tags.push(Tag::event(event.id));
+ tags.push(Tag::parse(["k", &event.kind.to_string()]).expect("valid kind tag"));
+ }
+
+ let task = self.send(EventBuilder::new(Kind::EventDeletion, "").tags(tags), cx);
+
+ self.tasks.push(cx.spawn(async move |_this, _cx| {
+ if let Err(e) = task.await {
+ log::warn!("failed to retract repository events: {e}");
+ }
+ Ok(())
+ }));
+ }
}
/// Add the given relays, connect to them, and fetch the filters: a one-shot
@@ -1128,21 +1415,6 @@ fn with_master_key(uri: &str, keys: &Keys) -> String {
format!("{uri}{separator}master={nsec}")
}
-/// Derive a repository identifier (d-tag) from the repo name, matching ngit
-/// and gitworkshop: spaces become hyphens, other non-alphanumeric characters
-/// (except `/`) become hyphens, case is preserved.
-fn identifier_from_name(name: &str) -> String {
- name.chars()
- .map(|c| {
- if c.is_ascii_alphanumeric() || c == '/' {
- c
- } else {
- '-'
- }
- })
- .collect()
-}
-
/// A `https://` (or `http://` for `ws://` grasp servers, like
/// ngit) base URL for a grasp server. The repository then lives at
/// `{base}/{npub}/{repo-id}.git`.
@@ -1169,6 +1441,46 @@ fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
}
+/// Push the repository at `path` to every grasp server: a server that
+/// rejects the push is logged, but the push only fails when no server
+/// accepted it. `push` performs the single-server push (e.g.
+/// [`signed_git::push_main`] for the create flow, [`signed_git::push_all`]
+/// for the init flow).
+async fn push_to_grasp_servers(
+ path: PathBuf,
+ owner: String,
+ repo_id: String,
+ servers: Vec,
+ push: fn(&Path, &str, &str, &str) -> Result<(), Error>,
+) -> Result<(), Error> {
+ let mut failures = Vec::new();
+ let mut pushed = 0;
+
+ for relay in &servers {
+ let Some(base_url) = grasp_base_url(relay) else {
+ failures.push(format!("{relay}: no domain"));
+ continue;
+ };
+ match push(&path, &base_url, &owner, &repo_id) {
+ Ok(()) => pushed += 1,
+ Err(e) => failures.push(format!("{relay}: {e}")),
+ }
+ }
+
+ if pushed == 0 {
+ bail!(
+ "could not push the repository to any grasp server: {}",
+ failures.join("; ")
+ );
+ }
+
+ for failure in failures {
+ log::warn!("grasp push failed: {failure}");
+ }
+
+ Ok(())
+}
+
/// Split a stored bunker credential into the plain URI and the session key.
/// Credentials without an embedded key (legacy) get a fresh one.
fn extract_master_key(credential: &str) -> (&str, Keys) {
@@ -1187,15 +1499,6 @@ fn extract_master_key(credential: &str) -> (&str, Keys) {
mod tests {
use super::*;
- #[test]
- fn identifier_from_name_slugs_like_gitworkshop() {
- assert_eq!(identifier_from_name("My Repo"), "My-Repo");
- assert_eq!(identifier_from_name("my-repo"), "my-repo");
- assert_eq!(identifier_from_name("Foo_Bar!"), "Foo-Bar-");
- assert_eq!(identifier_from_name("a/b"), "a/b");
- assert_eq!(identifier_from_name("Café"), "Caf-");
- }
-
#[test]
fn grasp_base_url_maps_schemes_like_ngit() {
let wss = RelayUrl::parse("wss://relay.ngit.dev").expect("url");
diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs
index 2e39ab0..927e859 100644
--- a/crates/signed_state/src/lib.rs
+++ b/crates/signed_state/src/lib.rs
@@ -1,5 +1,6 @@
mod backend;
mod git_store;
+mod local_repos;
mod profile;
mod repo;
mod repo_list;
@@ -9,6 +10,7 @@ use std::path::{Path, PathBuf};
pub use backend::{Backend, BackendEvent};
pub use git_store::GitStore;
use gpui::{App, AppContext, Entity};
+pub use local_repos::LocalReposStore;
pub use nostr_sdk::prelude::Timestamp;
pub use profile::{Profile, ProfileStore};
pub use repo::RepoStore;
@@ -16,8 +18,15 @@ pub use repo_list::{RepoActivityCounts, RepoListStore};
use signed_nostr::new_backend;
pub use utils::shorten_pubkey;
-/// Initialize the backend and stores, and install them as globals. Call once
-/// at startup, before opening any window that uses the stores.
+/// The default directories scanned for local git repositories
+/// on every platform: the user's Desktop and Documents folders.
+#[cfg(not(target_arch = "wasm32"))]
+fn default_scan_paths() -> Vec {
+ vec![paths::desktop_dir(), paths::documents_dir()]
+}
+
+/// Initialize the backend and stores, and install them as globals.
+/// Call once at startup, before opening any window that uses the stores.
#[cfg(not(target_arch = "wasm32"))]
pub fn init(db_path: impl AsRef, cx: &mut App) -> Entity {
// rustls uses the `aws_lc_rs` provider by default; ignore if already installed.
@@ -25,46 +34,64 @@ pub fn init(db_path: impl AsRef, cx: &mut App) -> Entity {
.install_default()
.ok();
- let path = db_path.as_ref().to_path_buf();
+ // Initialize the nostr client and universal signer.
let (client, signer) = cx.foreground_executor().block_on(async move {
+ let path = db_path.as_ref().to_path_buf();
new_backend(path)
.await
.expect("failed to initialize nostr backend")
});
+ // Initialize the backend and stores.
let entity = cx.new(|cx| Backend::new(client, signer, cx));
Backend::set_global(entity.clone(), cx);
+ // Initialize the profile store.
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
- // Start the explore list from the local database before the first
- // window opens; relay syncs continue in the background, so the list
- // never waits for them.
+ // Start the explore list from the local database before
+ // the first window opens, relay syncs continue in the background,
+ // so the list never waits for them.
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
- // The clone cache is only meaningful on native platforms; the wasm
- // build registers an empty store so `GitStore::global` still works.
+ // The clone cache is only meaningful on native platforms,
+ // the wasm build registers an empty store so `GitStore::global` still works.
GitStore::set_global(PathBuf::new(), cx);
+ // Scan the default directories (Desktop, Documents) for local git
+ // repositories; the sidebar lists them next to the user's NIP-34 repos.
+ LocalReposStore::set_global(
+ cx.new(|cx| LocalReposStore::new(default_scan_paths(), cx)),
+ cx,
+ );
+
entity
}
/// Initialize the backend with an in-memory database on wasm.
#[cfg(target_arch = "wasm32")]
pub fn init(cx: &mut App) -> Entity {
+ // Initialize the nostr client and universal signer.
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
+ // Initialize the backend and stores.
let entity = cx.new(|cx| Backend::new(client, signer, cx));
Backend::set_global(entity.clone(), cx);
+ // Initialize the profile store.
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
- // Start the explore list from the local database before the first
- // window opens; relay syncs continue in the background, so the list
- // never waits for them.
+ // Start the explore list from the local database before
+ // the first window opens, relay syncs continue in the background,
+ // so the list never waits for them.
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
+ // The clone cache is only meaningful on native platforms,
+ // the wasm build registers an empty store so `GitStore::global` still works.
GitStore::set_global(PathBuf::new(), cx);
+ // No filesystem scan on wasm: there are no local git repositories.
+ LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
+
entity
}
diff --git a/crates/signed_state/src/local_repos.rs b/crates/signed_state/src/local_repos.rs
new file mode 100644
index 0000000..934420a
--- /dev/null
+++ b/crates/signed_state/src/local_repos.rs
@@ -0,0 +1,116 @@
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use anyhow::Error;
+use gpui::{App, AppContext, Context, Entity, Global, Task};
+use signed_git::find_git_repos;
+
+struct GlobalLocalReposStore(Entity);
+
+impl Global for GlobalLocalReposStore {}
+
+/// Store of the git repositories discovered under a set of scan paths.
+///
+/// Created at startup by [`crate::init`] with the default scan paths
+/// (the Desktop and Documents folders; empty on wasm, where no scan runs),
+/// then installed as a global so the sidebar can list local repositories.
+/// The scan runs on a background thread; only the results cross back into the entity.
+pub struct LocalReposStore {
+ /// The directories being scanned.
+ pub roots: Arc>,
+ /// Git repositories discovered under [`Self::roots`], sorted by path.
+ pub repos: Arc>,
+ /// A scan is currently running.
+ pub scanning: bool,
+ /// A scan was requested while one was already running.
+ scan_dirty: bool,
+ tasks: Vec>>,
+}
+
+impl LocalReposStore {
+ /// Retrieve the global local-repositories store
+ /// (created at startup by [`crate::init`]).
+ pub fn global(cx: &App) -> Entity {
+ cx.global::().0.clone()
+ }
+
+ pub(crate) fn set_global(entity: Entity, cx: &mut App) {
+ cx.set_global(GlobalLocalReposStore(entity));
+ }
+
+ /// Create a store scanning `roots` right away
+ /// (a no-op when the list is empty, e.g. on wasm).
+ pub fn new(roots: Vec, cx: &mut Context) -> Self {
+ let mut store = Self {
+ roots: Arc::new(roots),
+ repos: Arc::new(Vec::new()),
+ scanning: false,
+ scan_dirty: false,
+ tasks: Vec::new(),
+ };
+ store.rescan(cx);
+ store
+ }
+
+ /// Forget a repository that has just been published to NIP-34, so it
+ /// leaves the local list immediately. A later rescan re-discovers it
+ /// from disk; the sidebar additionally hides published repositories by
+ /// identifier.
+ pub fn remove(&mut self, path: &Path, cx: &mut Context) {
+ self.repos = Arc::new(
+ self.repos
+ .iter()
+ .filter(|repo| repo.as_path() != path)
+ .cloned()
+ .collect(),
+ );
+ cx.notify();
+ }
+
+ /// Re-run the scan. Requests that arrive while a scan is running are
+ /// folded into one follow-up scan; the results replace the list atomically.
+ pub fn rescan(&mut self, cx: &mut Context) {
+ if self.scanning {
+ self.scan_dirty = true;
+ return;
+ }
+ if self.roots.is_empty() {
+ return;
+ }
+
+ self.scanning = true;
+ cx.notify();
+
+ let roots = self.roots.clone();
+ let work = cx.background_spawn(async move {
+ let mut repos = Vec::new();
+ for root in roots.iter() {
+ repos.extend(find_git_repos(root));
+ }
+ repos.sort();
+ repos.dedup();
+ repos
+ });
+
+ self.tasks.push(cx.spawn(async move |this, cx| {
+ let repos = work.await;
+ let again = this.update(cx, |this, cx| {
+ this.repos = Arc::new(repos);
+ this.scanning = false;
+ cx.notify();
+
+ let dirty = this.scan_dirty;
+ this.scan_dirty = false;
+ dirty
+ })?;
+
+ // Scans requested while this one was running are coalesced into
+ // a single follow-up scan.
+ if again {
+ this.update(cx, |this, cx| this.rescan(cx))?;
+ }
+
+ Ok(())
+ }));
+ }
+}
diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs
index 6456352..9f00170 100644
--- a/crates/signed_state/src/repo.rs
+++ b/crates/signed_state/src/repo.rs
@@ -86,8 +86,13 @@ impl RepoStore {
let kind = event.kind == Kind::GitRepoAnnouncement;
let author = event.pubkey == this.addr.public_key;
let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr);
+ // Locally published deletions may target any event of
+ // this repository; refresh so they take effect
+ // immediately, like relay deletions.
+ let deletion =
+ event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
- coordinate || (kind && author)
+ coordinate || (kind && author) || deletion
}
_ => false,
};
diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs
index e547c95..3d8d91f 100644
--- a/crates/signed_state/src/repo_list.rs
+++ b/crates/signed_state/src/repo_list.rs
@@ -98,8 +98,14 @@ impl RepoListStore {
}
}
BackendEvent::Published(event) => {
- event.kind == Kind::GitRepoAnnouncement
- && this.author.is_none_or(|a| a == event.pubkey)
+ let announcement = event.kind == Kind::GitRepoAnnouncement
+ && this.author.is_none_or(|a| a == event.pubkey);
+ // Locally published deletions (e.g. deleting a repo)
+ // are already in the local database; refresh so they
+ // take effect immediately, like relay deletions.
+ let deletion =
+ event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
+ announcement || deletion
}
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
_ => false,
diff --git a/crates/workspace/src/views/repo_detail/about.rs b/crates/workspace/src/views/repo_detail/about.rs
index 1bad904..36c2091 100644
--- a/crates/workspace/src/views/repo_detail/about.rs
+++ b/crates/workspace/src/views/repo_detail/about.rs
@@ -204,21 +204,22 @@ fn list(id: &'static str, items: impl IntoIterator- , cx: &App) ->
.min_w_0()
.children(items.into_iter().enumerate().map(|(ix, item)| {
h_flex()
+ .id(ix)
+ .h_8()
+ .px_2()
.gap_2()
- .items_center()
.min_w_0()
+ .bg(cx.theme().secondary)
+ .hover(|this| this.bg(cx.theme().secondary_hover))
+ .rounded(cx.theme().radius)
+ .text_color(cx.theme().secondary_foreground)
.child(
- h_flex()
- .h_5()
- .px_1()
+ div()
.flex_1()
.min_w_0()
- .overflow_hidden()
.whitespace_nowrap()
.text_ellipsis()
.text_sm()
- .bg(cx.theme().muted)
- .rounded(cx.theme().radius)
.child(SharedString::from(middle_truncate(&item, 28, 16))),
)
.child(
diff --git a/crates/workspace/src/views/repo_detail/init_dialog.rs b/crates/workspace/src/views/repo_detail/init_dialog.rs
new file mode 100644
index 0000000..09c9f09
--- /dev/null
+++ b/crates/workspace/src/views/repo_detail/init_dialog.rs
@@ -0,0 +1,217 @@
+use std::path::PathBuf;
+
+use assets::CustomIconName;
+use gpui::prelude::*;
+use gpui::{App, Entity, SharedString, WeakEntity, Window, div, px};
+use gpui_base::h_flex;
+use gpui_base::input::TextareaState;
+use gpui_component::button::{Button, ButtonVariants};
+use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
+use gpui_component::form::{field, v_form};
+use gpui_component::input::{Input, InputState, Textarea};
+use gpui_component::{ActiveTheme, Disableable, WindowExt};
+use signed_state::Backend;
+
+use super::RepoDetailView;
+use crate::views::sidebar::grasp_servers::{
+ GraspServersState, grasp_servers_field, load_user_grasp_servers,
+};
+
+/// Shared state for the Init dialog, so async results can be rendered.
+#[derive(Default)]
+pub struct InitRepoState {
+ pub busy: bool,
+ pub error: Option
,
+}
+
+/// Open the Init dialog for the local repository at `local_path`.
+///
+/// The dialog loads the user's default grasp servers (kind `10317` grasp
+/// list) and falls back to the shared defaults when none are set. On
+/// success the dialog closes and `view` switches into NIP-34 mode.
+pub fn open(
+ local_path: PathBuf,
+ view: WeakEntity,
+ window: &mut Window,
+ cx: &mut App,
+) {
+ let default_name = local_path
+ .file_name()
+ .map(|name| name.to_string_lossy().into_owned())
+ .unwrap_or_default();
+ let name_input = cx.new(|cx| InputState::new(window, cx).default_value(default_name));
+ let desc_input = cx.new(|cx| {
+ TextareaState::new(window, cx)
+ .auto_grow(3, 5)
+ .placeholder("Short description")
+ });
+ let relay_input = cx.new(|cx| {
+ InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com")
+ });
+ let state = cx.new(|_| InitRepoState::default());
+ let grasp_state = cx.new(|_| GraspServersState::new_default());
+
+ load_user_grasp_servers(grasp_state.clone(), window, cx);
+
+ window.open_dialog(cx, move |dialog, _window, _cx| {
+ const DESC: &str = "Publish this local repository to Nostr.";
+
+ let name_input = name_input.clone();
+ let desc_input = desc_input.clone();
+ let relay_input = relay_input.clone();
+ let state = state.clone();
+ let grasp_state = grasp_state.clone();
+ let local_path = local_path.clone();
+ let view = view.clone();
+
+ dialog
+ .width(px(520.))
+ .margin_top(px(50.))
+ .content(move |content, _window, cx| {
+ let busy = state.read(cx).busy;
+ let error = state.read(cx).error.clone();
+
+ content
+ .child(
+ DialogHeader::new()
+ .child(DialogTitle::new().child("Initialize repository"))
+ .child(DialogDescription::new().child(DESC)),
+ )
+ .child(
+ v_form()
+ .child(
+ field()
+ .label("Repository name")
+ .description("Max 100 characters")
+ .required(true)
+ .child(Input::new(&name_input).readonly(true)),
+ )
+ .child(
+ field()
+ .label("Description")
+ .child(Textarea::new(&desc_input)),
+ )
+ .child(
+ field()
+ .label("Folder")
+ .description("The local repository being published")
+ .child(
+ h_flex()
+ .h_8()
+ .w_full()
+ .px_2()
+ .bg(cx.theme().muted)
+ .text_sm()
+ .text_color(cx.theme().muted_foreground)
+ .rounded(cx.theme().radius)
+ .child(local_path.display().to_string()),
+ ),
+ )
+ .child(grasp_servers_field(&grasp_state, &relay_input, cx)),
+ )
+ .children(error.map(|message| {
+ div().text_sm().text_color(cx.theme().danger).child(message)
+ }))
+ .child(
+ DialogFooter::new().justify_end().child(
+ Button::new("init")
+ .primary()
+ .label("Initialize")
+ .icon(CustomIconName::Init)
+ .tooltip("Publish to Nostr")
+ .loading(busy)
+ .disabled(busy)
+ .on_click({
+ let name_input = name_input.clone();
+ let desc_input = desc_input.clone();
+ let state = state.clone();
+ let grasp_state = grasp_state.clone();
+ let local_path = local_path.clone();
+ let view = view.clone();
+
+ move |_ev, window, cx| {
+ init_repository(
+ local_path.clone(),
+ (name_input.clone(), desc_input.clone()),
+ state.clone(),
+ grasp_state.clone(),
+ view.clone(),
+ window,
+ cx,
+ );
+ }
+ }),
+ ),
+ )
+ })
+ });
+}
+
+/// Run the init flow; closes the dialog and switches the repository into
+/// its NIP-34 mode on success.
+fn init_repository(
+ local_path: PathBuf,
+ inputs: (Entity, Entity),
+ state: Entity,
+ grasp_state: Entity,
+ view: WeakEntity,
+ window: &mut Window,
+ cx: &mut App,
+) {
+ let (name_input, desc_input) = inputs;
+ let name = name_input.read(cx).value().trim().to_owned();
+ let description = desc_input.read(cx).value().trim().to_owned();
+ let servers = grasp_state.read(cx).grasp_servers.clone();
+
+ if name.is_empty() {
+ state.update(cx, |state, _| {
+ state.error = Some("Repository name is required".into());
+ });
+ return;
+ }
+
+ if servers.is_empty() {
+ state.update(cx, |state, _| {
+ state.error = Some("Add at least one grasp server".into());
+ });
+ return;
+ }
+
+ state.update(cx, |state, _| {
+ state.busy = true;
+ state.error = None;
+ });
+
+ let backend = Backend::global(cx);
+ let task = backend.update(cx, |backend, cx| {
+ backend.publish_local_repo(local_path.clone(), &name, &description, servers, cx)
+ });
+
+ let handle = window.window_handle();
+ let state = state.clone();
+ let view = view.clone();
+
+ cx.spawn(async move |cx| match task.await {
+ Ok(announcement) => {
+ cx.update_window(handle, |_, window, cx| {
+ window.close_dialog(cx);
+ if let Some(view) = view.upgrade() {
+ view.update(cx, |this, cx| {
+ this.apply_announcement(announcement, cx);
+ });
+ }
+ })
+ .ok();
+ }
+ Err(e) => {
+ cx.update_window(handle, |_, _window, cx| {
+ state.update(cx, |state, _| {
+ state.busy = false;
+ state.error = Some(e.to_string().into());
+ });
+ })
+ .ok();
+ }
+ })
+ .detach();
+}
diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs
index 9a26175..d8558d0 100644
--- a/crates/workspace/src/views/repo_detail/mod.rs
+++ b/crates/workspace/src/views/repo_detail/mod.rs
@@ -13,12 +13,14 @@ use gpui::{
Task, WeakEntity, Window, div, px, relative, size,
};
use gpui_base::{Button as BaseButton, Disableable, Popover};
+use gpui_component::alert::Alert;
use gpui_component::avatar::Avatar;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::clipboard::Clipboard;
use gpui_component::combobox::{
Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext,
};
+use gpui_component::menu::DropdownMenu;
use gpui_component::searchable_list::SearchableVec;
use gpui_component::tree::TreeState;
use gpui_component::{
@@ -28,7 +30,7 @@ use gpui_component::{
use nostr::prelude::{RelayUrl, ToBech32};
use signed_core::Announcement;
use signed_git::{CommitList, FileCommit};
-use signed_state::{GitStore, ProfileStore, RepoStore};
+use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoStore};
use crate::image_cache::{MAX_IMAGES, image_cache};
use crate::pixel_avatar::PixelAvatar;
@@ -38,6 +40,7 @@ mod browser;
mod commits;
mod diff;
mod helpers;
+mod init_dialog;
mod issue_detail;
mod issues;
mod pull_request_detail;
@@ -73,6 +76,12 @@ enum RepoAction {
NewIssue,
/// Open the "new pull request" dialog.
NewPR,
+ /// Open the about dialog.
+ About,
+ /// Re-push the repository to its grasp servers.
+ Push,
+ /// Delete the repository from nostr (owner only).
+ Delete,
}
/// Everything loaded from the local clone for the explorer: the tree seeds,
@@ -98,9 +107,15 @@ pub struct RepoDetailView {
dock_area: WeakEntity,
/// Snapshot taken at open time, shown until the store's first refresh
/// completes (and as a fallback while the store has no announcement).
- initial: Announcement,
+ /// `None` for local repositories that haven't been published yet.
+ initial: Option,
/// Per-repository nostr store (announcement, issues, PRs, statuses).
- store: Entity,
+ /// `None` until a local repository is initialized (published) to
+ /// NIP-34.
+ store: Option>,
+ /// Path of the local repository when opened from the scan; `None` once
+ /// it has been initialized to NIP-34 (or for announced repositories).
+ local_path: Option,
/// File explorer state (worktree of the local clone).
tree_state: Entity,
/// Root of the local clone, for reading files on demand.
@@ -141,6 +156,8 @@ pub struct RepoDetailView {
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,
/// Commit HEAD currently points to, shown in the header button.
head_commit: Option,
@@ -161,6 +178,8 @@ pub struct RepoDetailView {
}
impl RepoDetailView {
+ /// Open a repository announced on NIP-34: the store connects to the
+ /// announcement's relays and loads issues, PRs and statuses.
pub fn new(
dock_area: WeakEntity,
initial: Announcement,
@@ -170,7 +189,36 @@ impl RepoDetailView {
// The announcement we opened from already carries the repository's
// NIP-34 `relays` tag, so the store can connect to those relays
// immediately instead of waiting for the bootstrap fetch.
- let store = cx.new(|cx| RepoStore::new(initial.addr(), initial.relays.clone(), cx));
+ let addr = initial.addr();
+ let relays = initial.relays.clone();
+ let store = cx.new(|cx| RepoStore::new(addr, relays, cx));
+
+ Self::new_common(dock_area, Some(initial), Some(store), None, window, cx)
+ }
+
+ /// Open a local repository discovered by the scan. There is no
+ /// announcement and no nostr store until the user initializes
+ /// (publishes) it to NIP-34, so the header shows an Init button
+ /// instead of the NIP-34 actions.
+ pub fn new_local(
+ dock_area: WeakEntity,
+ local_path: PathBuf,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Self {
+ Self::new_common(dock_area, None, None, Some(local_path), window, cx)
+ }
+
+ /// Shared construction: file explorer state, ref selectors and the
+ /// deferred repository load.
+ fn new_common(
+ dock_area: WeakEntity,
+ initial: Option,
+ store: Option>,
+ local_path: Option,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Self {
let tree_state = cx.new(|cx| TreeState::new(cx));
// Empty until the clone completes; populated with the local refs.
@@ -222,6 +270,7 @@ impl RepoDetailView {
initial,
dock_area,
store,
+ local_path,
tree_state,
worktree: None,
md: None,
@@ -242,6 +291,7 @@ impl RepoDetailView {
item_sizes: Rc::new(Vec::new()),
loading: true,
cloning: false,
+ pushing: false,
error: None,
head_commit: None,
branch_select,
@@ -254,19 +304,49 @@ impl RepoDetailView {
}
}
- /// Load the repository and populate the file explorer. The local clone
- /// (if any) is loaded first without touching the network, so an
- /// unreachable server can't block the panel; a background fetch then
- /// refreshes the refs and commit list (a fetch never changes the
- /// checked-out files, so the tree and previews are left alone).
+ /// Load the repository and populate the file explorer. A local
+ /// (not yet published) repository is opened straight from disk. An
+ /// announced repository's local clone (if any) is loaded first without
+ /// touching the network, so an unreachable server can't block the
+ /// panel; a background fetch then refreshes the refs and commit list
+ /// (a fetch never changes the checked-out files, so the tree and
+ /// previews are left alone).
fn load_repo(&mut self, window: &mut Window, cx: &mut Context) {
self.loading = true;
self.error = None;
cx.notify();
+ // Local repositories live on disk at their scan path; there is no
+ // clone to ensure and no network refresh.
+ if let Some(local_path) = self.local_path.clone() {
+ let task = cx.spawn_in(window, async move |this, cx| {
+ let data = cx
+ .background_spawn(async move {
+ let repo = gix::open(&local_path)?;
+ load_repo_data(&repo)
+ })
+ .await;
+
+ this.update_in(cx, |this, window, cx| {
+ match data {
+ Ok(data) => this.apply_repo_data(data, window, cx),
+ Err(error) => this.error = Some(error.to_string().into()),
+ }
+ this.loading = false;
+ cx.notify();
+ })?;
+ Ok(())
+ });
+ self.tasks.push(task);
+ return;
+ }
+
+ let Some(initial) = self.initial.as_ref() else {
+ return;
+ };
let cache = GitStore::global(cx).cache().clone();
- let addr = self.initial.addr();
- let clone_urls: Vec = self.initial.clone.iter().map(ToString::to_string).collect();
+ let addr = initial.addr();
+ let clone_urls: Vec = initial.clone.iter().map(ToString::to_string).collect();
// Captured before the loads start: a branch/tag switch bumps it, and
// the refresh below is discarded when that happens.
let refresh_generation = self.ref_generation;
@@ -434,7 +514,9 @@ impl RepoDetailView {
}
let (clone_urls, name) = {
- let announcement = self.announcement(cx);
+ let Some(announcement) = self.announcement(cx) else {
+ return;
+ };
let addr = announcement.addr();
let clone_urls: Vec =
announcement.clone.iter().map(ToString::to_string).collect();
@@ -757,8 +839,65 @@ 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) {
+ if self.pushing {
+ return;
+ }
+ let Some(announcement) = self.announcement(cx).cloned() 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(())
+ }));
+ }
+
+ /// 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) {
+ let Some(announcement) = self.announcement(cx).cloned() 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(())
+ }));
+ }
+
/// Open the issues panel at the bottom of the dock area.
fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) {
+ let Some(store) = self.store.clone() else {
+ return;
+ };
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
@@ -766,7 +905,7 @@ impl RepoDetailView {
let panel = cx.new(|cx| {
IssuesView::new(
self.dock_area.clone(),
- self.store.clone(),
+ store,
self.display_name(cx),
window,
cx,
@@ -780,6 +919,9 @@ impl RepoDetailView {
/// Open the pull requests panel at the bottom of the dock area.
fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context) {
+ let Some(store) = self.store.clone() else {
+ return;
+ };
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
@@ -787,7 +929,7 @@ impl RepoDetailView {
let panel = cx.new(|cx| {
PullRequestsView::new(
self.dock_area.clone(),
- self.store.clone(),
+ store,
self.display_name(cx),
window,
cx,
@@ -1025,39 +1167,66 @@ impl RepoDetailView {
}
}
- /// The latest announcement from the store, or the open-time snapshot.
- fn announcement<'a>(&'a self, cx: &'a App) -> &'a Announcement {
- self.store
+ /// The latest announcement from the store, or the open-time snapshot;
+ /// `None` for local repositories that haven't been published yet.
+ fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> {
+ let store = self.store.as_ref()?;
+ store
.read(cx)
.announcement
.as_ref()
- .unwrap_or(&self.initial)
+ .or(self.initial.as_ref())
}
- /// Display name: the announcement's name, or the ID if no name is set.
+ /// Display name: the announcement's name (or ID) for announced
+ /// repositories, the directory name for local ones.
fn display_name(&self, cx: &App) -> SharedString {
- let announcement = self.announcement(cx);
- announcement
- .name
- .clone()
- .unwrap_or_else(|| SharedString::from(announcement.id.clone()))
+ if let Some(path) = &self.local_path {
+ return SharedString::from(
+ path.file_name()
+ .map(|name| name.to_string_lossy().into_owned())
+ .unwrap_or_else(|| path.display().to_string()),
+ );
+ }
+ self.announcement(cx)
+ .map(|announcement| {
+ announcement
+ .name
+ .clone()
+ .unwrap_or_else(|| SharedString::from(announcement.id.clone()))
+ })
+ .unwrap_or_default()
}
+ /// The NIP-34 header (actions, issues/PR counts) or, for a local
+ /// repository that hasn't been published yet, the local header with an
+ /// Init button.
fn render_header(&self, cx: &mut Context) -> AnyElement {
- let store = self.store.read(cx);
- let announcement = store.announcement.as_ref().unwrap_or(&self.initial);
+ if self.local_path.is_some() {
+ return self.render_local_header(cx);
+ }
+
+ let Some(store_entity) = self.store.as_ref() else {
+ return div().into_any_element();
+ };
+ let store = store_entity.read(cx);
+ let Some(announcement) = store
+ .announcement
+ .as_ref()
+ .or(self.initial.as_ref())
+ .cloned()
+ else {
+ return div().into_any_element();
+ };
let issue_count = SharedString::from(store.issue_count().to_string());
let pr_count = SharedString::from(store.pull_request_count().to_string());
let name = self.display_name(cx);
let description = announcement.description();
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
- let share = ShareTargets::from_announcement(announcement);
+ let share = ShareTargets::from_announcement(&announcement);
- let commits_count = self.all_commits.as_ref().map(|list| list.total);
- let worktree_empty = self.switching_ref || self.worktree.is_none();
-
- let nostr_url = nostr_clone_url(announcement, cx);
+ let nostr_url = nostr_clone_url(&announcement, cx);
let ngit_command = SharedString::from(format!("git clone {nostr_url}"));
let nak_command = SharedString::from(format!("nak git clone {nostr_url}"));
let git_commands = announcement.clone_urls();
@@ -1066,11 +1235,22 @@ impl RepoDetailView {
.on_action(
cx.listener(|this, action: &RepoAction, window, cx| match action {
RepoAction::NewIssue => {
- open_new_issue_dialog(this.store.clone(), window, cx);
+ if let Some(store) = this.store.clone() {
+ open_new_issue_dialog(store, window, cx);
+ }
}
RepoAction::NewPR => {
- open_new_pull_request_dialog(this.store.clone(), window, cx);
+ if let Some(store) = this.store.clone() {
+ open_new_pull_request_dialog(store, window, cx);
+ }
}
+ RepoAction::About => {
+ if let Some(announcement) = this.announcement(cx) {
+ open_about_dialog(announcement.clone(), window, cx);
+ }
+ }
+ RepoAction::Push => this.push_repository(window, cx),
+ RepoAction::Delete => this.delete_repository(window, cx),
}),
)
.px_4()
@@ -1160,11 +1340,13 @@ impl RepoDetailView {
})),
)
.dropdown_menu(|menu, _, _| {
- menu.menu_element_with_icon(
- IconName::Plus,
- Box::new(RepoAction::NewIssue),
- |_, _| div().text_xs().child("New issue"),
- )
+ menu.menu_element(Box::new(RepoAction::NewIssue), |_, _| {
+ h_flex()
+ .gap_2()
+ .text_sm()
+ .child(Icon::new(IconName::Plus))
+ .child("New issue")
+ })
}),
)
.child(
@@ -1201,11 +1383,13 @@ impl RepoDetailView {
})),
)
.dropdown_menu(|menu, _, _| {
- menu.menu_element_with_icon(
- IconName::Plus,
- Box::new(RepoAction::NewPR),
- |_, _| div().text_xs().child("New PR"),
- )
+ menu.menu_element(Box::new(RepoAction::NewPR), |_, _| {
+ h_flex()
+ .gap_2()
+ .text_sm()
+ .child(Icon::new(IconName::Plus))
+ .child("New PR")
+ })
}),
)
.child(
@@ -1227,17 +1411,50 @@ impl RepoDetailView {
.dropdown_menu(move |menu, _, _| share.menu(menu)),
)
.child(
- Button::new("info")
- .icon(IconName::Info)
- .tooltip("About")
+ Button::new("repo-menu-open")
+ .icon(IconName::EllipsisVertical)
+ .tooltip("Repository management")
+ .compact()
.secondary()
- .on_click(cx.listener(|this, _event, window, cx| {
- open_about_dialog(
- this.announcement(cx).clone(),
- window,
- cx,
+ .loading(self.pushing)
+ .disabled(self.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();
@@ -1342,157 +1559,261 @@ impl RepoDetailView {
}),
),
)
+ .child(self.render_header_tabs(cx))
+ .into_any_element()
+ }
+
+ /// Header for a local (not yet published) repository: the directory
+ /// name and path with an Init button instead of the NIP-34 actions
+ /// (issues, pull requests, share, info, clone).
+ fn render_local_header(&self, cx: &mut Context) -> AnyElement {
+ let name = self.display_name(cx);
+ let path = self
+ .local_path
+ .as_ref()
+ .map(|path| path.display().to_string())
+ .unwrap_or_default();
+ let avatar = PixelAvatar::new(path.clone());
+
+ v_flex()
+ .px_4()
+ .pb_4()
+ .w_full()
+ .gap_8()
+ .border_b_1()
+ .border_color(cx.theme().border)
.child(
h_flex()
+ .w_full()
+ .gap_4()
+ .items_start()
+ .justify_between()
+ .child(
+ v_flex()
+ .flex_1()
+ .min_w_0()
+ .gap_1()
+ .child(
+ h_flex()
+ .gap_2()
+ .min_h_8()
+ .font_semibold()
+ .child(avatar.size_6())
+ .child(name),
+ )
+ .child(
+ div()
+ .min_w_0()
+ .text_sm()
+ .text_color(cx.theme().muted_foreground)
+ .line_clamp(2)
+ .line_height(relative(1.25))
+ .text_ellipsis()
+ .child(path),
+ ),
+ )
+ .child(
+ Button::new("init")
+ .icon(CustomIconName::Init)
+ .label("Initialize on Nostr")
+ .primary()
+ .tooltip("Publish this repository to Nostr")
+ .on_click(cx.listener(|this, _event, window, cx| {
+ this.open_init_dialog(window, cx);
+ })),
+ ),
+ )
+ .child(self.render_header_tabs(cx))
+ .into_any_element()
+ }
+
+ /// Open the dialog guiding the user through publishing the local
+ /// repository to NIP-34.
+ fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context) {
+ let Some(local_path) = self.local_path.clone() else {
+ return;
+ };
+ let view = cx.entity().downgrade();
+ init_dialog::open(local_path, view, window, cx);
+ }
+
+ /// Switch the repository into its NIP-34 mode after a successful init:
+ /// create the nostr store for the announced repository and drop the
+ /// local (scan) identity. The worktree is unchanged, so the file
+ /// explorer keeps its loaded content.
+ pub(crate) fn apply_announcement(
+ &mut self,
+ announcement: Announcement,
+ cx: &mut Context,
+ ) {
+ // The repository is no longer a bare local repo: drop it from the
+ // scan results so it leaves the sidebar's local section immediately.
+ if let Some(path) = self.local_path.take() {
+ LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx));
+ }
+ let store =
+ cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx));
+ // Re-render when the store refreshes (issues, PRs, statuses).
+ self._subscriptions
+ .push(cx.observe(&store, |_this, _store, cx| cx.notify()));
+ self.store = Some(store);
+ self.initial = Some(announcement);
+ cx.notify();
+ }
+
+ /// The tab row shared by both header variants: Files/Commits tabs, the
+ /// HEAD commit button and the branch/tag selectors.
+ fn render_header_tabs(&self, cx: &mut Context) -> AnyElement {
+ let commits_count = self.all_commits.as_ref().map(|list| list.total);
+ let worktree_empty = self.switching_ref || self.worktree.is_none();
+
+ h_flex()
+ .items_center()
+ .gap_2()
+ .child(
+ BaseButton::new("files-tab")
+ .flex()
.items_center()
+ .h_8()
+ .px_2()
.gap_2()
.child(
- 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(
- h_flex()
- .justify_center()
- .px_1()
- .py_0p5()
- .min_w_4()
- .text_size(px(8.))
- .bg(cx.theme().muted)
- .text_color(cx.theme().muted_foreground)
- .rounded(cx.theme().radius)
- .line_height(relative(1.))
- .child(SharedString::from(count.to_string())),
- )
- })
- .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();
- })),
+ 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()
- .flex_1()
- .gap_2()
- .justify_end()
- .child(
- Button::new("enc")
- .ghost()
- .when_some(self.head_commit.as_ref(), |this, commit| {
- this.child(
- div()
- .text_xs()
- .text_color(cx.theme().muted_foreground)
- .child(SharedString::from(&commit.id)),
- )
- .child(
- div()
- .max_w(px(200.))
- .overflow_hidden()
- .text_ellipsis()
- .whitespace_nowrap()
- .text_xs()
- .child(SharedString::from(&commit.summary)),
- )
- })
- .tooltip(
- self.head_commit
- .as_ref()
- .map_or_else(SharedString::default, |commit| {
- commit.summary.clone().into()
- }),
- )
- .on_click(cx.listener(|this, _event, window, cx| {
- if let Some(commit) = &this.head_commit {
- let id = commit.id.clone();
- this.open_commit_diff(&id, window, cx);
- }
- })),
+ .gap_1()
+ .text_sm()
+ .child(Icon::new(CustomIconName::GitCommit).small())
+ .child("Commits"),
+ )
+ .when_some(commits_count, |this, count| {
+ this.child(
+ h_flex()
+ .justify_center()
+ .px_1()
+ .py_0p5()
+ .min_w_4()
+ .text_size(px(8.))
+ .bg(cx.theme().muted)
+ .text_color(cx.theme().muted_foreground)
+ .rounded(cx.theme().radius)
+ .line_height(relative(1.))
+ .child(SharedString::from(count.to_string())),
+ )
+ })
+ .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()
+ }),
)
- .child(
- div().w(px(120.)).child(
- Combobox::new(&self.branch_select)
- .placeholder("Branch")
- .appearance(false)
- .menu_width(px(200.))
- .disabled(worktree_empty)
- .bg(cx.theme().muted)
- .rounded(cx.theme().radius)
- .render_trigger(|ctx, _window, cx| {
- Self::render_ref_trigger(
- ctx,
- CustomIconName::GitBranch,
- cx,
- )
- }),
- ),
- )
- .child(
- div().w(px(120.)).child(
- Combobox::new(&self.tag_select)
- .placeholder("Tag")
- .appearance(false)
- .menu_width(px(200.))
- .disabled(worktree_empty)
- .bg(cx.theme().muted)
- .rounded(cx.theme().radius)
- .render_trigger(|ctx, _window, cx| {
- Self::render_ref_trigger(ctx, CustomIconName::Tag, cx)
- }),
- ),
- ),
+ .on_click(cx.listener(|this, _event, window, cx| {
+ if let Some(commit) = &this.head_commit {
+ let id = commit.id.clone();
+ this.open_commit_diff(&id, window, cx);
+ }
+ })),
+ )
+ .child(
+ div().w(px(120.)).child(
+ Combobox::new(&self.branch_select)
+ .placeholder("Branch")
+ .appearance(false)
+ .menu_width(px(200.))
+ .disabled(worktree_empty)
+ .bg(cx.theme().muted)
+ .rounded(cx.theme().radius)
+ .render_trigger(|ctx, _window, cx| {
+ Self::render_ref_trigger(ctx, CustomIconName::GitBranch, cx)
+ }),
+ ),
+ )
+ .child(
+ div().w(px(120.)).child(
+ Combobox::new(&self.tag_select)
+ .placeholder("Tag")
+ .appearance(false)
+ .menu_width(px(200.))
+ .disabled(worktree_empty)
+ .bg(cx.theme().muted)
+ .rounded(cx.theme().radius)
+ .render_trigger(|ctx, _window, cx| {
+ Self::render_ref_trigger(ctx, CustomIconName::Tag, cx)
+ }),
+ ),
),
)
.into_any_element()
}
fn render_maintainers(&self, cx: &mut Context) -> AnyElement {
- let announcement = self.announcement(cx);
+ let Some(announcement) = self.announcement(cx) else {
+ return div().into_any_element();
+ };
let profile_store = ProfileStore::global(cx);
let mut seen = HashSet::new();
@@ -1576,6 +1897,16 @@ impl Render for RepoDetailView {
.id("repo")
.size_full()
.child(self.render_header(cx))
+ .when_some(self.error.clone(), |this, error| {
+ this.child(
+ Alert::error("repo-error", error)
+ .banner()
+ .on_close(cx.listener(|this, _event, _window, cx| {
+ this.error = None;
+ cx.notify();
+ })),
+ )
+ })
.map(|this| match self.active_tab {
0 => this.child(
h_flex()
diff --git a/crates/workspace/src/views/sidebar/create_repo_dialog.rs b/crates/workspace/src/views/sidebar/create_repo_dialog.rs
index 001c688..7b0c7c0 100644
--- a/crates/workspace/src/views/sidebar/create_repo_dialog.rs
+++ b/crates/workspace/src/views/sidebar/create_repo_dialog.rs
@@ -2,56 +2,29 @@ use dock::{DockArea, DockPlacement, panel_handle};
use gpui::prelude::*;
use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px};
use gpui_base::input::TextareaState;
-use gpui_component::button::{Button, ButtonVariants, Toggle, ToggleVariants};
+use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputState, Textarea};
-use gpui_component::{ActiveTheme, Disableable, IconName, Sizable, WindowExt, h_flex, v_flex};
-use nostr::prelude::*;
-use signed_core::{Announcement, filters};
+use gpui_component::{ActiveTheme, Disableable, IconName, WindowExt, h_flex};
+use signed_core::Announcement;
use signed_state::Backend;
use super::super::RepoDetailView;
-
-/// Grasp servers offered when the user hasn't published a grasp list (kind `10317`) yet.
-const DEFAULT_GRASP_SERVERS: [&str; 3] = [
- "wss://relay.ngit.dev",
- "wss://gitnostr.com",
- "wss://git.shakespeare.diy",
-];
+use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
/// Shared state for the Create Repository dialog, so async results can be rendered.
#[derive(Default)]
pub struct CreateRepoState {
pub busy: bool,
- /// The user's grasp list (kind `10317`) is being loaded.
- pub loading_servers: bool,
pub error: Option,
- pub grasp_servers: Vec,
- /// Whether the grasp server section is shown; defaults to shown.
- pub servers_enabled: bool,
-}
-
-impl CreateRepoState {
- /// Defaults until the user's grasp list arrives; replaced by it when it lists any servers.
- fn new_default() -> Self {
- Self {
- loading_servers: true,
- servers_enabled: false,
- grasp_servers: DEFAULT_GRASP_SERVERS
- .iter()
- .filter_map(|url| RelayUrl::parse(url).ok())
- .collect(),
- ..Default::default()
- }
- }
}
/// Open the Create Repository dialog.
///
/// The dialog loads the user's default grasp servers (kind `10317` grasp
-/// list) and falls back to [`DEFAULT_GRASP_SERVERS`] when none are set.
-/// On success the dialog closes and the new repository opens in the dock.
+/// list) and falls back to the shared defaults when none are set. On
+/// success the dialog closes and the new repository opens in the dock.
pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) {
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Repository name"));
let desc_input = cx.new(|cx| {
@@ -66,21 +39,21 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App)
let relay_input = cx.new(|cx| {
InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com")
});
- let state = cx.new(|_| CreateRepoState::new_default());
+ let state = cx.new(|_| CreateRepoState::default());
+ let grasp_state = cx.new(|_| GraspServersState::new_default());
- load_user_grasp_servers(state.clone(), window, cx);
+ load_user_grasp_servers(grasp_state.clone(), window, cx);
window.open_dialog(cx, move |dialog, _window, _cx| {
const DESC: &str = "Publish a new repository to your grasp servers.";
const FOLDER_NOTE: &str = "Where the repository is stored, defaults to your Desktop";
- const SERVER_NOTE: &str =
- "Where the repository is hosted, the initial push goes to each server";
let name_input = name_input.clone();
let desc_input = desc_input.clone();
let folder_input = folder_input.clone();
let relay_input = relay_input.clone();
let state = state.clone();
+ let grasp_state = grasp_state.clone();
let dock_area = dock_area.clone();
dialog
@@ -89,9 +62,6 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App)
.content(move |content, _window, cx| {
let busy = state.read(cx).busy;
let error = state.read(cx).error.clone();
- let servers = state.read(cx).grasp_servers.clone();
- let loading_servers = state.read(cx).loading_servers;
- let servers_enabled = state.read(cx).servers_enabled;
content
.child(
@@ -137,89 +107,7 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App)
),
),
)
- .child(
- field()
- .label_fn({
- let state = state.clone();
- move |_window, cx| {
- let enabled = state.read(cx).servers_enabled;
- h_flex()
- .w_full()
- .justify_between()
- .items_center()
- .gap_1()
- .child(
- Toggle::new("grasp-servers-toggle")
- .xsmall()
- .ghost()
- .icon({
- if enabled {
- IconName::ChevronDown
- } else {
- IconName::ChevronUp
- }
- })
- .checked(enabled)
- .on_click({
- let state = state.clone();
- move |checked, _window, cx| {
- state.update(cx, |state, cx| {
- state.servers_enabled =
- *checked;
- cx.notify();
- });
- }
- }),
- )
- .child(div().child("Grasp servers"))
- }
- })
- .when(servers_enabled, |this| this.description(SERVER_NOTE))
- .child(v_flex().gap_1().when(servers_enabled, |this| {
- this.children(servers.iter().enumerate().map(
- |(ix, relay)| {
- render_server_row(ix, relay, state.clone(), cx)
- },
- ))
- .child(
- h_flex()
- .gap_1()
- .items_center()
- .child(
- div().flex_1().child(Input::new(&relay_input)),
- )
- .child(
- Button::new("add-relay")
- .icon(IconName::Plus)
- .ghost()
- .tooltip("Add grasp server")
- .on_click({
- let state = state.clone();
- let relay_input = relay_input.clone();
- move |_ev, window, cx| {
- add_relay(
- &state,
- &relay_input,
- window,
- cx,
- );
- }
- }),
- ),
- )
- .when(
- loading_servers,
- |this| {
- this.child(
- div()
- .text_xs()
- .text_color(cx.theme().muted_foreground)
- .child("Loading your grasp servers..."),
- )
- },
- )
- })),
- ),
+ .child(grasp_servers_field(&grasp_state, &relay_input, cx)),
)
.children(error.map(|message| {
div().text_sm().text_color(cx.theme().danger).child(message)
@@ -237,6 +125,7 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App)
let name_input = name_input.clone();
let desc_input = desc_input.clone();
let state = state.clone();
+ let grasp_state = grasp_state.clone();
let dock_area = dock_area.clone();
move |_ev, window, cx| {
@@ -244,6 +133,7 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App)
name_input.clone(),
desc_input.clone(),
state.clone(),
+ grasp_state.clone(),
dock_area.clone(),
window,
cx,
@@ -256,53 +146,6 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App)
});
}
-/// A grasp server row: the host as a tag plus a remove button.
-fn render_server_row(
- ix: usize,
- relay: &RelayUrl,
- state: Entity,
- cx: &App,
-) -> impl IntoElement {
- h_flex()
- .w_full()
- .gap_1()
- .items_center()
- .child(
- h_flex()
- .h_8()
- .w_full()
- .px_2()
- .bg(cx.theme().muted)
- .text_color(cx.theme().muted_foreground)
- .text_sm()
- .rounded(cx.theme().radius)
- .child(display_server(relay)),
- )
- .child(
- Button::new(format!("remove-relay:{ix}"))
- .icon(IconName::Close)
- .ghost()
- .flex_shrink_0()
- .tooltip("Remove")
- .on_click({
- let state = state.clone();
- move |_ev, _window, cx| {
- state.update(cx, |state, _| {
- state.grasp_servers.remove(ix);
- });
- }
- }),
- )
-}
-
-/// The bare host of a grasp server (defaults are entered without a scheme).
-fn display_server(relay: &RelayUrl) -> SharedString {
- relay
- .domain()
- .map(SharedString::from)
- .unwrap_or_else(|| SharedString::from(relay.to_string()))
-}
-
/// Prompt the user to pick the folder the repository will be stored in, using
/// the platform's native folder picker, and show the result in the disabled
/// folder input.
@@ -331,54 +174,19 @@ fn choose_folder(folder_input: &Entity, window: &mut Window, cx: &mu
.detach();
}
-/// Parse the relay input (accepting a bare host) and append it to the list.
-fn add_relay(
- state: &Entity,
- input: &Entity,
- window: &mut Window,
- cx: &mut App,
-) {
- let value = input.read(cx).value().trim().to_owned();
- if value.is_empty() {
- return;
- }
-
- let normalized = if value.contains("://") {
- value.clone()
- } else {
- format!("wss://{value}")
- };
-
- match RelayUrl::parse(&normalized) {
- Ok(relay) => {
- state.update(cx, |state, _| {
- state.error = None;
- if !state.grasp_servers.contains(&relay) {
- state.grasp_servers.push(relay);
- }
- });
- input.update(cx, |input, cx| input.set_value("", window, cx));
- }
- Err(_) => {
- state.update(cx, |state, _| {
- state.error = Some(format!("Invalid grasp server URL: {value}").into());
- });
- }
- }
-}
-
/// Run the create-repository flow; closes the dialog and opens the new repository on success.
fn create_repository(
name_input: Entity,
desc_input: Entity,
state: Entity,
+ grasp_state: Entity,
dock_area: WeakEntity,
window: &mut Window,
cx: &mut App,
) {
let name = name_input.read(cx).value().trim().to_owned();
let description = desc_input.read(cx).value().trim().to_owned();
- let servers = state.read(cx).grasp_servers.clone();
+ let servers = grasp_state.read(cx).grasp_servers.clone();
if name.is_empty() {
state.update(cx, |state, _| {
@@ -444,54 +252,3 @@ fn open_repo(
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
});
}
-
-/// Load the user's grasp list (kind `10317`) from the local database and
-/// replace the defaults with it when it lists any servers.
-fn load_user_grasp_servers(state: Entity, window: &mut Window, cx: &mut App) {
- let backend = Backend::global(cx);
- let Some(user) = backend.read(cx).current_user() else {
- state.update(cx, |state, _| state.loading_servers = false);
- return;
- };
- let client = backend.read(cx).client();
- let handle = window.window_handle();
-
- cx.spawn(async move |cx| {
- let result: anyhow::Result> = async {
- let mut events: Vec = client
- .database()
- .query(filters::grasp_list(user))
- .await?
- .into_iter()
- .collect();
- events.sort_by_key(|event| event.created_at);
-
- Ok(events
- .into_iter()
- .last()
- .map(|event| {
- event
- .tags
- .iter()
- .filter(|tag| tag.kind() == "g")
- .filter_map(|tag| tag.content())
- .filter_map(|url| RelayUrl::parse(url).ok())
- .collect()
- })
- .unwrap_or_default())
- }
- .await;
-
- let _ = cx.update_window(handle, |_, _window, cx| {
- state.update(cx, |state, _| {
- state.loading_servers = false;
- if let Ok(servers) = result
- && !servers.is_empty()
- {
- state.grasp_servers = servers;
- }
- });
- });
- })
- .detach();
-}
diff --git a/crates/workspace/src/views/sidebar/grasp_servers.rs b/crates/workspace/src/views/sidebar/grasp_servers.rs
new file mode 100644
index 0000000..25a9d0a
--- /dev/null
+++ b/crates/workspace/src/views/sidebar/grasp_servers.rs
@@ -0,0 +1,278 @@
+use gpui::prelude::*;
+use gpui::{App, Entity, SharedString, Window, div};
+use gpui_component::button::{Button, ButtonVariants, Toggle, ToggleVariants};
+use gpui_component::form::{Field, field};
+use gpui_component::input::{Input, InputState};
+use gpui_component::{ActiveTheme, IconName, Sizable, h_flex, v_flex};
+use nostr::prelude::*;
+use signed_core::filters;
+use signed_state::Backend;
+
+/// Grasp servers offered when the user hasn't published a grasp list (kind `10317`) yet.
+const DEFAULT_GRASP_SERVERS: [&str; 3] = [
+ "wss://relay.ngit.dev",
+ "wss://gitnostr.com",
+ "wss://git.shakespeare.diy",
+];
+
+/// State of the grasp-server section of a publish dialog, so async
+/// results can be rendered.
+#[derive(Default)]
+pub struct GraspServersState {
+ /// The user's grasp list (kind `10317`) is being loaded.
+ pub loading_servers: bool,
+ pub grasp_servers: Vec,
+ /// Whether the grasp server section is shown; defaults to shown.
+ pub servers_enabled: bool,
+ /// Error of the last grasp-server edit (e.g. an invalid relay URL).
+ pub error: Option,
+}
+
+impl GraspServersState {
+ /// Defaults until the user's grasp list arrives; replaced by it when it lists any servers.
+ pub fn new_default() -> Self {
+ Self {
+ loading_servers: true,
+ servers_enabled: false,
+ grasp_servers: DEFAULT_GRASP_SERVERS
+ .iter()
+ .filter_map(|url| RelayUrl::parse(url).ok())
+ .collect(),
+ ..Default::default()
+ }
+ }
+}
+
+/// The "Grasp servers" form field shared by the publish dialogs: an
+/// expandable toggle, the configured servers (each removable) and an
+/// add-relay input, with a loading hint while the user's grasp list
+/// (kind `10317`) is being fetched.
+pub fn grasp_servers_field(
+ state: &Entity,
+ relay_input: &Entity,
+ cx: &App,
+) -> Field {
+ const SERVER_NOTE: &str =
+ "Where the repository is hosted, the initial push goes to each server";
+
+ let state = state.clone();
+ let relay_input = relay_input.clone();
+
+ let servers = state.read(cx).grasp_servers.clone();
+ let loading_servers = state.read(cx).loading_servers;
+ let servers_enabled = state.read(cx).servers_enabled;
+ let error = state.read(cx).error.clone();
+
+ field()
+ .label_fn({
+ let state = state.clone();
+ move |_window, cx| {
+ let enabled = state.read(cx).servers_enabled;
+ h_flex()
+ .w_full()
+ .justify_between()
+ .items_center()
+ .gap_1()
+ .child(
+ Toggle::new("grasp-servers-toggle")
+ .xsmall()
+ .ghost()
+ .icon({
+ if enabled {
+ IconName::ChevronDown
+ } else {
+ IconName::ChevronUp
+ }
+ })
+ .checked(enabled)
+ .on_click({
+ let state = state.clone();
+ move |checked, _window, cx| {
+ state.update(cx, |state, cx| {
+ state.servers_enabled = *checked;
+ cx.notify();
+ });
+ }
+ }),
+ )
+ .child(div().child("Grasp servers"))
+ }
+ })
+ .when(servers_enabled, |this| this.description(SERVER_NOTE))
+ .child(v_flex().gap_1().when(servers_enabled, |this| {
+ this.children(
+ servers
+ .iter()
+ .enumerate()
+ .map(|(ix, relay)| render_server_row(ix, relay, state.clone(), cx)),
+ )
+ .child(
+ h_flex()
+ .gap_1()
+ .items_center()
+ .child(div().flex_1().child(Input::new(&relay_input)))
+ .child(
+ Button::new("add-relay")
+ .icon(IconName::Plus)
+ .ghost()
+ .tooltip("Add grasp server")
+ .on_click({
+ let state = state.clone();
+ let relay_input = relay_input.clone();
+ move |_ev, window, cx| {
+ add_relay(&state, &relay_input, window, cx);
+ }
+ }),
+ ),
+ )
+ .when(loading_servers, |this| {
+ this.child(
+ div()
+ .text_xs()
+ .text_color(cx.theme().muted_foreground)
+ .child("Loading your grasp servers..."),
+ )
+ })
+ .when_some(error, |this, error| {
+ this.child(div().text_xs().text_color(cx.theme().danger).child(error))
+ })
+ }))
+}
+
+/// A grasp server row: the host as a tag plus a remove button.
+fn render_server_row(
+ ix: usize,
+ relay: &RelayUrl,
+ state: Entity,
+ cx: &App,
+) -> impl IntoElement {
+ h_flex()
+ .w_full()
+ .gap_1()
+ .items_center()
+ .child(
+ h_flex()
+ .h_8()
+ .w_full()
+ .px_2()
+ .bg(cx.theme().muted)
+ .text_color(cx.theme().muted_foreground)
+ .text_sm()
+ .rounded(cx.theme().radius)
+ .child(display_server(relay)),
+ )
+ .child(
+ Button::new(format!("remove-relay:{ix}"))
+ .icon(IconName::Close)
+ .ghost()
+ .flex_shrink_0()
+ .tooltip("Remove")
+ .on_click({
+ let state = state.clone();
+ move |_ev, _window, cx| {
+ state.update(cx, |state, _| {
+ state.grasp_servers.remove(ix);
+ });
+ }
+ }),
+ )
+}
+
+/// The bare host of a grasp server (defaults are entered without a scheme).
+fn display_server(relay: &RelayUrl) -> SharedString {
+ relay
+ .domain()
+ .map(SharedString::from)
+ .unwrap_or_else(|| SharedString::from(relay.to_string()))
+}
+
+/// Parse the relay input (accepting a bare host) and append it to the list.
+fn add_relay(
+ state: &Entity,
+ input: &Entity,
+ window: &mut Window,
+ cx: &mut App,
+) {
+ let value = input.read(cx).value().trim().to_owned();
+ if value.is_empty() {
+ return;
+ }
+
+ let normalized = if value.contains("://") {
+ value.clone()
+ } else {
+ format!("wss://{value}")
+ };
+
+ match RelayUrl::parse(&normalized) {
+ Ok(relay) => {
+ state.update(cx, |state, _| {
+ state.error = None;
+ if !state.grasp_servers.contains(&relay) {
+ state.grasp_servers.push(relay);
+ }
+ });
+ input.update(cx, |input, cx| input.set_value("", window, cx));
+ }
+ Err(_) => {
+ state.update(cx, |state, _| {
+ state.error = Some(format!("Invalid grasp server URL: {value}").into());
+ });
+ }
+ }
+}
+
+/// Load the user's grasp list (kind `10317`) from the local database and
+/// replace the defaults with it when it lists any servers.
+pub fn load_user_grasp_servers(
+ state: Entity,
+ window: &mut Window,
+ cx: &mut App,
+) {
+ let backend = Backend::global(cx);
+ let Some(user) = backend.read(cx).current_user() else {
+ state.update(cx, |state, _| state.loading_servers = false);
+ return;
+ };
+ let client = backend.read(cx).client();
+ let handle = window.window_handle();
+
+ cx.spawn(async move |cx| {
+ let result: anyhow::Result> = async {
+ let mut events: Vec = client
+ .database()
+ .query(filters::grasp_list(user))
+ .await?
+ .into_iter()
+ .collect();
+ events.sort_by_key(|event| event.created_at);
+
+ Ok(events
+ .into_iter()
+ .last()
+ .map(|event| {
+ event
+ .tags
+ .iter()
+ .filter(|tag| tag.kind() == "g")
+ .filter_map(|tag| tag.content())
+ .filter_map(|url| RelayUrl::parse(url).ok())
+ .collect()
+ })
+ .unwrap_or_default())
+ }
+ .await;
+
+ let _ = cx.update_window(handle, |_, _window, cx| {
+ state.update(cx, |state, _| {
+ state.loading_servers = false;
+ if let Ok(servers) = result
+ && !servers.is_empty()
+ {
+ state.grasp_servers = servers;
+ }
+ });
+ });
+ })
+ .detach();
+}
diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs
index dad9bac..98886a5 100644
--- a/crates/workspace/src/views/sidebar/mod.rs
+++ b/crates/workspace/src/views/sidebar/mod.rs
@@ -1,4 +1,6 @@
+use std::collections::HashSet;
use std::ops::Range;
+use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use assets::CustomIconName;
@@ -17,14 +19,15 @@ use gpui_component::avatar::Avatar;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::InputState;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
-use signed_core::Announcement;
-use signed_state::{Backend, BackendEvent, Profile, ProfileStore, RepoListStore};
+use signed_core::{Announcement, identifier_from_name};
+use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore, RepoListStore};
use super::{RepoDetailView, RepoListView};
use crate::image_cache::{MAX_IMAGES, image_cache};
use crate::pixel_avatar::PixelAvatar;
mod create_repo_dialog;
+pub(crate) mod grasp_servers;
mod import_dialog;
mod onboarding_dialog;
pub(crate) mod passphrase_dialog;
@@ -37,20 +40,23 @@ pub struct SidebarPanel {
focus_handle: FocusHandle,
dock_area: WeakEntity,
explore: Option>,
+ logged_in: bool,
/// Repositories announced by the current user, listed under
/// "All Repositories". Recreated when the signer changes.
my_repos: Option>,
/// Observes the current user's repo store so the list re-renders.
my_repos_subscription: Option,
- logged_in: bool,
/// Banner artwork shown behind the sign-in screen,
/// picked at random from the bundled `backgrounds/` assets.
banner: SharedString,
+ /// Observes the local-repository scan so new discoveries re-render.
+ _local_repos_subscription: Subscription,
_subscription: Subscription,
}
impl SidebarPanel {
pub fn new(dock_area: WeakEntity, cx: &mut Context) -> Self {
+ let local_repos_store = LocalReposStore::global(cx);
let backend = Backend::global(cx);
let logged_in = backend.read(cx).current_user().is_some();
@@ -71,14 +77,19 @@ impl SidebarPanel {
cx.notify();
});
+ let local_repos_subscription = cx.observe(&local_repos_store, |_, _, cx| {
+ cx.notify();
+ });
+
let mut panel = Self {
focus_handle: cx.focus_handle(),
dock_area,
+ logged_in,
explore: None,
my_repos: None,
my_repos_subscription: None,
- logged_in,
banner: pick_banner(),
+ _local_repos_subscription: local_repos_subscription,
_subscription: subscription,
};
@@ -166,11 +177,32 @@ impl SidebarPanel {
});
}
+ /// Open a local repository's detail view in the dock's center; the
+ /// detail view offers to publish it to NIP-34.
+ fn open_local_repo(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context) {
+ let detail =
+ cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, window, cx));
+
+ let _ = self.dock_area.update(cx, |dock_area, cx| {
+ dock_area.add_panel_view(
+ panel_handle(detail),
+ DockPlacement::Center,
+ None,
+ window,
+ cx,
+ );
+ });
+ }
+
/// The "All Repositories" section: header with the create button and
/// the current user's repositories below it, lazily rendered through a
- /// [`uniform_list`].
+ /// [`uniform_list`], followed by the local git repositories discovered
+ /// by the startup scan.
fn render_my_repos(&self, cx: &mut Context) -> impl IntoElement {
let store = self.my_repos.as_ref();
+ let local = LocalReposStore::global(cx);
+ let local_repos = local.read(cx).repos.clone();
+ let scanning = local.read(cx).scanning;
v_flex()
.px_2()
@@ -193,19 +225,54 @@ impl SidebarPanel {
.child(div().text_xs().font_semibold().child("All Repositories")),
)
.child(
- Button::new("add")
- .icon(IconName::Plus)
- .small()
- .ghost()
- .on_click(cx.listener(|this, _ev, window, cx| {
- this.open_create_repo(window, cx);
- })),
+ h_flex()
+ .gap_1()
+ .child(
+ Button::new("rescan")
+ .icon(CustomIconName::Refresh)
+ .small()
+ .ghost()
+ .tooltip("Rescan for local repositories")
+ .on_click(cx.listener(|_this, _ev, _window, cx| {
+ let local_repos = LocalReposStore::global(cx);
+ local_repos.update(cx, |store, cx| store.rescan(cx));
+ })),
+ )
+ .child(
+ Button::new("add")
+ .icon(IconName::Plus)
+ .small()
+ .ghost()
+ .on_click(cx.listener(|this, _ev, window, cx| {
+ this.open_create_repo(window, cx);
+ })),
+ ),
),
)
.when_some(store, |builder, store| {
let announcements = store.read(cx).announcements.clone();
+ // Local repositories that have already been published to
+ // NIP-34 are listed among the user's repositories above;
+ // hide them from the local section (matched by the
+ // identifier derived from the directory name, like the
+ // init dialog's default name).
+ let announced_ids: HashSet =
+ announcements.iter().map(|a| a.id.clone()).collect();
+ let local_repos: Vec = local_repos
+ .iter()
+ .filter(|path| {
+ let Some(name) = path.file_name() else {
+ return true;
+ };
+ !announced_ids.contains(&identifier_from_name(&name.to_string_lossy()))
+ })
+ .cloned()
+ .collect();
+ // One merged list: the user's NIP-34 repositories first,
+ // then the local repositories discovered by the scan.
+ let total = announcements.len() + local_repos.len();
- if announcements.is_empty() {
+ if total == 0 {
builder.child(
div()
.flex_1()
@@ -213,18 +280,27 @@ impl SidebarPanel {
.py_1()
.text_xs()
.text_color(cx.theme().muted_foreground)
- .child("No repositories yet"),
+ .child(if scanning {
+ "Scanning for local repositories…"
+ } else {
+ "No repositories yet"
+ }),
)
} else {
builder.child(
uniform_list(
- "my-repos-list",
- announcements.len(),
+ "repos",
+ total,
cx.processor(move |this, range: Range, _window, cx| {
range
.map(|ix| {
- this.render_repo_row(&announcements[ix], cx)
- .into_any_element()
+ this.render_repo_row_at(
+ &announcements,
+ &local_repos,
+ ix,
+ cx,
+ )
+ .into_any_element()
})
.collect()
}),
@@ -236,8 +312,27 @@ impl SidebarPanel {
})
}
- /// One repository row in the sidebar, styled like the nav items: a
- /// deterministic pixel avatar and the repo name.
+ /// One row of the merged sidebar list: a NIP-34 repository or a local
+ /// repository.
+ fn render_repo_row_at(
+ &self,
+ announcements: &[Announcement],
+ local_repos: &[PathBuf],
+ ix: usize,
+ cx: &mut Context,
+ ) -> AnyElement {
+ if ix < announcements.len() {
+ return self
+ .render_repo_row(&announcements[ix], cx)
+ .into_any_element();
+ }
+
+ let local_ix = ix - announcements.len();
+ let path = &local_repos[local_ix];
+
+ self.render_local_row(path, cx).into_any_element()
+ }
+
fn render_repo_row(
&self,
announcement: &Announcement,
@@ -255,6 +350,32 @@ impl SidebarPanel {
)
}
+ /// One local repository row: a deterministic pixel avatar seeded from
+ /// the path, the directory name, and a warning suffix marking it as
+ /// not yet set up for NIP-34. Clicking it opens the repository's
+ /// detail view, which offers to initialize it.
+ fn render_local_row(&self, path: &Path, cx: &mut Context) -> impl IntoElement {
+ let name = path
+ .file_name()
+ .map(|name| name.to_string_lossy().into_owned())
+ .unwrap_or_else(|| path.display().to_string());
+ let path = path.to_path_buf();
+
+ NavItem::new(
+ format!("local-repo:{}", path.display()),
+ name,
+ PixelAvatar::new(path.to_string_lossy()),
+ )
+ .suffix(
+ Icon::new(IconName::TriangleAlert)
+ .small()
+ .text_color(cx.theme().warning),
+ )
+ .on_click(cx.listener(move |this, _ev, window, cx| {
+ this.open_local_repo(path.clone(), window, cx);
+ }))
+ }
+
/// Show the Import Identity dialog.
fn open_import(&mut self, window: &mut Window, cx: &mut Context) {
import_dialog::open(window, cx);
@@ -491,8 +612,8 @@ impl Render for SidebarPanel {
}
/// A single navigation entry in the sidebar: an arbitrary leading element
-/// (an icon, avatar, ...) and a text label with a hover highlight and an
-/// optional click handler.
+/// (an icon, avatar, ...) and a text label with a hover highlight,
+/// an optional trailing suffix (e.g. a status icon) and an optional click handler.
#[allow(clippy::type_complexity)]
#[derive(IntoElement)]
struct NavItem {
@@ -500,6 +621,8 @@ struct NavItem {
style: StyleRefinement,
icon: AnyElement,
label: SharedString,
+ /// Trailing element rendered at the right edge of the row, after the (ellipsized) label.
+ suffix: Option,
on_click: Option>,
}
@@ -515,10 +638,17 @@ impl NavItem {
icon: icon.into_any_element(),
label: label.into(),
style: StyleRefinement::default(),
+ suffix: None,
on_click: None,
}
}
+ /// A trailing element rendered at the right edge of the row
+ fn suffix(mut self, suffix: impl IntoElement) -> Self {
+ self.suffix = Some(suffix.into_any_element());
+ self
+ }
+
fn on_click(mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Box::new(listener));
self
@@ -545,6 +675,9 @@ impl RenderOnce for NavItem {
.text_ellipsis()
.child(self.label),
)
+ .when_some(self.suffix, |this, suffix| {
+ this.child(div().flex_shrink_0().child(suffix))
+ })
.hover(|this| this.bg(cx.theme().list_hover))
.when_some(self.on_click, |this, listener| this.on_click(listener))
}
diff --git a/docs/TODO.md b/docs/TODO.md
index fa6fc2e..b5bfff4 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -1,7 +1,9 @@
# TODO
+## Local repository scan
+
+- [ ] Allow the user to configure which directories are scanned for local git repositories (currently fixed to the Desktop and Documents folders).
+
## Create repository dialog
-- [ ] Persist the user's preferred local repository folder (the one picked in
- the create-repository dialog, defaulting to Desktop) and use it as the
- default next time the dialog opens.
+- [ ] Persist the user's preferred local repository folder (the one picked in the create-repository dialog, defaulting to Desktop) and use it as the default next time the dialog opens.