feat: add local repository scanning #8

Merged
reya merged 5 commits from feat/scan-repo into master 2026-08-31 08:12:40 +00:00
22 changed files with 2029 additions and 572 deletions
Generated
+1
View File
@@ -7924,6 +7924,7 @@ dependencies = [
"nostr", "nostr",
"nostr-connect", "nostr-connect",
"nostr-sdk", "nostr-sdk",
"paths",
"rustls", "rustls",
"signed_core", "signed_core",
"signed_git", "signed_git",
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M18.5 19.0615C20.6627 18.4544 22.25 16.4502 22.25 14.0714C22.25 11.2114 19.9555 8.89286 17.125 8.89286C16.5661 8.89286 16.0281 8.98326 15.5245 9.15037C14.4289 6.56294 11.8865 4.75 8.925 4.75C4.96236 4.75 1.75 7.99594 1.75 12C1.75 14.7508 3.26609 17.1437 5.5 18.3722M14.5 16.25L12 13.75L9.5 16.25M12 20V14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 488 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M13 21C13.5523 21 14 20.5523 14 20C14 19.4477 13.5523 19 13 19C12.4477 19 12 19.4477 12 20C12 20.5523 12.4477 21 13 21Z" fill="currentColor"/><path d="M21 11C21 10.4477 20.5523 9.99999 20 9.99999C19.4477 9.99999 19 10.4477 19 11C19 11.5523 19.4477 12 20 12C20.5523 12 21 11.5523 21 11Z" fill="currentColor"/><path d="M19.9295 14.2679C20.4078 14.5441 20.5716 15.1557 20.2955 15.634C20.0193 16.1123 19.4078 16.2761 18.9295 16C18.4512 15.7238 18.2873 15.1123 18.5634 14.634C18.8396 14.1557 19.4512 13.9918 19.9295 14.2679Z" fill="currentColor"/><path d="M17.3676 19.2942C17.8459 19.0181 18.0098 18.4065 17.7336 17.9282C17.4575 17.4499 16.8459 17.286 16.3676 17.5621C15.8893 17.8383 15.7254 18.4499 16.0016 18.9282C16.2777 19.4065 16.8893 19.5703 17.3676 19.2942Z" fill="currentColor"/><path d="M18.9269 7.99998C18.4487 8.27612 17.8371 8.11225 17.5609 7.63396C17.2848 7.15566 17.4487 6.54407 17.9269 6.26793C18.4052 5.99179 19.0168 6.15566 19.293 6.63396C19.5691 7.11225 19.4052 7.72384 18.9269 7.99998Z" fill="currentColor"/><path d="M9.25 14.75V20.25H3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.2493 4.41452C14.2521 3.98683 13.1537 3.75 12 3.75C7.44365 3.75 3.75 7.44365 3.75 12C3.75 15.498 5.92698 18.4875 9 19.6876" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+3 -1
View File
@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M19.2479 6.923L12.9979 3.32455C12.3802 2.96888 11.6198 2.96888 11.0021 3.32455L4.75208 6.92297C4.13211 7.27992 3.75 7.94083 3.75 8.65622V15.3439C3.75 16.0593 4.13213 16.7202 4.75213 17.0772L11.0021 20.6754C11.6198 21.031 12.3802 21.031 12.9979 20.6753L19.2479 17.0769C19.8679 16.7199 20.25 16.059 20.25 15.3436V8.65625C20.25 7.94086 19.8679 7.27995 19.2479 6.923Z" stroke="black" stroke-width="1.5" stroke-linecap="square"/><path d="M15.25 12C15.25 13.7949 13.7949 15.25 12 15.25C10.2051 15.25 8.75 13.7949 8.75 12C8.75 10.2051 10.2051 8.75 12 8.75C13.7949 8.75 15.25 10.2051 15.25 12Z" stroke="black" stroke-width="1.5" stroke-linecap="square"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M7.878 5.21415L7.17474 5.05186C6.58003 4.91462 5.95657 5.09343 5.525 5.525C5.09343 5.95657 4.91462 6.58003 5.05186 7.17474L5.21415 7.878C5.40122 8.6886 5.06696 9.53036 4.37477 9.99182L3.51965 10.5619C3.03881 10.8825 2.75 11.4221 2.75 12C2.75 12.5779 3.03881 13.1175 3.51965 13.4381L4.37477 14.0082C5.06696 14.4696 5.40122 15.3114 5.21415 16.122L5.05186 16.8253C4.91462 17.42 5.09343 18.0434 5.525 18.475C5.95657 18.9066 6.58003 19.0854 7.17474 18.9481L7.878 18.7858C8.6886 18.5988 9.53036 18.933 9.99182 19.6252L10.5619 20.4804C10.8825 20.9612 11.4221 21.25 12 21.25C12.5779 21.25 13.1175 20.9612 13.4381 20.4804L14.0082 19.6252C14.4696 18.933 15.3114 18.5988 16.122 18.7858L16.8253 18.9481C17.42 19.0854 18.0434 18.9066 18.475 18.475C18.9066 18.0434 19.0854 17.42 18.9481 16.8253L18.7858 16.122C18.5988 15.3114 18.933 14.4696 19.6252 14.0082L20.4804 13.4381C20.9612 13.1175 21.25 12.5779 21.25 12C21.25 11.4221 20.9612 10.8825 20.4804 10.5619L19.6252 9.99182C18.933 9.53036 18.5988 8.6886 18.7858 7.878L18.9481 7.17473C19.0854 6.58003 18.9066 5.95657 18.475 5.525C18.0434 5.09343 17.42 4.91462 16.8253 5.05186L16.122 5.21415C15.3114 5.40122 14.4696 5.06696 14.0082 4.37477L13.4381 3.51965C13.1175 3.03881 12.5779 2.75 12 2.75C11.4221 2.75 10.8825 3.03881 10.5619 3.51965L9.99182 4.37477C9.53036 5.06696 8.6886 5.40122 7.878 5.21415Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M14.75 12C14.75 13.5188 13.5188 14.75 12 14.75C10.4812 14.75 9.25 13.5188 9.25 12C9.25 10.4812 10.4812 9.25 12 9.25C13.5188 9.25 14.75 10.4812 14.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 733 B

After

Width:  |  Height:  |  Size: 1.7 KiB

+4
View File
@@ -74,7 +74,9 @@ pub enum CustomIconName {
Share, Share,
Trending, Trending,
Recent, Recent,
Refresh,
Grid, Grid,
Init,
} }
impl IconNamed for CustomIconName { impl IconNamed for CustomIconName {
@@ -101,8 +103,10 @@ impl IconNamed for CustomIconName {
CustomIconName::Markdown => "icons/markdown.svg", CustomIconName::Markdown => "icons/markdown.svg",
CustomIconName::Share => "icons/share.svg", CustomIconName::Share => "icons/share.svg",
CustomIconName::Trending => "icons/trending.svg", CustomIconName::Trending => "icons/trending.svg",
CustomIconName::Refresh => "icons/refresh.svg",
CustomIconName::Recent => "icons/recent.svg", CustomIconName::Recent => "icons/recent.svg",
CustomIconName::Grid => "icons/grid.svg", CustomIconName::Grid => "icons/grid.svg",
CustomIconName::Init => "icons/init.svg",
} }
.into() .into()
} }
+6
View File
@@ -41,6 +41,12 @@ pub fn desktop_dir() -> PathBuf {
dirs::desktop_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default()) 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 /// Sets a custom directory for all user data, overriding the default data
/// directory. Must be called before any other path operation. The directory /// directory. Must be called before any other path operation. The directory
/// is created if it doesn't exist and canonicalized to an absolute path. /// is created if it doesn't exist and canonicalized to an absolute path.
+29 -3
View File
@@ -2,12 +2,38 @@ use nostr::prelude::*;
/// Address of a NIP-34 repository announcement: `30617:<owner-pubkey>:<repo-id>`. /// Address of a NIP-34 repository announcement: `30617:<owner-pubkey>:<repo-id>`.
/// ///
/// The Rust Nostr SDK's [`Coordinate`] already provides parsing, formatting /// The Rust Nostr SDK's [`Coordinate`] already provides parsing, formatting and hashing for this.
/// and hashing for this; the alias keeps the repository-specific vocabulary /// The alias keeps the repository-specific vocabulary while reusing the SDK type.
/// while reusing the SDK type.
pub type RepoAddr = Coordinate; pub type RepoAddr = Coordinate;
/// Build the address of a NIP-34 repository announcement. /// Build the address of a NIP-34 repository announcement.
pub fn repo_addr(owner: PublicKey, id: impl Into<String>) -> RepoAddr { pub fn repo_addr(owner: PublicKey, id: impl Into<String>) -> RepoAddr {
Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(id) 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-");
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ pub mod model;
pub mod state; pub mod state;
pub mod status; 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 annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
pub use clone_url::{CloneTarget, parse_clone_url}; pub use clone_url::{CloneTarget, parse_clone_url};
pub use comments::{CommentThread, comment_threads}; pub use comments::{CommentThread, comment_threads};
+243 -8
View File
@@ -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::collections::HashSet;
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; 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<PathBuf> {
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 a repository into `path` from the first working URL in
/// `clone_urls` (the announcement's `clone` tag), then fetch the /// `clone_urls` (the announcement's `clone` tag), then fetch the
/// `refs/nostr/*` PR refs like the cache clone does. The destination must /// `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<Str
)?; )?;
let commit = git_in(path, &["rev-parse", "HEAD"])?; let commit = git_in(path, &["rev-parse", "HEAD"])?;
if commit.len() != 40 { if commit.len() != 40 {
bail!("unexpected initial commit id: {commit}"); bail!("unexpected initial commit id: {commit}");
} }
Ok(commit) Ok(commit)
} }
/// Push the `main` branch of the repository at `repo_path` to a grasp /// Push the `main` branch of the repository at `repo_path` to a grasp server.
/// server. Grasp servers speak git smart HTTP; the repository lives at
/// `{base_url}/{owner}/{repo-id}.git` (the same path their `clone` URLs
/// announce, per the GRASP protocol).
pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
let url = format!("{base_url}/{owner}/{repo_id}.git"); 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(()) 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<Option<String>> {
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. /// Add `origin` pointing at `url` when the repository has no remote yet.
/// No-op if `origin` already exists. /// No-op if `origin` already exists.
pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> { 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] #[test]
fn repo_ref_state_lists_branches_tags_and_head() { fn repo_ref_state_lists_branches_tags_and_head() {
let (_dir, repo) = fixture(&[("a.txt", b"hello")]); let (_dir, repo) = fixture(&[("a.txt", b"hello")]);
+1
View File
@@ -23,3 +23,4 @@ log.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rustls = "0.23" rustls = "0.23"
paths = { path = "../paths" }
+370 -67
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::str::FromStr; use std::str::FromStr;
use std::time::Duration; use std::time::Duration;
@@ -9,7 +10,7 @@ use nostr::event::IntoEventBuilder;
use nostr_connect::prelude::*; use nostr_connect::prelude::*;
use nostr_sdk::client::SyncSummary; use nostr_sdk::client::SyncSummary;
use nostr_sdk::prelude::*; 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 signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
use crate::git_store::GitStore; use crate::git_store::GitStore;
@@ -496,15 +497,30 @@ impl Backend {
})? })?
.await?; .await?;
this.update(cx, |this, cx| { let state_event = match this
let builder = build_state( .update(cx, |this, cx| {
&repo_id, let builder = build_state(
&[("refs/heads/main".to_owned(), commit)], &repo_id,
Some("main"), &[("refs/heads/main".to_owned(), commit)],
); Some("main"),
this.send(builder, cx) );
})? this.send(builder, cx)
.await?; })?
.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 // 4. Push the initial commit to every grasp server. A server
// that fails to accept the push is logged, but the creation // that fails to accept the push is logged, but the creation
@@ -514,38 +530,285 @@ impl Backend {
let owner = owner.clone(); let owner = owner.clone();
let repo_id = repo_id.clone(); let repo_id = repo_id.clone();
let servers = servers.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<RelayUrl>,
cx: &mut Context<Self>,
) -> Task<Result<Announcement, Error>> {
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 { async move {
let mut failures = Vec::new(); let state = signed_git::worktree_ref_state(&path)?;
let mut pushed = 0; let euc = signed_git::root_commit(&path)?;
for relay in &servers { Ok::<_, Error>((state, euc))
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>(())
} }
}); });
push.await?; let (state, euc) = work.await?;
Announcement::from_event(&event) // 2. Ensure the grasp servers are in the relay pool; the nostr
.ok_or_else(|| anyhow!("failed to parse the published announcement")) // client queues events until each relay is connected.
this.update(cx, |this, cx| {
let urls: Vec<String> = 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<Self>,
) -> Task<Result<(), Error>> {
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<Self>,
) -> Task<Result<(), Error>> {
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 /// Login with an `nsec1...` secret key. The credential is verified by
/// the signer flow and persisted in the keyring. /// the signer flow and persisted in the keyring.
pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context<Self>) { pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context<Self>) {
let nsec = nsec.trim().to_owned(); let keys = match SecretKey::parse(nsec) {
let keys = match SecretKey::parse(&nsec) {
Ok(secret) => Keys::new(secret), Ok(secret) => Keys::new(secret),
Err(e) => { Err(e) => {
cx.emit(BackendEvent::error(e.to_string())); cx.emit(BackendEvent::error(e.to_string()));
@@ -588,15 +849,15 @@ impl Backend {
} }
}; };
let write = let nsec = nsec.trim().to_owned();
cx.write_credentials(USER_KEYRING, &keys.public_key().to_hex(), nsec.as_bytes()); 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| { self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(e) = write.await { if let Err(e) = write.await {
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?; this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
return Ok(()); return Ok(());
} }
this.update(cx, |this, cx| this.set_signer(keys, cx))?; this.update(cx, |this, cx| this.set_signer(keys, cx))?;
Ok(()) Ok(())
})); }));
@@ -1040,6 +1301,32 @@ impl Backend {
Ok(()) 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<Self>) {
if events.is_empty() {
return;
}
let mut tags: Vec<Tag> = 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 /// 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}") 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://<host>` (or `http://<host>` for `ws://` grasp servers, like /// A `https://<host>` (or `http://<host>` for `ws://` grasp servers, like
/// ngit) base URL for a grasp server. The repository then lives at /// ngit) base URL for a grasp server. The repository then lives at
/// `{base}/{npub}/{repo-id}.git`. /// `{base}/{npub}/{repo-id}.git`.
@@ -1169,6 +1441,46 @@ fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url>
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok() 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<RelayUrl>,
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. /// Split a stored bunker credential into the plain URI and the session key.
/// Credentials without an embedded key (legacy) get a fresh one. /// Credentials without an embedded key (legacy) get a fresh one.
fn extract_master_key(credential: &str) -> (&str, Keys) { fn extract_master_key(credential: &str) -> (&str, Keys) {
@@ -1187,15 +1499,6 @@ fn extract_master_key(credential: &str) -> (&str, Keys) {
mod tests { mod tests {
use super::*; 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] #[test]
fn grasp_base_url_maps_schemes_like_ngit() { fn grasp_base_url_maps_schemes_like_ngit() {
let wss = RelayUrl::parse("wss://relay.ngit.dev").expect("url"); let wss = RelayUrl::parse("wss://relay.ngit.dev").expect("url");
+38 -11
View File
@@ -1,5 +1,6 @@
mod backend; mod backend;
mod git_store; mod git_store;
mod local_repos;
mod profile; mod profile;
mod repo; mod repo;
mod repo_list; mod repo_list;
@@ -9,6 +10,7 @@ use std::path::{Path, PathBuf};
pub use backend::{Backend, BackendEvent}; pub use backend::{Backend, BackendEvent};
pub use git_store::GitStore; pub use git_store::GitStore;
use gpui::{App, AppContext, Entity}; use gpui::{App, AppContext, Entity};
pub use local_repos::LocalReposStore;
pub use nostr_sdk::prelude::Timestamp; pub use nostr_sdk::prelude::Timestamp;
pub use profile::{Profile, ProfileStore}; pub use profile::{Profile, ProfileStore};
pub use repo::RepoStore; pub use repo::RepoStore;
@@ -16,8 +18,15 @@ pub use repo_list::{RepoActivityCounts, RepoListStore};
use signed_nostr::new_backend; use signed_nostr::new_backend;
pub use utils::shorten_pubkey; pub use utils::shorten_pubkey;
/// Initialize the backend and stores, and install them as globals. Call once /// The default directories scanned for local git repositories
/// at startup, before opening any window that uses the stores. /// on every platform: the user's Desktop and Documents folders.
#[cfg(not(target_arch = "wasm32"))]
fn default_scan_paths() -> Vec<PathBuf> {
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"))] #[cfg(not(target_arch = "wasm32"))]
pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> { pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
// rustls uses the `aws_lc_rs` provider by default; ignore if already installed. // rustls uses the `aws_lc_rs` provider by default; ignore if already installed.
@@ -25,46 +34,64 @@ pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
.install_default() .install_default()
.ok(); .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 (client, signer) = cx.foreground_executor().block_on(async move {
let path = db_path.as_ref().to_path_buf();
new_backend(path) new_backend(path)
.await .await
.expect("failed to initialize nostr backend") .expect("failed to initialize nostr backend")
}); });
// Initialize the backend and stores.
let entity = cx.new(|cx| Backend::new(client, signer, cx)); let entity = cx.new(|cx| Backend::new(client, signer, cx));
Backend::set_global(entity.clone(), cx); Backend::set_global(entity.clone(), cx);
// Initialize the profile store.
ProfileStore::set_global(cx.new(ProfileStore::new), cx); ProfileStore::set_global(cx.new(ProfileStore::new), cx);
// Start the explore list from the local database before the first // Start the explore list from the local database before
// window opens; relay syncs continue in the background, so the list // the first window opens, relay syncs continue in the background,
// never waits for them. // so the list never waits for them.
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx); RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
// The clone cache is only meaningful on native platforms; the wasm // The clone cache is only meaningful on native platforms,
// build registers an empty store so `GitStore::global` still works. // the wasm build registers an empty store so `GitStore::global` still works.
GitStore::set_global(PathBuf::new(), cx); 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 entity
} }
/// Initialize the backend with an in-memory database on wasm. /// Initialize the backend with an in-memory database on wasm.
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub fn init(cx: &mut App) -> Entity<Backend> { pub fn init(cx: &mut App) -> Entity<Backend> {
// Initialize the nostr client and universal signer.
let (client, signer) = new_backend().expect("failed to initialize nostr backend"); 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)); let entity = cx.new(|cx| Backend::new(client, signer, cx));
Backend::set_global(entity.clone(), cx); Backend::set_global(entity.clone(), cx);
// Initialize the profile store.
ProfileStore::set_global(cx.new(ProfileStore::new), cx); ProfileStore::set_global(cx.new(ProfileStore::new), cx);
// Start the explore list from the local database before the first // Start the explore list from the local database before
// window opens; relay syncs continue in the background, so the list // the first window opens, relay syncs continue in the background,
// never waits for them. // so the list never waits for them.
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx); 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); 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 entity
} }
+116
View File
@@ -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<LocalReposStore>);
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<Vec<PathBuf>>,
/// Git repositories discovered under [`Self::roots`], sorted by path.
pub repos: Arc<Vec<PathBuf>>,
/// A scan is currently running.
pub scanning: bool,
/// A scan was requested while one was already running.
scan_dirty: bool,
tasks: Vec<Task<Result<(), Error>>>,
}
impl LocalReposStore {
/// Retrieve the global local-repositories store
/// (created at startup by [`crate::init`]).
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalLocalReposStore>().0.clone()
}
pub(crate) fn set_global(entity: Entity<Self>, 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<PathBuf>, cx: &mut Context<Self>) -> 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>) {
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<Self>) {
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(())
}));
}
}
+6 -1
View File
@@ -86,8 +86,13 @@ impl RepoStore {
let kind = event.kind == Kind::GitRepoAnnouncement; let kind = event.kind == Kind::GitRepoAnnouncement;
let author = event.pubkey == this.addr.public_key; let author = event.pubkey == this.addr.public_key;
let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr); 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, _ => false,
}; };
+8 -2
View File
@@ -98,8 +98,14 @@ impl RepoListStore {
} }
} }
BackendEvent::Published(event) => { BackendEvent::Published(event) => {
event.kind == Kind::GitRepoAnnouncement let announcement = event.kind == Kind::GitRepoAnnouncement
&& this.author.is_none_or(|a| a == event.pubkey) && 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, BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
_ => false, _ => false,
@@ -204,21 +204,22 @@ fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) ->
.min_w_0() .min_w_0()
.children(items.into_iter().enumerate().map(|(ix, item)| { .children(items.into_iter().enumerate().map(|(ix, item)| {
h_flex() h_flex()
.id(ix)
.h_8()
.px_2()
.gap_2() .gap_2()
.items_center()
.min_w_0() .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( .child(
h_flex() div()
.h_5()
.px_1()
.flex_1() .flex_1()
.min_w_0() .min_w_0()
.overflow_hidden()
.whitespace_nowrap() .whitespace_nowrap()
.text_ellipsis() .text_ellipsis()
.text_sm() .text_sm()
.bg(cx.theme().muted)
.rounded(cx.theme().radius)
.child(SharedString::from(middle_truncate(&item, 28, 16))), .child(SharedString::from(middle_truncate(&item, 28, 16))),
) )
.child( .child(
@@ -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<SharedString>,
}
/// 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<RepoDetailView>,
window: &mut Window,
cx: &mut App,
) {
let default_name = local_path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let 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<InputState>, Entity<TextareaState>),
state: Entity<InitRepoState>,
grasp_state: Entity<GraspServersState>,
view: WeakEntity<RepoDetailView>,
window: &mut Window,
cx: &mut App,
) {
let (name_input, desc_input) = inputs;
let name = name_input.read(cx).value().trim().to_owned();
let description = desc_input.read(cx).value().trim().to_owned();
let servers = grasp_state.read(cx).grasp_servers.clone();
if name.is_empty() {
state.update(cx, |state, _| {
state.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();
}
+519 -188
View File
@@ -13,12 +13,14 @@ use gpui::{
Task, WeakEntity, Window, div, px, relative, size, Task, WeakEntity, Window, div, px, relative, size,
}; };
use gpui_base::{Button as BaseButton, Disableable, Popover}; use gpui_base::{Button as BaseButton, Disableable, Popover};
use gpui_component::alert::Alert;
use gpui_component::avatar::Avatar; use gpui_component::avatar::Avatar;
use gpui_component::button::{Button, ButtonVariants}; use gpui_component::button::{Button, ButtonVariants};
use gpui_component::clipboard::Clipboard; use gpui_component::clipboard::Clipboard;
use gpui_component::combobox::{ use gpui_component::combobox::{
Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext, Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext,
}; };
use gpui_component::menu::DropdownMenu;
use gpui_component::searchable_list::SearchableVec; use gpui_component::searchable_list::SearchableVec;
use gpui_component::tree::TreeState; use gpui_component::tree::TreeState;
use gpui_component::{ use gpui_component::{
@@ -28,7 +30,7 @@ use gpui_component::{
use nostr::prelude::{RelayUrl, ToBech32}; use nostr::prelude::{RelayUrl, ToBech32};
use signed_core::Announcement; use signed_core::Announcement;
use signed_git::{CommitList, FileCommit}; 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::image_cache::{MAX_IMAGES, image_cache};
use crate::pixel_avatar::PixelAvatar; use crate::pixel_avatar::PixelAvatar;
@@ -38,6 +40,7 @@ mod browser;
mod commits; mod commits;
mod diff; mod diff;
mod helpers; mod helpers;
mod init_dialog;
mod issue_detail; mod issue_detail;
mod issues; mod issues;
mod pull_request_detail; mod pull_request_detail;
@@ -73,6 +76,12 @@ enum RepoAction {
NewIssue, NewIssue,
/// Open the "new pull request" dialog. /// Open the "new pull request" dialog.
NewPR, 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, /// Everything loaded from the local clone for the explorer: the tree seeds,
@@ -98,9 +107,15 @@ pub struct RepoDetailView {
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
/// Snapshot taken at open time, shown until the store's first refresh /// Snapshot taken at open time, shown until the store's first refresh
/// completes (and as a fallback while the store has no announcement). /// 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<Announcement>,
/// Per-repository nostr store (announcement, issues, PRs, statuses). /// Per-repository nostr store (announcement, issues, PRs, statuses).
store: Entity<RepoStore>, /// `None` until a local repository is initialized (published) to
/// NIP-34.
store: Option<Entity<RepoStore>>,
/// 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<PathBuf>,
/// File explorer state (worktree of the local clone). /// File explorer state (worktree of the local clone).
tree_state: Entity<TreeState>, tree_state: Entity<TreeState>,
/// Root of the local clone, for reading files on demand. /// Root of the local clone, for reading files on demand.
@@ -141,6 +156,8 @@ pub struct RepoDetailView {
loading: bool, loading: bool,
/// The header clone button is cloning into a user-chosen folder. /// The header clone button is cloning into a user-chosen folder.
cloning: bool, cloning: bool,
/// A push to the grasp servers is in flight.
pushing: bool,
error: Option<SharedString>, error: Option<SharedString>,
/// Commit HEAD currently points to, shown in the header button. /// Commit HEAD currently points to, shown in the header button.
head_commit: Option<FileCommit>, head_commit: Option<FileCommit>,
@@ -161,6 +178,8 @@ pub struct RepoDetailView {
} }
impl 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( pub fn new(
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
initial: Announcement, initial: Announcement,
@@ -170,7 +189,36 @@ impl RepoDetailView {
// The announcement we opened from already carries the repository's // The announcement we opened from already carries the repository's
// NIP-34 `relays` tag, so the store can connect to those relays // NIP-34 `relays` tag, so the store can connect to those relays
// immediately instead of waiting for the bootstrap fetch. // 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<DockArea>,
local_path: PathBuf,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self::new_common(dock_area, None, None, Some(local_path), window, cx)
}
/// Shared construction: file explorer state, ref selectors and the
/// deferred repository load.
fn new_common(
dock_area: WeakEntity<DockArea>,
initial: Option<Announcement>,
store: Option<Entity<RepoStore>>,
local_path: Option<PathBuf>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let tree_state = cx.new(|cx| TreeState::new(cx)); let tree_state = cx.new(|cx| TreeState::new(cx));
// Empty until the clone completes; populated with the local refs. // Empty until the clone completes; populated with the local refs.
@@ -222,6 +270,7 @@ impl RepoDetailView {
initial, initial,
dock_area, dock_area,
store, store,
local_path,
tree_state, tree_state,
worktree: None, worktree: None,
md: None, md: None,
@@ -242,6 +291,7 @@ impl RepoDetailView {
item_sizes: Rc::new(Vec::new()), item_sizes: Rc::new(Vec::new()),
loading: true, loading: true,
cloning: false, cloning: false,
pushing: false,
error: None, error: None,
head_commit: None, head_commit: None,
branch_select, branch_select,
@@ -254,19 +304,49 @@ impl RepoDetailView {
} }
} }
/// Load the repository and populate the file explorer. The local clone /// Load the repository and populate the file explorer. A local
/// (if any) is loaded first without touching the network, so an /// (not yet published) repository is opened straight from disk. An
/// unreachable server can't block the panel; a background fetch then /// announced repository's local clone (if any) is loaded first without
/// refreshes the refs and commit list (a fetch never changes the /// touching the network, so an unreachable server can't block the
/// checked-out files, so the tree and previews are left alone). /// 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>) { fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true; self.loading = true;
self.error = None; self.error = None;
cx.notify(); 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 cache = GitStore::global(cx).cache().clone();
let addr = self.initial.addr(); let addr = initial.addr();
let clone_urls: Vec<String> = self.initial.clone.iter().map(ToString::to_string).collect(); let clone_urls: Vec<String> = initial.clone.iter().map(ToString::to_string).collect();
// Captured before the loads start: a branch/tag switch bumps it, and // Captured before the loads start: a branch/tag switch bumps it, and
// the refresh below is discarded when that happens. // the refresh below is discarded when that happens.
let refresh_generation = self.ref_generation; let refresh_generation = self.ref_generation;
@@ -434,7 +514,9 @@ impl RepoDetailView {
} }
let (clone_urls, name) = { let (clone_urls, name) = {
let announcement = self.announcement(cx); let Some(announcement) = self.announcement(cx) else {
return;
};
let addr = announcement.addr(); let addr = announcement.addr();
let clone_urls: Vec<String> = let clone_urls: Vec<String> =
announcement.clone.iter().map(ToString::to_string).collect(); 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<Self>) {
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<Self>) {
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. /// Open the issues panel at the bottom of the dock area.
fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(store) = self.store.clone() else {
return;
};
let Some(dock_area) = self.dock_area.upgrade() else { let Some(dock_area) = self.dock_area.upgrade() else {
return; return;
}; };
@@ -766,7 +905,7 @@ impl RepoDetailView {
let panel = cx.new(|cx| { let panel = cx.new(|cx| {
IssuesView::new( IssuesView::new(
self.dock_area.clone(), self.dock_area.clone(),
self.store.clone(), store,
self.display_name(cx), self.display_name(cx),
window, window,
cx, cx,
@@ -780,6 +919,9 @@ impl RepoDetailView {
/// Open the pull requests panel at the bottom of the dock area. /// 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<Self>) { fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(store) = self.store.clone() else {
return;
};
let Some(dock_area) = self.dock_area.upgrade() else { let Some(dock_area) = self.dock_area.upgrade() else {
return; return;
}; };
@@ -787,7 +929,7 @@ impl RepoDetailView {
let panel = cx.new(|cx| { let panel = cx.new(|cx| {
PullRequestsView::new( PullRequestsView::new(
self.dock_area.clone(), self.dock_area.clone(),
self.store.clone(), store,
self.display_name(cx), self.display_name(cx),
window, window,
cx, cx,
@@ -1025,39 +1167,66 @@ impl RepoDetailView {
} }
} }
/// The latest announcement from the store, or the open-time snapshot. /// The latest announcement from the store, or the open-time snapshot;
fn announcement<'a>(&'a self, cx: &'a App) -> &'a Announcement { /// `None` for local repositories that haven't been published yet.
self.store fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> {
let store = self.store.as_ref()?;
store
.read(cx) .read(cx)
.announcement .announcement
.as_ref() .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 { fn display_name(&self, cx: &App) -> SharedString {
let announcement = self.announcement(cx); if let Some(path) = &self.local_path {
announcement return SharedString::from(
.name path.file_name()
.clone() .map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| SharedString::from(announcement.id.clone())) .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<Self>) -> AnyElement { fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let store = self.store.read(cx); if self.local_path.is_some() {
let announcement = store.announcement.as_ref().unwrap_or(&self.initial); 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 issue_count = SharedString::from(store.issue_count().to_string());
let pr_count = SharedString::from(store.pull_request_count().to_string()); let pr_count = SharedString::from(store.pull_request_count().to_string());
let name = self.display_name(cx); let name = self.display_name(cx);
let description = announcement.description(); let description = announcement.description();
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); 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 nostr_url = nostr_clone_url(&announcement, cx);
let worktree_empty = self.switching_ref || self.worktree.is_none();
let nostr_url = nostr_clone_url(announcement, cx);
let ngit_command = SharedString::from(format!("git clone {nostr_url}")); let ngit_command = SharedString::from(format!("git clone {nostr_url}"));
let nak_command = SharedString::from(format!("nak git clone {nostr_url}")); let nak_command = SharedString::from(format!("nak git clone {nostr_url}"));
let git_commands = announcement.clone_urls(); let git_commands = announcement.clone_urls();
@@ -1066,11 +1235,22 @@ impl RepoDetailView {
.on_action( .on_action(
cx.listener(|this, action: &RepoAction, window, cx| match action { cx.listener(|this, action: &RepoAction, window, cx| match action {
RepoAction::NewIssue => { 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 => { 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() .px_4()
@@ -1160,11 +1340,13 @@ impl RepoDetailView {
})), })),
) )
.dropdown_menu(|menu, _, _| { .dropdown_menu(|menu, _, _| {
menu.menu_element_with_icon( menu.menu_element(Box::new(RepoAction::NewIssue), |_, _| {
IconName::Plus, h_flex()
Box::new(RepoAction::NewIssue), .gap_2()
|_, _| div().text_xs().child("New issue"), .text_sm()
) .child(Icon::new(IconName::Plus))
.child("New issue")
})
}), }),
) )
.child( .child(
@@ -1201,11 +1383,13 @@ impl RepoDetailView {
})), })),
) )
.dropdown_menu(|menu, _, _| { .dropdown_menu(|menu, _, _| {
menu.menu_element_with_icon( menu.menu_element(Box::new(RepoAction::NewPR), |_, _| {
IconName::Plus, h_flex()
Box::new(RepoAction::NewPR), .gap_2()
|_, _| div().text_xs().child("New PR"), .text_sm()
) .child(Icon::new(IconName::Plus))
.child("New PR")
})
}), }),
) )
.child( .child(
@@ -1227,17 +1411,50 @@ impl RepoDetailView {
.dropdown_menu(move |menu, _, _| share.menu(menu)), .dropdown_menu(move |menu, _, _| share.menu(menu)),
) )
.child( .child(
Button::new("info") Button::new("repo-menu-open")
.icon(IconName::Info) .icon(IconName::EllipsisVertical)
.tooltip("About") .tooltip("Repository management")
.compact()
.secondary() .secondary()
.on_click(cx.listener(|this, _event, window, cx| { .loading(self.pushing)
open_about_dialog( .disabled(self.pushing)
this.announcement(cx).clone(), .dropdown_menu(move |menu, _, cx| {
window, let backend = Backend::global(cx);
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({ .child({
let view = cx.entity(); 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<Self>) -> AnyElement {
let name = self.display_name(cx);
let path = self
.local_path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_default();
let avatar = PixelAvatar::new(path.clone());
v_flex()
.px_4()
.pb_4()
.w_full()
.gap_8()
.border_b_1()
.border_color(cx.theme().border)
.child( .child(
h_flex() 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<Self>) {
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<Self>,
) {
// 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<Self>) -> AnyElement {
let commits_count = self.all_commits.as_ref().map(|list| list.total);
let worktree_empty = self.switching_ref || self.worktree.is_none();
h_flex()
.items_center()
.gap_2()
.child(
BaseButton::new("files-tab")
.flex()
.items_center() .items_center()
.h_8()
.px_2()
.gap_2() .gap_2()
.child( .child(
BaseButton::new("files-tab") h_flex()
.flex() .gap_1()
.items_center() .text_sm()
.h_8() .child(Icon::new(CustomIconName::GitFile).small())
.px_2() .child("Files"),
.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();
})),
) )
.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( .child(
h_flex() h_flex()
.flex_1() .gap_1()
.gap_2() .text_sm()
.justify_end() .child(Icon::new(CustomIconName::GitCommit).small())
.child( .child("Commits"),
Button::new("enc") )
.ghost() .when_some(commits_count, |this, count| {
.when_some(self.head_commit.as_ref(), |this, commit| { this.child(
this.child( h_flex()
div() .justify_center()
.text_xs() .px_1()
.text_color(cx.theme().muted_foreground) .py_0p5()
.child(SharedString::from(&commit.id)), .min_w_4()
) .text_size(px(8.))
.child( .bg(cx.theme().muted)
div() .text_color(cx.theme().muted_foreground)
.max_w(px(200.)) .rounded(cx.theme().radius)
.overflow_hidden() .line_height(relative(1.))
.text_ellipsis() .child(SharedString::from(count.to_string())),
.whitespace_nowrap() )
.text_xs() })
.child(SharedString::from(&commit.summary)), .text_color(cx.theme().button_foreground)
) .rounded(cx.theme().radius)
}) .hover(|this| this.bg(cx.theme().button_hover))
.tooltip( .active(|this| this.bg(cx.theme().button_active))
self.head_commit .selected(self.active_tab == 1)
.as_ref() .when(self.active_tab == 1, |this| {
.map_or_else(SharedString::default, |commit| { this.bg(cx.theme().button_active)
commit.summary.clone().into() })
}), .on_click(cx.listener(|this, _event, _window, cx| {
) this.active_tab = 1;
.on_click(cx.listener(|this, _event, window, cx| { cx.notify();
if let Some(commit) = &this.head_commit { })),
let id = commit.id.clone(); )
this.open_commit_diff(&id, window, cx); .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( .on_click(cx.listener(|this, _event, window, cx| {
div().w(px(120.)).child( if let Some(commit) = &this.head_commit {
Combobox::new(&self.branch_select) let id = commit.id.clone();
.placeholder("Branch") this.open_commit_diff(&id, window, cx);
.appearance(false) }
.menu_width(px(200.)) })),
.disabled(worktree_empty) )
.bg(cx.theme().muted) .child(
.rounded(cx.theme().radius) div().w(px(120.)).child(
.render_trigger(|ctx, _window, cx| { Combobox::new(&self.branch_select)
Self::render_ref_trigger( .placeholder("Branch")
ctx, .appearance(false)
CustomIconName::GitBranch, .menu_width(px(200.))
cx, .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") .child(
.appearance(false) div().w(px(120.)).child(
.menu_width(px(200.)) Combobox::new(&self.tag_select)
.disabled(worktree_empty) .placeholder("Tag")
.bg(cx.theme().muted) .appearance(false)
.rounded(cx.theme().radius) .menu_width(px(200.))
.render_trigger(|ctx, _window, cx| { .disabled(worktree_empty)
Self::render_ref_trigger(ctx, CustomIconName::Tag, cx) .bg(cx.theme().muted)
}), .rounded(cx.theme().radius)
), .render_trigger(|ctx, _window, cx| {
), Self::render_ref_trigger(ctx, CustomIconName::Tag, cx)
}),
),
), ),
) )
.into_any_element() .into_any_element()
} }
fn render_maintainers(&self, cx: &mut Context<Self>) -> AnyElement { fn render_maintainers(&self, cx: &mut Context<Self>) -> 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 profile_store = ProfileStore::global(cx);
let mut seen = HashSet::new(); let mut seen = HashSet::new();
@@ -1576,6 +1897,16 @@ impl Render for RepoDetailView {
.id("repo") .id("repo")
.size_full() .size_full()
.child(self.render_header(cx)) .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 { .map(|this| match self.active_tab {
0 => this.child( 0 => this.child(
h_flex() h_flex()
@@ -2,56 +2,29 @@ use dock::{DockArea, DockPlacement, panel_handle};
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px}; use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px};
use gpui_base::input::TextareaState; 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::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form}; use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputState, Textarea}; use gpui_component::input::{Input, InputState, Textarea};
use gpui_component::{ActiveTheme, Disableable, IconName, Sizable, WindowExt, h_flex, v_flex}; use gpui_component::{ActiveTheme, Disableable, IconName, WindowExt, h_flex};
use nostr::prelude::*; use signed_core::Announcement;
use signed_core::{Announcement, filters};
use signed_state::Backend; use signed_state::Backend;
use super::super::RepoDetailView; use super::super::RepoDetailView;
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
/// 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",
];
/// Shared state for the Create Repository dialog, so async results can be rendered. /// Shared state for the Create Repository dialog, so async results can be rendered.
#[derive(Default)] #[derive(Default)]
pub struct CreateRepoState { pub struct CreateRepoState {
pub busy: bool, pub busy: bool,
/// The user's grasp list (kind `10317`) is being loaded.
pub loading_servers: bool,
pub error: Option<SharedString>, pub error: Option<SharedString>,
pub grasp_servers: Vec<RelayUrl>,
/// 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. /// Open the Create Repository dialog.
/// ///
/// The dialog loads the user's default grasp servers (kind `10317` grasp /// The dialog loads the user's default grasp servers (kind `10317` grasp
/// list) and falls back to [`DEFAULT_GRASP_SERVERS`] when none are set. /// list) and falls back to the shared defaults when none are set. On
/// On success the dialog closes and the new repository opens in the dock. /// success the dialog closes and the new repository opens in the dock.
pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) { pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Repository name")); let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Repository name"));
let desc_input = cx.new(|cx| { let desc_input = cx.new(|cx| {
@@ -66,21 +39,21 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
let relay_input = cx.new(|cx| { let relay_input = cx.new(|cx| {
InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com") 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| { window.open_dialog(cx, move |dialog, _window, _cx| {
const DESC: &str = "Publish a new repository to your grasp servers."; 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 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 name_input = name_input.clone();
let desc_input = desc_input.clone(); let desc_input = desc_input.clone();
let folder_input = folder_input.clone(); let folder_input = folder_input.clone();
let relay_input = relay_input.clone(); let relay_input = relay_input.clone();
let state = state.clone(); let state = state.clone();
let grasp_state = grasp_state.clone();
let dock_area = dock_area.clone(); let dock_area = dock_area.clone();
dialog dialog
@@ -89,9 +62,6 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
.content(move |content, _window, cx| { .content(move |content, _window, cx| {
let busy = state.read(cx).busy; let busy = state.read(cx).busy;
let error = state.read(cx).error.clone(); 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 content
.child( .child(
@@ -137,89 +107,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
), ),
), ),
) )
.child( .child(grasp_servers_field(&grasp_state, &relay_input, cx)),
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..."),
)
},
)
})),
),
) )
.children(error.map(|message| { .children(error.map(|message| {
div().text_sm().text_color(cx.theme().danger).child(message) div().text_sm().text_color(cx.theme().danger).child(message)
@@ -237,6 +125,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
let name_input = name_input.clone(); let name_input = name_input.clone();
let desc_input = desc_input.clone(); let desc_input = desc_input.clone();
let state = state.clone(); let state = state.clone();
let grasp_state = grasp_state.clone();
let dock_area = dock_area.clone(); let dock_area = dock_area.clone();
move |_ev, window, cx| { move |_ev, window, cx| {
@@ -244,6 +133,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
name_input.clone(), name_input.clone(),
desc_input.clone(), desc_input.clone(),
state.clone(), state.clone(),
grasp_state.clone(),
dock_area.clone(), dock_area.clone(),
window, window,
cx, cx,
@@ -256,53 +146,6 @@ pub fn open(dock_area: WeakEntity<DockArea>, 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<CreateRepoState>,
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 /// 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 /// the platform's native folder picker, and show the result in the disabled
/// folder input. /// folder input.
@@ -331,54 +174,19 @@ fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mu
.detach(); .detach();
} }
/// Parse the relay input (accepting a bare host) and append it to the list.
fn add_relay(
state: &Entity<CreateRepoState>,
input: &Entity<InputState>,
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. /// Run the create-repository flow; closes the dialog and opens the new repository on success.
fn create_repository( fn create_repository(
name_input: Entity<InputState>, name_input: Entity<InputState>,
desc_input: Entity<TextareaState>, desc_input: Entity<TextareaState>,
state: Entity<CreateRepoState>, state: Entity<CreateRepoState>,
grasp_state: Entity<GraspServersState>,
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) { ) {
let name = name_input.read(cx).value().trim().to_owned(); let name = name_input.read(cx).value().trim().to_owned();
let description = desc_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() { if name.is_empty() {
state.update(cx, |state, _| { state.update(cx, |state, _| {
@@ -444,54 +252,3 @@ fn open_repo(
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx); 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<CreateRepoState>, 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<Vec<RelayUrl>> = async {
let mut events: Vec<Event> = 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();
}
@@ -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<RelayUrl>,
/// 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<SharedString>,
}
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<GraspServersState>,
relay_input: &Entity<InputState>,
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<GraspServersState>,
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<GraspServersState>,
input: &Entity<InputState>,
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<GraspServersState>,
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<Vec<RelayUrl>> = async {
let mut events: Vec<Event> = 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();
}
+155 -22
View File
@@ -1,4 +1,6 @@
use std::collections::HashSet;
use std::ops::Range; use std::ops::Range;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use assets::CustomIconName; use assets::CustomIconName;
@@ -17,14 +19,15 @@ use gpui_component::avatar::Avatar;
use gpui_component::button::{Button, ButtonVariants}; use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::InputState; use gpui_component::input::InputState;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use signed_core::Announcement; use signed_core::{Announcement, identifier_from_name};
use signed_state::{Backend, BackendEvent, Profile, ProfileStore, RepoListStore}; use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore, RepoListStore};
use super::{RepoDetailView, RepoListView}; use super::{RepoDetailView, RepoListView};
use crate::image_cache::{MAX_IMAGES, image_cache}; use crate::image_cache::{MAX_IMAGES, image_cache};
use crate::pixel_avatar::PixelAvatar; use crate::pixel_avatar::PixelAvatar;
mod create_repo_dialog; mod create_repo_dialog;
pub(crate) mod grasp_servers;
mod import_dialog; mod import_dialog;
mod onboarding_dialog; mod onboarding_dialog;
pub(crate) mod passphrase_dialog; pub(crate) mod passphrase_dialog;
@@ -37,20 +40,23 @@ pub struct SidebarPanel {
focus_handle: FocusHandle, focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
explore: Option<WeakEntity<RepoListView>>, explore: Option<WeakEntity<RepoListView>>,
logged_in: bool,
/// Repositories announced by the current user, listed under /// Repositories announced by the current user, listed under
/// "All Repositories". Recreated when the signer changes. /// "All Repositories". Recreated when the signer changes.
my_repos: Option<Entity<RepoListStore>>, my_repos: Option<Entity<RepoListStore>>,
/// Observes the current user's repo store so the list re-renders. /// Observes the current user's repo store so the list re-renders.
my_repos_subscription: Option<Subscription>, my_repos_subscription: Option<Subscription>,
logged_in: bool,
/// Banner artwork shown behind the sign-in screen, /// Banner artwork shown behind the sign-in screen,
/// picked at random from the bundled `backgrounds/` assets. /// picked at random from the bundled `backgrounds/` assets.
banner: SharedString, banner: SharedString,
/// Observes the local-repository scan so new discoveries re-render.
_local_repos_subscription: Subscription,
_subscription: Subscription, _subscription: Subscription,
} }
impl SidebarPanel { impl SidebarPanel {
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self { pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
let local_repos_store = LocalReposStore::global(cx);
let backend = Backend::global(cx); let backend = Backend::global(cx);
let logged_in = backend.read(cx).current_user().is_some(); let logged_in = backend.read(cx).current_user().is_some();
@@ -71,14 +77,19 @@ impl SidebarPanel {
cx.notify(); cx.notify();
}); });
let local_repos_subscription = cx.observe(&local_repos_store, |_, _, cx| {
cx.notify();
});
let mut panel = Self { let mut panel = Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
dock_area, dock_area,
logged_in,
explore: None, explore: None,
my_repos: None, my_repos: None,
my_repos_subscription: None, my_repos_subscription: None,
logged_in,
banner: pick_banner(), banner: pick_banner(),
_local_repos_subscription: local_repos_subscription,
_subscription: 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<Self>) {
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 "All Repositories" section: header with the create button and
/// the current user's repositories below it, lazily rendered through a /// 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<Self>) -> impl IntoElement { fn render_my_repos(&self, cx: &mut Context<Self>) -> impl IntoElement {
let store = self.my_repos.as_ref(); 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() v_flex()
.px_2() .px_2()
@@ -193,19 +225,54 @@ impl SidebarPanel {
.child(div().text_xs().font_semibold().child("All Repositories")), .child(div().text_xs().font_semibold().child("All Repositories")),
) )
.child( .child(
Button::new("add") h_flex()
.icon(IconName::Plus) .gap_1()
.small() .child(
.ghost() Button::new("rescan")
.on_click(cx.listener(|this, _ev, window, cx| { .icon(CustomIconName::Refresh)
this.open_create_repo(window, cx); .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| { .when_some(store, |builder, store| {
let announcements = store.read(cx).announcements.clone(); 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<String> =
announcements.iter().map(|a| a.id.clone()).collect();
let local_repos: Vec<PathBuf> = 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( builder.child(
div() div()
.flex_1() .flex_1()
@@ -213,18 +280,27 @@ impl SidebarPanel {
.py_1() .py_1()
.text_xs() .text_xs()
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child("No repositories yet"), .child(if scanning {
"Scanning for local repositories…"
} else {
"No repositories yet"
}),
) )
} else { } else {
builder.child( builder.child(
uniform_list( uniform_list(
"my-repos-list", "repos",
announcements.len(), total,
cx.processor(move |this, range: Range<usize>, _window, cx| { cx.processor(move |this, range: Range<usize>, _window, cx| {
range range
.map(|ix| { .map(|ix| {
this.render_repo_row(&announcements[ix], cx) this.render_repo_row_at(
.into_any_element() &announcements,
&local_repos,
ix,
cx,
)
.into_any_element()
}) })
.collect() .collect()
}), }),
@@ -236,8 +312,27 @@ impl SidebarPanel {
}) })
} }
/// One repository row in the sidebar, styled like the nav items: a /// One row of the merged sidebar list: a NIP-34 repository or a local
/// deterministic pixel avatar and the repo name. /// repository.
fn render_repo_row_at(
&self,
announcements: &[Announcement],
local_repos: &[PathBuf],
ix: usize,
cx: &mut Context<Self>,
) -> 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( fn render_repo_row(
&self, &self,
announcement: &Announcement, 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<Self>) -> 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. /// Show the Import Identity dialog.
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
import_dialog::open(window, cx); import_dialog::open(window, cx);
@@ -491,8 +612,8 @@ impl Render for SidebarPanel {
} }
/// A single navigation entry in the sidebar: an arbitrary leading element /// A single navigation entry in the sidebar: an arbitrary leading element
/// (an icon, avatar, ...) and a text label with a hover highlight and an /// (an icon, avatar, ...) and a text label with a hover highlight,
/// optional click handler. /// an optional trailing suffix (e.g. a status icon) and an optional click handler.
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
#[derive(IntoElement)] #[derive(IntoElement)]
struct NavItem { struct NavItem {
@@ -500,6 +621,8 @@ struct NavItem {
style: StyleRefinement, style: StyleRefinement,
icon: AnyElement, icon: AnyElement,
label: SharedString, label: SharedString,
/// Trailing element rendered at the right edge of the row, after the (ellipsized) label.
suffix: Option<AnyElement>,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>, on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
} }
@@ -515,10 +638,17 @@ impl NavItem {
icon: icon.into_any_element(), icon: icon.into_any_element(),
label: label.into(), label: label.into(),
style: StyleRefinement::default(), style: StyleRefinement::default(),
suffix: None,
on_click: 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 { fn on_click(mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Box::new(listener)); self.on_click = Some(Box::new(listener));
self self
@@ -545,6 +675,9 @@ impl RenderOnce for NavItem {
.text_ellipsis() .text_ellipsis()
.child(self.label), .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)) .hover(|this| this.bg(cx.theme().list_hover))
.when_some(self.on_click, |this, listener| this.on_click(listener)) .when_some(self.on_click, |this, listener| this.on_click(listener))
} }
+5 -3
View File
@@ -1,7 +1,9 @@
# TODO # 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 ## Create repository dialog
- [ ] Persist the user's preferred local repository folder (the one picked in - [ ] 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.
the create-repository dialog, defaulting to Desktop) and use it as the
default next time the dialog opens.