diff --git a/Cargo.lock b/Cargo.lock index b3ea1ec..1675a2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -218,15 +218,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "ar_archive_writer" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" -dependencies = [ - "object 0.39.1", -] - [[package]] name = "arbitrary" version = "1.4.2" @@ -653,7 +644,7 @@ dependencies = [ "cfg-if", "libc", "miniz_oxide", - "object 0.37.3", + "object", "rustc-demangle", "windows-link 0.2.1", ] @@ -1268,7 +1259,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "gpui_util", "indexmap", @@ -1283,9 +1274,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -1734,7 +1725,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "proc-macro2", "quote", @@ -3489,7 +3480,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "accesskit", "anyhow", @@ -3539,7 +3530,6 @@ dependencies = [ "slotmap", "smallvec", "spin 0.10.1", - "stacksafe", "strum", "sum_tree", "taffy", @@ -3561,7 +3551,7 @@ dependencies = [ [[package]] name = "gpui-base" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#7885c41663c7a6cc68ad0c99b1ba33550f807ff0" +source = "git+https://github.com/longbridge/gpui-component#d5821f270317754f2a311a0bb148ec32cbb0ced4" dependencies = [ "aho-corasick", "anyhow", @@ -3593,7 +3583,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#7885c41663c7a6cc68ad0c99b1ba33550f807ff0" +source = "git+https://github.com/longbridge/gpui-component#d5821f270317754f2a311a0bb148ec32cbb0ced4" dependencies = [ "anyhow", "chrono", @@ -3673,7 +3663,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#7885c41663c7a6cc68ad0c99b1ba33550f807ff0" +source = "git+https://github.com/longbridge/gpui-component#d5821f270317754f2a311a0bb148ec32cbb0ced4" dependencies = [ "anyhow", "gpui", @@ -3687,7 +3677,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#7885c41663c7a6cc68ad0c99b1ba33550f807ff0" +source = "git+https://github.com/longbridge/gpui-component#d5821f270317754f2a311a0bb148ec32cbb0ced4" dependencies = [ "proc-macro2", "quote", @@ -3697,7 +3687,7 @@ dependencies = [ [[package]] name = "gpui_apple" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "anyhow", "block", @@ -3720,7 +3710,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "accesskit", "accesskit_unix", @@ -3766,7 +3756,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "accesskit", "accesskit_macos", @@ -3812,7 +3802,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3823,7 +3813,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "console_error_panic_hook", "gpui", @@ -3836,7 +3826,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "schemars", "serde", @@ -3846,7 +3836,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "anyhow", "log", @@ -3856,7 +3846,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3880,7 +3870,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "anyhow", "bytemuck", @@ -3906,7 +3896,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "accesskit", "accesskit_windows", @@ -4219,7 +4209,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "anyhow", "async-compression", @@ -4239,7 +4229,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "rustls", "rustls-platform-verifier 0.5.3", @@ -5215,7 +5205,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "anyhow", "bindgen", @@ -6053,15 +6043,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "object" -version = "0.39.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -6262,7 +6243,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "collections", "serde", @@ -6307,7 +6288,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.7", + "rand 0.8.8", ] [[package]] @@ -6639,15 +6620,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "psm" -version = "0.1.32" -source = "git+https://github.com/rust-lang/stacker?branch=master#d3ac2df79a409c681329006e9b31c96ab9c5fcae" -dependencies = [ - "ar_archive_writer", - "cc", -] - [[package]] name = "pulp" version = "0.22.3" @@ -6787,9 +6759,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -7059,7 +7031,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "derive_refineable", ] @@ -7142,7 +7114,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "anyhow", "bytes", @@ -7554,7 +7526,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "async-task", "backtrace", @@ -7653,7 +7625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ "bitcoin_hashes 0.14.101", - "rand 0.8.7", + "rand 0.8.8", "secp256k1-sys", ] @@ -8165,40 +8137,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "stacker" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" -dependencies = [ - "cc", - "cfg-if", - "libc", - "psm", - "windows-sys 0.61.2", -] - -[[package]] -name = "stacksafe" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95f9c34983ac74195c710c473db6fdf1085f64a47dbaa0090d1bea03be70da66" -dependencies = [ - "stacker", - "stacksafe-macro", -] - -[[package]] -name = "stacksafe-macro" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6feeae42a2d6b0dcb8aeb2f08d9e48cdac600239cf8a20fc59f9e252e86bdfe1" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", -] - [[package]] name = "static_assertions" version = "1.1.0" @@ -8281,7 +8219,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "heapless 0.9.3", "log", @@ -9654,7 +9592,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "perf", "quote", @@ -11307,7 +11245,7 @@ dependencies = [ "core-graphics-helmer-fork", "log", "objc", - "rand 0.8.7", + "rand 0.8.8", "screencapturekit", "screencapturekit-sys", "sysinfo", @@ -11452,7 +11390,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "anyhow", "chrono", @@ -11469,7 +11407,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" dependencies = [ "tracing", "tracing-subscriber", @@ -11480,7 +11418,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" +source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd" [[package]] name = "zune-core" @@ -11562,3 +11500,8 @@ dependencies = [ "syn 3.0.4", "winnow 1.0.4", ] + +[[patch.unused]] +name = "psm" +version = "0.1.32" +source = "git+https://github.com/rust-lang/stacker?branch=master#d3ac2df79a409c681329006e9b31c96ab9c5fcae" diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 713dbf1..c08dd2c 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -126,6 +126,12 @@ pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> { } fn clone(url: &str, path: &Path) -> Result { + // GRASP servers announce `grasp:////` clone URLs; + // the transport is git smart HTTP, so rewrite the scheme for gix. + let url = url + .strip_prefix("grasp://") + .map(|rest| format!("https://{rest}")) + .unwrap_or_else(|| url.to_owned()); let url = gix::url::parse(url).context("invalid clone URL")?; let mut prepare = gix::prepare_clone(url, path)?; @@ -135,6 +141,111 @@ fn clone(url: &str, path: &Path) -> Result { Ok(repo) } +/// Create a new repository at `path`: initialize a `main` branch, write a +/// `README.md` derived from `name`/`description`, and create the initial +/// commit. Returns the initial commit id. +/// +/// Uses the git CLI (like [`apply_patch`]) because it handles the plumbing +/// (index writes, ref updates, default branch selection) natively. +pub fn init_repository(path: &Path, name: &str, description: &str) -> Result { + std::fs::create_dir_all(path) + .with_context(|| format!("failed to create {}", path.display()))?; + + git_in(path, &["init", "-b", "main"])?; + + let readme = if description.trim().is_empty() { + format!("# {name}\n") + } else { + format!("# {name}\n\n{description}\n") + }; + std::fs::write(path.join("README.md"), readme).context("failed to write README.md")?; + + git_in(path, &["add", "README.md"])?; + // Identity and signing are passed per-invocation so the repository is + // commitable without a global git identity or signing setup. + git_in( + path, + &[ + "-c", + "user.name=Signed", + "-c", + "user.email=signed@localhost", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "Initial commit", + ], + )?; + + let commit = git_in(path, &["rev-parse", "HEAD"])?; + if commit.len() != 40 { + bail!("unexpected initial commit id: {commit}"); + } + Ok(commit) +} + +/// Push the `main` branch of the repository at `repo_path` to a grasp +/// 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<()> { + 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/main:refs/heads/main"]) + .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(()) +} + +/// Add `origin` pointing at `url` when the repository has no remote yet. +/// No-op if `origin` already exists. +pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> { + // `git remote get-url origin` exits non-zero when the remote is absent. + if git_in(repo_path, &["remote", "get-url", "origin"]).is_ok() { + return Ok(()); + } + git_in(repo_path, &["remote", "add", "origin", url])?; + Ok(()) +} + +/// Run a git command in `dir`, returning trimmed stdout. The terminal prompt +/// is disabled so a credential request fails instead of hanging. +fn git_in(dir: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .env("GIT_TERMINAL_PROMPT", "0") + .stderr(Stdio::piped()) + .output() + .context("failed to spawn `git`")?; + + if !output.status.success() { + bail!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) +} + /// Map an untrusted repository id to a safe single path component. /// /// Replaces everything outside `[A-Za-z0-9._-]` with `_`, and rejects the @@ -1530,6 +1641,70 @@ mod tests { git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]); } + #[test] + fn init_repository_creates_main_branch_and_readme() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("my-repo"); + + let commit = init_repository(&path, "My Repo", "Does things.\n\nCool.").expect("init"); + assert_eq!(commit.len(), 40); + + let repo = gix::open(&path).expect("open"); + let workdir = repo.workdir().expect("workdir"); + + assert_eq!( + std::fs::read_to_string(workdir.join("README.md")).expect("read"), + "# My Repo\n\nDoes things.\n\nCool.\n" + ); + + let branch = current_branch(&repo).expect("branch").expect("on a branch"); + assert_eq!(branch, "main"); + // [`FileCommit`] carries the short id; the full id is 40 chars. + assert_eq!( + head_commit(&repo).expect("head").expect("commit").id, + &commit[..7] + ); + + let state = repo_ref_state(&repo).expect("refs"); + assert_eq!(state.head.as_deref(), Some("main")); + assert_eq!(state.refs, vec![("refs/heads/main".to_owned(), commit)]); + } + + #[test] + fn init_repository_omits_description_when_empty() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("my-repo"); + + init_repository(&path, "My Repo", " ").expect("init"); + let repo = gix::open(&path).expect("open"); + let workdir = repo.workdir().expect("workdir"); + + assert_eq!( + std::fs::read_to_string(workdir.join("README.md")).expect("read"), + "# My Repo\n" + ); + } + + #[test] + fn ensure_origin_adds_remote_only_once() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("my-repo"); + init_repository(&path, "My Repo", "").expect("init"); + + ensure_origin(&path, "https://gitnostr.com/npub1test/repo.git").expect("add"); + assert_eq!( + git_in(&path, &["remote", "get-url", "origin"]).expect("url"), + "https://gitnostr.com/npub1test/repo.git" + ); + + // A second call must not override the existing remote. + ensure_origin(&path, "https://other.example/repo.git").expect("keep"); + assert_eq!( + git_in(&path, &["remote", "get-url", "origin"]).expect("url"), + "https://gitnostr.com/npub1test/repo.git" + ); + } + /// Run a git command in `dir`, asserting success. fn git_run(dir: &Path, args: &[&str]) { let status = Command::new("git") diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 88c556d..7fee0ef 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -1,15 +1,19 @@ use std::collections::HashMap; +use std::str::FromStr; use std::time::Duration; -use anyhow::{Error, anyhow}; +use anyhow::{Error, anyhow, bail}; +use bitcoin_hashes::sha1::Hash as Sha1Hash; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task}; use nostr::event::IntoEventBuilder; use nostr_connect::prelude::*; use nostr_sdk::client::SyncSummary; use nostr_sdk::prelude::*; -use signed_core::filters; +use signed_core::{Announcement, build_state, filters, repo_addr}; use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update}; +use crate::git_store::GitStore; + /// Keyring entry holding the user credential (`nsec1...` or `bunker://...` /// with an embedded `?master=` NIP-46 session key). pub const USER_KEYRING: &str = "Signed Safe Storage"; @@ -375,6 +379,180 @@ impl Backend { }) } + /// Create a new repository: initialize a local clone with a `main` + /// branch and a `README.md`, publish the NIP-34 announcement and the + /// repository state to the grasp relays, then push the initial commit + /// to each grasp server. + /// + /// The events must reach the grasp servers *before* the push: GRASP + /// servers hold the signed state event in "purgatory" and only accept + /// a push for a not-yet-existing repository while that authorization is + /// pending (it expires after 30 minutes), like gitworkshop and ngit. + /// + /// The git work (init, commit, push) runs on background threads. The + /// returned task yields the published announcement on success, so + /// callers can open the new repository right away. The announcement's + /// `relays` tag carries the grasp servers, which are also added to the + /// relay pool so the published events reach them. + pub fn create_repository( + &mut self, + name: &str, + description: &str, + grasp_servers: Vec, + cx: &mut Context, + ) -> Task> { + let name = name.trim().to_owned(); + let description = description.trim().to_owned(); + + if name.is_empty() { + return Task::ready(Err(anyhow!("Repository name is required"))); + } + if grasp_servers.is_empty() { + return Task::ready(Err(anyhow!("Add at least one grasp server"))); + } + let Some(public_key) = self.current_user else { + return Task::ready(Err(anyhow!("Sign in to create a repository"))); + }; + + // The repository identifier is derived from the name, like ngit and + // gitworkshop: spaces become hyphens, other non-alphanumeric + // characters (except `/`) become hyphens, case is preserved. + 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 addr = repo_addr(public_key, repo_id.clone()); + let cache = GitStore::global(cx).cache().clone(); + let path = cache.repo_path(&addr); + let owner = public_key + .to_bech32() + .unwrap_or_else(|_| public_key.to_hex()); + let servers = grasp_servers.clone(); + + cx.spawn(async move |this, cx| { + // 1. Initialize the local clone (main branch + README + initial + // commit) on a background thread. + let work = cx.background_spawn({ + let path = path.clone(); + let name = name.clone(); + let description = description.clone(); + let owner = owner.clone(); + let repo_id = repo_id.clone(); + let servers = servers.clone(); + + async move { + let parent = path + .parent() + .ok_or_else(|| anyhow!("invalid repository path"))?; + std::fs::create_dir_all(parent)?; + let commit = signed_git::init_repository(&path, &name, &description)?; + + // Point `origin` at the first grasp server so later + // fetches (and pushes) have a target, like ngit. + if let Some(base) = servers.first().and_then(grasp_base_url) { + let url = format!("{base}/{owner}/{repo_id}.git"); + signed_git::ensure_origin(&path, &url).ok(); + } + + Ok::<_, Error>(commit) + } + }); + + let commit = work.await?; + let commit_sha = + Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid initial commit id"))?; + + // 2. Ensure the grasp servers are in the relay pool; the nostr + // client queues events until each relay is connected. + this.update(cx, |this, cx| { + let urls: Vec = servers.iter().map(ToString::to_string).collect(); + this.add_relays(urls, cx); + })?; + + // 3. Publish the announcement, then the state event, to the + // grasp relays. The state event is the push authorization + // ("purgatory"), so it must be accepted before step 4. + let announcement = GitRepositoryAnnouncement { + id: repo_id.clone(), + name: Some(name.clone()), + description: (!description.is_empty()).then_some(description.clone()), + web: Vec::new(), + clone: servers + .iter() + .filter_map(|relay| grasp_clone_url(relay, &owner, &repo_id)) + .collect(), + relays: servers.clone(), + euc: Some(commit_sha), + maintainers: Vec::new(), + }; + + let event = this + .update(cx, |this, cx| { + this.send(announcement.into_event_builder(), cx) + })? + .await?; + + this.update(cx, |this, cx| { + let builder = build_state( + &repo_id, + &[("refs/heads/main".to_owned(), commit)], + Some("main"), + ); + this.send(builder, cx) + })? + .await?; + + // 4. Push the initial commit to every grasp server. A server + // that fails to accept the push is logged, but the creation + // only fails when no server accepted it. + let push = cx.background_spawn({ + let path = path.clone(); + let owner = owner.clone(); + let repo_id = repo_id.clone(); + let servers = servers.clone(); + + async move { + let mut failures = Vec::new(); + let mut pushed = 0; + for relay in &servers { + let Some(base_url) = grasp_base_url(relay) else { + failures.push(format!("{relay}: no domain")); + continue; + }; + match signed_git::push_main(&path, &base_url, &owner, &repo_id) { + Ok(()) => pushed += 1, + Err(e) => failures.push(format!("{relay}: {e}")), + } + } + + if pushed == 0 { + bail!( + "could not push the repository to any grasp server: {}", + failures.join("; ") + ); + } + for failure in failures { + log::warn!("grasp push failed: {failure}"); + } + + Ok::<_, Error>(()) + } + }); + push.await?; + + Announcement::from_event(&event) + .ok_or_else(|| anyhow!("failed to parse the published announcement")) + }) + } + /// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on /// the credential's prefix. pub fn login(&mut self, credential: &str, cx: &mut Context) { @@ -954,6 +1132,47 @@ fn with_master_key(uri: &str, keys: &Keys) -> String { format!("{uri}{separator}master={nsec}") } +/// Derive a repository identifier (d-tag) from the repo name, matching ngit +/// and gitworkshop: spaces become hyphens, other non-alphanumeric characters +/// (except `/`) become hyphens, case is preserved. +fn identifier_from_name(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '/' { + c + } else { + '-' + } + }) + .collect() +} + +/// A `https://` (or `http://` for `ws://` grasp servers, like +/// ngit) base URL for a grasp server. The repository then lives at +/// `{base}/{npub}/{repo-id}.git`. +fn grasp_base_url(relay: &RelayUrl) -> Option { + // `domain()` drops the port; parse the full URL to keep it (local dev + // grasp servers commonly run on a custom port). + let parsed = Url::parse(relay.as_str()).ok()?; + let host = parsed.host_str()?; + let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default(); + // `ws://` grasp servers (e.g. local dev relays) speak plain HTTP; + // everything else is HTTPS, matching ngit. + let scheme = if relay.scheme().is_secure() { + "https" + } else { + "http" + }; + Some(format!("{scheme}://{host}{port}")) +} + +/// The GRASP clone URL of a repository on a grasp server, matching the +/// format ngit announces: `https:////.git`. +fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option { + let base = grasp_base_url(relay)?; + Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok() +} + /// Split a stored bunker credential into the plain URI and the session key. /// Credentials without an embedded key (legacy) get a fresh one. fn extract_master_key(credential: &str) -> (&str, Keys) { @@ -967,3 +1186,42 @@ fn extract_master_key(credential: &str) -> (&str, Keys) { None => (credential, Keys::generate()), } } + +#[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-"); + } + + #[test] + fn grasp_base_url_maps_schemes_like_ngit() { + let wss = RelayUrl::parse("wss://relay.ngit.dev").expect("url"); + assert_eq!( + grasp_base_url(&wss).as_deref(), + Some("https://relay.ngit.dev") + ); + + let ws = RelayUrl::parse("ws://localhost:8080").expect("url"); + assert_eq!( + grasp_base_url(&ws).as_deref(), + Some("http://localhost:8080") + ); + } + + #[test] + fn grasp_clone_url_matches_ngit_format() { + let relay = RelayUrl::parse("wss://gitnostr.com").expect("url"); + let url = grasp_clone_url(&relay, "npub1test", "my-repo").expect("url"); + assert_eq!( + url.to_string(), + "https://gitnostr.com/npub1test/my-repo.git" + ); + } +} diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 04a39f9..aca2e9f 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -1,3 +1,4 @@ +mod pixel_avatar; mod views; mod workspace; diff --git a/crates/workspace/src/pixel_avatar.rs b/crates/workspace/src/pixel_avatar.rs new file mode 100644 index 0000000..a996c16 --- /dev/null +++ b/crates/workspace/src/pixel_avatar.rs @@ -0,0 +1,204 @@ +use gpui::prelude::*; +use gpui::{App, Pixels, Window, div, px}; +use gpui_component::{ActiveTheme, Colorize}; + +/// Number of rows and columns in the pixel grid. +const GRID_SIZE: usize = 8; +/// Probability that a cell in the left half is filled. +const FILL_PROBABILITY: f32 = 0.42; +/// Probability that a filled cell uses the accent shade instead of the main color. +const ACCENT_PROBABILITY: f32 = 0.25; +/// Minimum number of filled left-half cells, so a sparse roll still yields a +/// recognizable shape (each left-half cell is mirrored to a right-half one). +const MIN_FILLED: usize = 5; + +/// A deterministic, offline "pixel art" avatar: an 8×8 grid with horizontal +/// mirror symmetry, seeded from a stable string such as the repository id and +/// owner public key. The same seed always renders the same avatar. +#[derive(IntoElement)] +pub(crate) struct PixelAvatar { + seed: u64, + size: Pixels, +} + +impl PixelAvatar { + /// Create an avatar seeded from `seed`. The seed should be a stable string + /// unique to the entity the avatar represents. + pub(crate) fn new(seed: impl AsRef) -> Self { + Self { + seed: fnv1a(seed.as_ref().as_bytes()), + size: px(16.), + } + } +} + +impl RenderOnce for PixelAvatar { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let pattern = pattern(self.seed); + + let hue = self.seed as f32 / u64::MAX as f32; + let main = theme.blue.hue(hue); + let shade = if theme.is_dark() { + main.lightness((main.l * 1.6).min(0.95)) + } else { + main.lightness((main.l * 0.45).max(0.18)) + }; + + let mut cells = Vec::new(); + for row in 0..GRID_SIZE { + for col in 0..GRID_SIZE { + let value = pattern[row * GRID_SIZE + col]; + if value != 0 { + let color = if value == 2 { shade } else { main }; + cells.push( + div() + .row_start(row as i16 + 1) + .row_end(row as i16 + 2) + .col_start(col as i16 + 1) + .col_end(col as i16 + 2) + .bg(color), + ); + } + } + } + + div() + .grid() + .grid_cols(GRID_SIZE as u16) + .grid_rows(GRID_SIZE as u16) + .size(self.size) + .flex_shrink_0() + .overflow_hidden() + .bg(main.opacity(0.16)) + .children(cells) + } +} + +/// Generate the 8×8 cell pattern for `seed`. Cells are `0` (empty), `1` +/// (main color) or `2` (accent shade); the right half mirrors the left half. +fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] { + let mut rng = PixelRng::new(seed); + let mut pattern = [0u8; GRID_SIZE * GRID_SIZE]; + let mut filled = 0usize; + + for row in 0..GRID_SIZE { + for col in 0..GRID_SIZE / 2 { + if rng.chance(FILL_PROBABILITY) { + let accent = rng.chance(ACCENT_PROBABILITY); + set_cell(&mut pattern, row, col, if accent { 2 } else { 1 }); + filled += 1; + } + } + } + + // Sparse rolls can come out nearly empty; top the pattern up to the + // minimum fill, scanning from a seeded starting cell. + if filled < MIN_FILLED { + let half = GRID_SIZE * GRID_SIZE / 2; + let start = (rng.next() % half as u64) as usize; + for offset in 0..half { + if filled >= MIN_FILLED { + break; + } + let ix = (start + offset) % half; + let row = ix / (GRID_SIZE / 2); + let col = ix % (GRID_SIZE / 2); + if pattern[row * GRID_SIZE + col] == 0 { + set_cell(&mut pattern, row, col, 1); + filled += 1; + } + } + } + + pattern +} + +/// Fill `cell (row, col)` and its horizontal mirror. +fn set_cell(pattern: &mut [u8; GRID_SIZE * GRID_SIZE], row: usize, col: usize, value: u8) { + pattern[row * GRID_SIZE + col] = value; + pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)] = value; +} + +/// FNV-1a 64-bit hash; stable across platforms and runs. +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325u64; + for &byte in bytes { + hash ^= byte as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +/// Tiny xorshift64* PRNG for deriving the pattern from the seed. +struct PixelRng(u64); + +impl PixelRng { + fn new(seed: u64) -> Self { + Self(seed.max(1)) + } + + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(0x2545_f491_4f6c_dd1d) + } + + fn chance(&mut self, probability: f32) -> bool { + self.next() as f32 / (u64::MAX as f32) < probability + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn count_filled(pattern: &[u8; GRID_SIZE * GRID_SIZE]) -> usize { + pattern.iter().filter(|&&cell| cell != 0).count() + } + + #[test] + fn pattern_is_mirror_symmetric() { + for seed in 0..50 { + let pattern = pattern(seed); + for row in 0..GRID_SIZE { + for col in 0..GRID_SIZE { + assert_eq!( + pattern[row * GRID_SIZE + col], + pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)], + "asymmetric pattern for seed {seed} at ({row}, {col})" + ); + } + } + } + } + + #[test] + fn pattern_has_minimum_fill() { + for seed in 0..50 { + let pattern = pattern(seed); + assert!( + count_filled(&pattern) >= MIN_FILLED * 2, + "pattern too sparse for seed {seed}" + ); + } + } + + #[test] + fn pattern_is_deterministic() { + for seed in [0, 1, 42, u64::MAX] { + assert_eq!(pattern(seed), pattern(seed)); + } + assert_ne!(pattern(42), pattern(43)); + } + + #[test] + fn fnv1a_is_stable_and_distinct() { + assert_eq!(fnv1a(b""), 0xcbf2_9ce4_8422_2325); + assert_eq!(fnv1a(b"repo"), fnv1a(b"repo")); + assert_ne!(fnv1a(b"repo:a"), fnv1a(b"repo:b")); + } +} diff --git a/crates/workspace/src/views/sidebar/create_repo_dialog.rs b/crates/workspace/src/views/sidebar/create_repo_dialog.rs new file mode 100644 index 0000000..5e760a5 --- /dev/null +++ b/crates/workspace/src/views/sidebar/create_repo_dialog.rs @@ -0,0 +1,393 @@ +use dock::{DockArea, DockPlacement, panel_handle}; +use gpui::prelude::*; +use gpui::{App, Entity, SharedString, WeakEntity, Window, div, px}; +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::tag::Tag; +use gpui_component::{ActiveTheme, Disableable, IconName, Sizable, WindowExt, h_flex, v_flex}; +use nostr::prelude::*; +use signed_core::{Announcement, filters}; +use signed_state::Backend; + +use super::super::RepoDetailView; + +/// Grasp servers offered when the user hasn't published a grasp list (kind `10317`) yet. +const DEFAULT_GRASP_SERVERS: [&str; 3] = [ + "wss://relay.ngit.dev", + "wss://gitnostr.com", + "wss://git.shakespeare.diy", +]; + +/// Shared state for the Create Repository dialog, so async results can be rendered. +#[derive(Default)] +pub struct CreateRepoState { + pub busy: bool, + /// The user's grasp list (kind `10317`) is being loaded. + pub loading_servers: bool, + pub error: Option, + pub grasp_servers: Vec, +} + +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, + grasp_servers: DEFAULT_GRASP_SERVERS + .iter() + .filter_map(|url| RelayUrl::parse(url).ok()) + .collect(), + ..Default::default() + } + } +} + +/// Open the Create Repository dialog. +/// +/// The dialog loads the user's default grasp servers (kind `10317` grasp +/// list) and falls back to [`DEFAULT_GRASP_SERVERS`] when none are set. +/// On success the dialog closes and the new repository opens in the dock. +pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) { + let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Repository name")); + let desc_input = cx.new(|cx| { + 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(|_| CreateRepoState::new_default()); + + load_user_grasp_servers(state.clone(), window, cx); + + window.open_dialog(cx, move |dialog, _window, _cx| { + let name_input = name_input.clone(); + let desc_input = desc_input.clone(); + let relay_input = relay_input.clone(); + let state = state.clone(); + let dock_area = dock_area.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(); + let servers = state.read(cx).grasp_servers.clone(); + let loading_servers = state.read(cx).loading_servers; + + content + .child( + DialogHeader::new() + .child(DialogTitle::new().child("Create repository")) + .child( + DialogDescription::new() + .child("Publish a new repository to your grasp servers."), + ), + ) + .child( + v_form() + .child( + field() + .label("Repository name") + .description("Max 100 characters") + .required(true) + .child(Input::new(&name_input)), + ) + .child( + field() + .label("Description") + .description("Optional") + .child(Textarea::new(&desc_input)), + ) + .child( + field() + .label("Grasp servers") + .description( + "Where the repository is hosted; the initial push goes to each server", + ) + .child( + v_flex() + .gap_1() + .children( + servers + .iter() + .enumerate() + .map(|(ix, relay)| { + render_server_row(ix, relay, state.clone()) + }), + ) + .child( + h_flex().gap_1().items_center().child( + div().flex_1().child(Input::new(&relay_input)), + ) + .child( + Button::new("add-relay") + .icon(IconName::Plus) + .small() + .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| { + div().text_sm().text_color(cx.theme().danger).child(message) + })) + .child( + DialogFooter::new().justify_end().child( + Button::new("create") + .primary() + .label("Create repository") + .tooltip("Create repository") + .loading(busy) + .disabled(busy) + .on_click({ + let name_input = name_input.clone(); + let desc_input = desc_input.clone(); + let state = state.clone(); + let dock_area = dock_area.clone(); + + move |_ev, window, cx| { + create_repository( + name_input.clone(), + desc_input.clone(), + state.clone(), + dock_area.clone(), + window, + cx, + ); + } + }), + ), + ) + }) + }); +} + +/// A grasp server row: the host as a tag plus a remove button. +fn render_server_row( + ix: usize, + relay: &RelayUrl, + state: Entity, +) -> impl IntoElement { + h_flex() + .w_full() + .gap_1() + .items_center() + .child(Tag::new().flex_1().child(display_server(relay))) + .child( + Button::new(format!("remove-relay:{ix}")) + .icon(IconName::Close) + .small() + .ghost() + .flex_shrink_0() + .tooltip("Remove") + .on_click({ + let state = state.clone(); + move |_ev, _window, cx| { + state.update(cx, |state, _| { + state.grasp_servers.remove(ix); + }); + } + }), + ) +} + +/// The bare host of a grasp server (defaults are entered without a scheme). +fn display_server(relay: &RelayUrl) -> SharedString { + relay + .domain() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(relay.to_string())) +} + +/// Parse the relay input (accepting a bare host) and append it to the list. +fn add_relay( + state: &Entity, + input: &Entity, + window: &mut Window, + cx: &mut App, +) { + let value = input.read(cx).value().trim().to_owned(); + if value.is_empty() { + return; + } + + let normalized = if value.contains("://") { + value.clone() + } else { + format!("wss://{value}") + }; + + match RelayUrl::parse(&normalized) { + Ok(relay) => { + state.update(cx, |state, _| { + state.error = None; + if !state.grasp_servers.contains(&relay) { + state.grasp_servers.push(relay); + } + }); + input.update(cx, |input, cx| input.set_value("", window, cx)); + } + Err(_) => { + state.update(cx, |state, _| { + state.error = Some(format!("Invalid grasp server URL: {value}").into()); + }); + } + } +} + +/// Run the create-repository flow; closes the dialog and opens the new repository on success. +fn create_repository( + name_input: Entity, + desc_input: Entity, + state: Entity, + dock_area: WeakEntity, + window: &mut Window, + cx: &mut App, +) { + let name = name_input.read(cx).value().trim().to_owned(); + let description = desc_input.read(cx).value().trim().to_owned(); + let servers = state.read(cx).grasp_servers.clone(); + + 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.create_repository(&name, &description, servers, cx) + }); + let handle = window.window_handle(); + let state = state.clone(); + let dock_area = dock_area.clone(); + + cx.spawn(async move |cx| match task.await { + Ok(announcement) => { + cx.update_window(handle, |_, window, cx| { + window.close_dialog(cx); + open_repo(dock_area, announcement, window, 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(); +} + +/// Open the newly created repository in the dock's center. +fn open_repo( + dock_area: WeakEntity, + announcement: Announcement, + window: &mut Window, + cx: &mut App, +) { + let Some(dock_area) = dock_area.upgrade() else { + return; + }; + + let panel = cx.new(|cx| RepoDetailView::new(dock_area.downgrade(), announcement, window, cx)); + + dock_area.update(cx, |dock_area, 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, window: &mut Window, cx: &mut App) { + let backend = Backend::global(cx); + let Some(user) = backend.read(cx).current_user() else { + state.update(cx, |state, _| state.loading_servers = false); + return; + }; + let client = backend.read(cx).client(); + let handle = window.window_handle(); + + cx.spawn(async move |cx| { + let result: anyhow::Result> = async { + let mut events: Vec = client + .database() + .query(filters::grasp_list(user)) + .await? + .into_iter() + .collect(); + events.sort_by_key(|event| event.created_at); + + Ok(events + .into_iter() + .last() + .map(|event| { + event + .tags + .iter() + .filter(|tag| tag.kind() == "g") + .filter_map(|tag| tag.content()) + .filter_map(|url| RelayUrl::parse(url).ok()) + .collect() + }) + .unwrap_or_default()) + } + .await; + + let _ = cx.update_window(handle, |_, _window, cx| { + state.update(cx, |state, _| { + state.loading_servers = false; + if let Ok(servers) = result + && !servers.is_empty() + { + state.grasp_servers = servers; + } + }); + }); + }) + .detach(); +} diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 14e73cf..bc0d96a 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -1,3 +1,5 @@ +use std::ops::Range; + use assets::CustomIconName; use dock::{ BasePanel, DockArea, DockPlacement, Panel, PanelEvent, TAB_BAR_HEIGHT, panel_handle, @@ -5,18 +7,21 @@ use dock::{ }; use gpui::prelude::*; use gpui::{ - App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, Render, - SharedString, StyleRefinement, Subscription, WeakEntity, Window, div, px, + AnyElement, App, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, + Render, SharedString, StyleRefinement, Subscription, WeakEntity, Window, div, px, uniform_list, }; use gpui_component::avatar::Avatar; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::InputState; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; -use signed_state::{Backend, BackendEvent, Profile, ProfileStore}; +use signed_core::Announcement; +use signed_state::{Backend, BackendEvent, Profile, ProfileStore, RepoListStore}; -use super::RepoListView; +use super::{RepoDetailView, RepoListView}; use crate::image_cache::{MAX_IMAGES, image_cache}; +use crate::pixel_avatar::PixelAvatar; +mod create_repo_dialog; mod import_dialog; mod onboarding_dialog; pub(crate) mod passphrase_dialog; @@ -29,6 +34,11 @@ pub struct SidebarPanel { focus_handle: FocusHandle, dock_area: WeakEntity, explore: Option>, + /// Repositories announced by the current user, listed under + /// "All Repositories". Recreated when the signer changes. + my_repos: Option>, + /// Observes the current user's repo store so the list re-renders. + my_repos_subscription: Option, logged_in: bool, _subscription: Subscription, } @@ -42,21 +52,44 @@ impl SidebarPanel { match event { BackendEvent::SignerChanged => { this.logged_in = backend.read(cx).current_user().is_some(); + this.refresh_my_repos(cx); } BackendEvent::SignerRequired => { this.logged_in = false; + this.my_repos = None; + this.my_repos_subscription = None; } _ => return, } cx.notify(); }); - Self { + let mut panel = Self { focus_handle: cx.focus_handle(), dock_area, explore: None, + my_repos: None, + my_repos_subscription: None, logged_in, _subscription: subscription, + }; + + if logged_in { + panel.refresh_my_repos(cx); + } + + panel + } + + /// (Re)create the store listing the current user's repositories. + fn refresh_my_repos(&mut self, cx: &mut Context) { + self.my_repos_subscription = None; + + let author = Backend::global(cx).read(cx).current_user(); + self.my_repos = author.map(|author| cx.new(|cx| RepoListStore::new(Some(author), cx))); + + if let Some(store) = self.my_repos.as_ref() { + self.my_repos_subscription = Some(cx.observe(store, |_, _, cx| cx.notify())); } } @@ -98,6 +131,126 @@ impl SidebarPanel { onboarding_dialog::open(name_input, pass_input, repass_input, state, window, cx); } + /// Show the Create Repository dialog. + fn open_create_repo(&mut self, window: &mut Window, cx: &mut Context) { + create_repo_dialog::open(self.dock_area.clone(), window, cx); + } + + /// Open a repository's detail view in the dock's center. + fn open_repo( + &mut self, + announcement: &Announcement, + window: &mut Window, + cx: &mut Context, + ) { + let detail = cx.new(|cx| { + RepoDetailView::new(self.dock_area.clone(), announcement.clone(), window, cx) + }); + + let _ = self.dock_area.update(cx, |dock_area, cx| { + dock_area.add_panel_view( + panel_handle(detail), + DockPlacement::Center, + None, + window, + cx, + ); + }); + } + + /// The "All Repositories" section: header with the create button and + /// the current user's repositories below it, lazily rendered through a + /// [`uniform_list`]. + fn render_my_repos(&self, cx: &mut Context) -> impl IntoElement { + let store = self.my_repos.as_ref(); + + v_flex() + .px_2() + .flex_1() + .min_h_0() + .w_full() + .child( + h_flex() + .h_10() + .w_full() + .flex_shrink_0() + .justify_between() + .items_center() + .child( + h_flex() + .px_2() + .gap_2() + .text_color(cx.theme().muted_foreground) + .child(Icon::new(CustomIconName::Filter).small()) + .child(div().text_xs().font_semibold().child("All Repositories")), + ) + .child( + Button::new("add") + .icon(IconName::Plus) + .small() + .ghost() + .on_click(cx.listener(|this, _ev, window, cx| { + this.open_create_repo(window, cx); + })), + ), + ) + .when_some(store, |builder, store| { + let announcements = store.read(cx).announcements.clone(); + + if announcements.is_empty() { + builder.child( + div() + .flex_1() + .px_2() + .py_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("No repositories yet"), + ) + } else { + builder.child( + uniform_list( + "my-repos-list", + announcements.len(), + cx.processor(move |this, range: Range, _window, cx| { + range + .map(|ix| { + this.render_repo_row(&announcements[ix], cx) + .into_any_element() + }) + .collect() + }), + ) + .flex_1() + .min_h_0(), + ) + } + }) + } + + /// One repository row in the sidebar, styled like the nav items: a + /// deterministic pixel avatar and the repo name. + fn render_repo_row( + &self, + announcement: &Announcement, + cx: &mut Context, + ) -> impl IntoElement { + let name = announcement + .name + .clone() + .unwrap_or_else(|| SharedString::from(announcement.id.clone())); + let avatar = PixelAvatar::new(format!( + "{}:{}", + announcement.owner.to_hex(), + announcement.id + )); + let announcement = announcement.clone(); + + NavItem::new(format!("my-repo:{}", announcement.id), name, avatar).on_click( + cx.listener(move |this, _ev, window, cx| this.open_repo(&announcement, window, cx)), + ) + } + /// Show the Import Identity dialog. fn open_import(&mut self, window: &mut Window, cx: &mut Context) { import_dialog::open(window, cx); @@ -214,8 +367,9 @@ impl Render for SidebarPanel { .bg(cx.theme().sidebar) .text_color(cx.theme().sidebar_foreground) .child( - div() + v_flex() .flex_1() + .min_h_0() .when_some(profile.as_ref(), |this, profile| { this.child(self.render_user(profile, window, cx)) }) @@ -225,41 +379,34 @@ impl Render for SidebarPanel { .gap_1() .items_start() .justify_start() - .child(NavItem::new("inbox", "Inbox", IconName::Inbox).on_click( - cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), - )) - .child(NavItem::new("explore", "Browse", IconName::Globe).on_click( - cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), - )) - .child(NavItem::new("search", "Search", IconName::Search).on_click( - cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), - )) .child( - v_flex().w_full().child( - h_flex() - .h_10() - .w_full() - .justify_between() - .items_center() - .child( - h_flex() - .px_2() - .gap_2() - .text_color(cx.theme().muted_foreground) - .child(Icon::new(CustomIconName::Filter).small()) - .child( - div() - .text_xs() - .font_semibold() - .child("All Repositories"), - ), - ) - .child( - Button::new("add").icon(IconName::Plus).small().ghost(), - ), - ), + NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small()) + .on_click(cx.listener(|this, _ev, window, cx| { + this.open_explore(window, cx) + })), + ) + .child( + NavItem::new( + "explore", + "Browse", + Icon::new(IconName::Globe).small(), + ) + .on_click(cx.listener( + |this, _ev, window, cx| this.open_explore(window, cx), + )), + ) + .child( + NavItem::new( + "search", + "Search", + Icon::new(IconName::Search).small(), + ) + .on_click(cx.listener( + |this, _ev, window, cx| this.open_explore(window, cx), + )), ), - ), + ) + .child(self.render_my_repos(cx)), ) .child( v_flex() @@ -268,11 +415,18 @@ impl Render for SidebarPanel { .gap_1() .items_start() .justify_start() - .child(NavItem::new("guide", "Guide", IconName::Info).on_click( - cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), - )) .child( - NavItem::new("settings", "Settings", IconName::Settings).on_click( + NavItem::new("guide", "Guide", Icon::new(IconName::Info).small()).on_click( + cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), + ), + ) + .child( + NavItem::new( + "settings", + "Settings", + Icon::new(IconName::Settings).small(), + ) + .on_click( cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), ), ), @@ -280,27 +434,29 @@ impl Render for SidebarPanel { } } -/// A single navigation entry in the sidebar: an icon and label with a hover -/// highlight and an optional click handler. +/// A single navigation entry in the sidebar: an arbitrary leading element +/// (an icon, avatar, ...) and a text label with a hover highlight and an +/// optional click handler. #[allow(clippy::type_complexity)] #[derive(IntoElement)] struct NavItem { id: ElementId, style: StyleRefinement, - icon: IconName, + icon: AnyElement, label: SharedString, on_click: Option>, } impl NavItem { - fn new(id: I, label: L, icon: IconName) -> Self + fn new(id: I, label: L, icon: N) -> Self where I: Into, L: Into, + N: IntoElement, { Self { id: id.into(), - icon, + icon: icon.into_any_element(), label: label.into(), style: StyleRefinement::default(), on_click: None, @@ -323,8 +479,16 @@ impl RenderOnce for NavItem { .w_full() .gap_2() .rounded(cx.theme().radius) - .child(Icon::new(self.icon).small()) - .child(div().text_sm().child(self.label)) + .child(self.icon) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .whitespace_nowrap() + .text_ellipsis() + .child(self.label), + ) .hover(|this| this.bg(cx.theme().list_hover)) .when_some(self.on_click, |this, listener| this.on_click(listener)) }