add create repo dialog

This commit is contained in:
2026-08-26 16:49:25 +07:00
parent dacfd49cdf
commit a00ee68258
7 changed files with 1289 additions and 151 deletions
Generated
+42 -99
View File
@@ -218,15 +218,6 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" 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]] [[package]]
name = "arbitrary" name = "arbitrary"
version = "1.4.2" version = "1.4.2"
@@ -653,7 +644,7 @@ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"miniz_oxide", "miniz_oxide",
"object 0.37.3", "object",
"rustc-demangle", "rustc-demangle",
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
@@ -1268,7 +1259,7 @@ dependencies = [
[[package]] [[package]]
name = "collections" name = "collections"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"gpui_util", "gpui_util",
"indexmap", "indexmap",
@@ -1283,9 +1274,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
dependencies = [ dependencies = [
"bytes", "bytes",
"memchr", "memchr",
@@ -1734,7 +1725,7 @@ dependencies = [
[[package]] [[package]]
name = "derive_refineable" name = "derive_refineable"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -3489,7 +3480,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui" name = "gpui"
version = "0.2.2" version = "0.2.2"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"anyhow", "anyhow",
@@ -3539,7 +3530,6 @@ dependencies = [
"slotmap", "slotmap",
"smallvec", "smallvec",
"spin 0.10.1", "spin 0.10.1",
"stacksafe",
"strum", "strum",
"sum_tree", "sum_tree",
"taffy", "taffy",
@@ -3561,7 +3551,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui-base" name = "gpui-base"
version = "0.5.2" version = "0.5.2"
source = "git+https://github.com/longbridge/gpui-component#7885c41663c7a6cc68ad0c99b1ba33550f807ff0" source = "git+https://github.com/longbridge/gpui-component#d5821f270317754f2a311a0bb148ec32cbb0ced4"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"anyhow", "anyhow",
@@ -3593,7 +3583,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui-component" name = "gpui-component"
version = "0.5.2" version = "0.5.2"
source = "git+https://github.com/longbridge/gpui-component#7885c41663c7a6cc68ad0c99b1ba33550f807ff0" source = "git+https://github.com/longbridge/gpui-component#d5821f270317754f2a311a0bb148ec32cbb0ced4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -3673,7 +3663,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui-component-assets" name = "gpui-component-assets"
version = "0.5.1" version = "0.5.1"
source = "git+https://github.com/longbridge/gpui-component#7885c41663c7a6cc68ad0c99b1ba33550f807ff0" source = "git+https://github.com/longbridge/gpui-component#d5821f270317754f2a311a0bb148ec32cbb0ced4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"gpui", "gpui",
@@ -3687,7 +3677,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui-component-macros" name = "gpui-component-macros"
version = "0.5.1" version = "0.5.1"
source = "git+https://github.com/longbridge/gpui-component#7885c41663c7a6cc68ad0c99b1ba33550f807ff0" source = "git+https://github.com/longbridge/gpui-component#d5821f270317754f2a311a0bb148ec32cbb0ced4"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -3697,7 +3687,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_apple" name = "gpui_apple"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"block", "block",
@@ -3720,7 +3710,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_linux" name = "gpui_linux"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"accesskit_unix", "accesskit_unix",
@@ -3766,7 +3756,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_macos" name = "gpui_macos"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"accesskit_macos", "accesskit_macos",
@@ -3812,7 +3802,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_macros" name = "gpui_macros"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
@@ -3823,7 +3813,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_platform" name = "gpui_platform"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"console_error_panic_hook", "console_error_panic_hook",
"gpui", "gpui",
@@ -3836,7 +3826,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_shared_string" name = "gpui_shared_string"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"schemars", "schemars",
"serde", "serde",
@@ -3846,7 +3836,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_util" name = "gpui_util"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"log", "log",
@@ -3856,7 +3846,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_web" name = "gpui_web"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"console_error_panic_hook", "console_error_panic_hook",
@@ -3880,7 +3870,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_wgpu" name = "gpui_wgpu"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytemuck", "bytemuck",
@@ -3906,7 +3896,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_windows" name = "gpui_windows"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"accesskit_windows", "accesskit_windows",
@@ -4219,7 +4209,7 @@ dependencies = [
[[package]] [[package]]
name = "http_client" name = "http_client"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-compression", "async-compression",
@@ -4239,7 +4229,7 @@ dependencies = [
[[package]] [[package]]
name = "http_client_tls" name = "http_client_tls"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"rustls", "rustls",
"rustls-platform-verifier 0.5.3", "rustls-platform-verifier 0.5.3",
@@ -5215,7 +5205,7 @@ dependencies = [
[[package]] [[package]]
name = "media" name = "media"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bindgen", "bindgen",
@@ -6053,15 +6043,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "object"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -6262,7 +6243,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "perf" name = "perf"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"collections", "collections",
"serde", "serde",
@@ -6307,7 +6288,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [ dependencies = [
"phf_shared 0.11.3", "phf_shared 0.11.3",
"rand 0.8.7", "rand 0.8.8",
] ]
[[package]] [[package]]
@@ -6639,15 +6620,6 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "pulp" name = "pulp"
version = "0.22.3" version = "0.22.3"
@@ -6787,9 +6759,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.8.7" version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha 0.3.1", "rand_chacha 0.3.1",
@@ -7059,7 +7031,7 @@ dependencies = [
[[package]] [[package]]
name = "refineable" name = "refineable"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"derive_refineable", "derive_refineable",
] ]
@@ -7142,7 +7114,7 @@ dependencies = [
[[package]] [[package]]
name = "reqwest_client" name = "reqwest_client"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -7554,7 +7526,7 @@ dependencies = [
[[package]] [[package]]
name = "scheduler" name = "scheduler"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"async-task", "async-task",
"backtrace", "backtrace",
@@ -7653,7 +7625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252"
dependencies = [ dependencies = [
"bitcoin_hashes 0.14.101", "bitcoin_hashes 0.14.101",
"rand 0.8.7", "rand 0.8.8",
"secp256k1-sys", "secp256k1-sys",
] ]
@@ -8165,40 +8137,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 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]] [[package]]
name = "static_assertions" name = "static_assertions"
version = "1.1.0" version = "1.1.0"
@@ -8281,7 +8219,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "sum_tree" name = "sum_tree"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"heapless 0.9.3", "heapless 0.9.3",
"log", "log",
@@ -9654,7 +9592,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "util_macros" name = "util_macros"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"perf", "perf",
"quote", "quote",
@@ -11307,7 +11245,7 @@ dependencies = [
"core-graphics-helmer-fork", "core-graphics-helmer-fork",
"log", "log",
"objc", "objc",
"rand 0.8.7", "rand 0.8.8",
"screencapturekit", "screencapturekit",
"screencapturekit-sys", "screencapturekit-sys",
"sysinfo", "sysinfo",
@@ -11452,7 +11390,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]] [[package]]
name = "zlog" name = "zlog"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -11469,7 +11407,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]] [[package]]
name = "ztracing" name = "ztracing"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
dependencies = [ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@@ -11480,7 +11418,7 @@ dependencies = [
[[package]] [[package]]
name = "ztracing_macro" name = "ztracing_macro"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#5631830c564afa89b3aba679f45d9c3345f9460f" source = "git+https://github.com/zed-industries/zed#55007f518bc1d49e6b3291c5eaa1aabf649b36fd"
[[package]] [[package]]
name = "zune-core" name = "zune-core"
@@ -11562,3 +11500,8 @@ dependencies = [
"syn 3.0.4", "syn 3.0.4",
"winnow 1.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"
+175
View File
@@ -126,6 +126,12 @@ pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
} }
fn clone(url: &str, path: &Path) -> Result<gix::Repository> { fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
// GRASP servers announce `grasp://<host>/<owner>/<repo>` 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 url = gix::url::parse(url).context("invalid clone URL")?;
let mut prepare = gix::prepare_clone(url, path)?; let mut prepare = gix::prepare_clone(url, path)?;
@@ -135,6 +141,111 @@ fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
Ok(repo) 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<String> {
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<String> {
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. /// Map an untrusted repository id to a safe single path component.
/// ///
/// Replaces everything outside `[A-Za-z0-9._-]` with `_`, and rejects the /// 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]); 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. /// Run a git command in `dir`, asserting success.
fn git_run(dir: &Path, args: &[&str]) { fn git_run(dir: &Path, args: &[&str]) {
let status = Command::new("git") let status = Command::new("git")
+260 -2
View File
@@ -1,15 +1,19 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::str::FromStr;
use std::time::Duration; 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 gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
use nostr::event::IntoEventBuilder; 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::filters; use signed_core::{Announcement, build_state, filters, repo_addr};
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update}; use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
use crate::git_store::GitStore;
/// Keyring entry holding the user credential (`nsec1...` or `bunker://...` /// Keyring entry holding the user credential (`nsec1...` or `bunker://...`
/// with an embedded `?master=<nsec>` NIP-46 session key). /// with an embedded `?master=<nsec>` NIP-46 session key).
pub const USER_KEYRING: &str = "Signed Safe Storage"; 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<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 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<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: 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 /// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on
/// the credential's prefix. /// the credential's prefix.
pub fn login(&mut self, credential: &str, cx: &mut Context<Self>) { pub fn login(&mut self, credential: &str, cx: &mut Context<Self>) {
@@ -954,6 +1132,47 @@ 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
/// ngit) base URL for a grasp server. The repository then lives at
/// `{base}/{npub}/{repo-id}.git`.
fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
// `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://<host>/<npub>/<repo-id>.git`.
fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url> {
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. /// 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) {
@@ -967,3 +1186,42 @@ fn extract_master_key(credential: &str) -> (&str, Keys) {
None => (credential, Keys::generate()), 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"
);
}
}
+1
View File
@@ -1,3 +1,4 @@
mod pixel_avatar;
mod views; mod views;
mod workspace; mod workspace;
+204
View File
@@ -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<str>) -> 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"));
}
}
@@ -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<SharedString>,
pub grasp_servers: Vec<RelayUrl>,
}
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<DockArea>, 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<CreateRepoState>,
) -> 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<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.
fn create_repository(
name_input: Entity<InputState>,
desc_input: Entity<TextareaState>,
state: Entity<CreateRepoState>,
dock_area: WeakEntity<DockArea>,
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<DockArea>,
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<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();
}
+214 -50
View File
@@ -1,3 +1,5 @@
use std::ops::Range;
use assets::CustomIconName; use assets::CustomIconName;
use dock::{ use dock::{
BasePanel, DockArea, DockPlacement, Panel, PanelEvent, TAB_BAR_HEIGHT, panel_handle, BasePanel, DockArea, DockPlacement, Panel, PanelEvent, TAB_BAR_HEIGHT, panel_handle,
@@ -5,18 +7,21 @@ use dock::{
}; };
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{ use gpui::{
App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, Render, AnyElement, App, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
SharedString, StyleRefinement, Subscription, WeakEntity, Window, div, px, Render, SharedString, StyleRefinement, Subscription, WeakEntity, Window, div, px, uniform_list,
}; };
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::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_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::image_cache::{MAX_IMAGES, image_cache};
use crate::pixel_avatar::PixelAvatar;
mod create_repo_dialog;
mod import_dialog; mod import_dialog;
mod onboarding_dialog; mod onboarding_dialog;
pub(crate) mod passphrase_dialog; pub(crate) mod passphrase_dialog;
@@ -29,6 +34,11 @@ 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>>,
/// Repositories announced by the current user, listed under
/// "All Repositories". Recreated when the signer changes.
my_repos: Option<Entity<RepoListStore>>,
/// Observes the current user's repo store so the list re-renders.
my_repos_subscription: Option<Subscription>,
logged_in: bool, logged_in: bool,
_subscription: Subscription, _subscription: Subscription,
} }
@@ -42,21 +52,44 @@ impl SidebarPanel {
match event { match event {
BackendEvent::SignerChanged => { BackendEvent::SignerChanged => {
this.logged_in = backend.read(cx).current_user().is_some(); this.logged_in = backend.read(cx).current_user().is_some();
this.refresh_my_repos(cx);
} }
BackendEvent::SignerRequired => { BackendEvent::SignerRequired => {
this.logged_in = false; this.logged_in = false;
this.my_repos = None;
this.my_repos_subscription = None;
} }
_ => return, _ => return,
} }
cx.notify(); cx.notify();
}); });
Self { let mut panel = Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
dock_area, dock_area,
explore: None, explore: None,
my_repos: None,
my_repos_subscription: None,
logged_in, logged_in,
_subscription: subscription, _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>) {
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); 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<Self>) {
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<Self>,
) {
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<Self>) -> 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<usize>, _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<Self>,
) -> 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. /// 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);
@@ -214,8 +367,9 @@ impl Render for SidebarPanel {
.bg(cx.theme().sidebar) .bg(cx.theme().sidebar)
.text_color(cx.theme().sidebar_foreground) .text_color(cx.theme().sidebar_foreground)
.child( .child(
div() v_flex()
.flex_1() .flex_1()
.min_h_0()
.when_some(profile.as_ref(), |this, profile| { .when_some(profile.as_ref(), |this, profile| {
this.child(self.render_user(profile, window, cx)) this.child(self.render_user(profile, window, cx))
}) })
@@ -225,41 +379,34 @@ impl Render for SidebarPanel {
.gap_1() .gap_1()
.items_start() .items_start()
.justify_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( .child(
v_flex().w_full().child( NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small())
h_flex() .on_click(cx.listener(|this, _ev, window, cx| {
.h_10() this.open_explore(window, cx)
.w_full() })),
.justify_between() )
.items_center() .child(
.child( NavItem::new(
h_flex() "explore",
.px_2() "Browse",
.gap_2() Icon::new(IconName::Globe).small(),
.text_color(cx.theme().muted_foreground) )
.child(Icon::new(CustomIconName::Filter).small()) .on_click(cx.listener(
.child( |this, _ev, window, cx| this.open_explore(window, cx),
div() )),
.text_xs() )
.font_semibold() .child(
.child("All Repositories"), NavItem::new(
), "search",
) "Search",
.child( Icon::new(IconName::Search).small(),
Button::new("add").icon(IconName::Plus).small().ghost(), )
), .on_click(cx.listener(
), |this, _ev, window, cx| this.open_explore(window, cx),
)),
), ),
), )
.child(self.render_my_repos(cx)),
) )
.child( .child(
v_flex() v_flex()
@@ -268,11 +415,18 @@ impl Render for SidebarPanel {
.gap_1() .gap_1()
.items_start() .items_start()
.justify_start() .justify_start()
.child(NavItem::new("guide", "Guide", IconName::Info).on_click(
cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)),
))
.child( .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)), 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 /// A single navigation entry in the sidebar: an arbitrary leading element
/// highlight and an optional click handler. /// (an icon, avatar, ...) and a text label with a hover highlight and an
/// optional click handler.
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
#[derive(IntoElement)] #[derive(IntoElement)]
struct NavItem { struct NavItem {
id: ElementId, id: ElementId,
style: StyleRefinement, style: StyleRefinement,
icon: IconName, icon: AnyElement,
label: SharedString, label: SharedString,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>, on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
} }
impl NavItem { impl NavItem {
fn new<I, L>(id: I, label: L, icon: IconName) -> Self fn new<I, L, N>(id: I, label: L, icon: N) -> Self
where where
I: Into<ElementId>, I: Into<ElementId>,
L: Into<SharedString>, L: Into<SharedString>,
N: IntoElement,
{ {
Self { Self {
id: id.into(), id: id.into(),
icon, icon: icon.into_any_element(),
label: label.into(), label: label.into(),
style: StyleRefinement::default(), style: StyleRefinement::default(),
on_click: None, on_click: None,
@@ -323,8 +479,16 @@ impl RenderOnce for NavItem {
.w_full() .w_full()
.gap_2() .gap_2()
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.child(Icon::new(self.icon).small()) .child(self.icon)
.child(div().text_sm().child(self.label)) .child(
div()
.flex_1()
.min_w_0()
.text_sm()
.whitespace_nowrap()
.text_ellipsis()
.child(self.label),
)
.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))
} }