diff --git a/Cargo.lock b/Cargo.lock index a4f693c..dfd3028 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,38 +14,18 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", "phf 0.13.1", "serde", "zvariant", ] -[[package]] -name = "accesskit_consumer" -version = "0.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950720ce064757a1b629caad3a408e8d2c63bb01f29b8a3ff8daa331053ffeb" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", -] - [[package]] name = "accesskit_consumer" version = "0.38.0" @@ -63,7 +43,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", + "accesskit_consumer", "hashbrown 0.16.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", @@ -72,9 +52,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -90,12 +70,12 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.33.1" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36e93ac7bf50b964f1cbb75f741629a4e950571baa1ef1274457ab5a80d9bcc2" +checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1" dependencies = [ "accesskit", - "accesskit_consumer 0.37.0", + "accesskit_consumer", "hashbrown 0.16.1", "static_assertions", "windows 0.62.2", @@ -311,7 +291,6 @@ dependencies = [ "anyhow", "gpui", "gpui-component", - "log", "rust-embed", "serde_json", ] @@ -342,9 +321,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +checksum = "515a1f282e33d55983c499d7e9e87082e81cbc32974825bf9032f928392d5844" dependencies = [ "compression-codecs", "compression-core", @@ -678,9 +657,9 @@ dependencies = [ [[package]] name = "bindgen" -version = "0.71.1" +version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "bitflags 2.13.1", "cexpr", @@ -1238,7 +1217,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "gpui_util", "indexmap", @@ -1263,9 +1242,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.38" +version = "0.4.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +checksum = "2fe67f2944eef52fc7b106b8c9450d243a88701a0c065f7f57235e76abaed7df" dependencies = [ "bzip2", "compression-core", @@ -1275,9 +1254,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" [[package]] name = "concurrent-queue" @@ -1704,7 +1683,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "proc-macro2", "quote", @@ -1866,7 +1845,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.5+spec-1.1.0", "vswhom", "winreg", ] @@ -3484,7 +3463,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "accesskit", "anyhow", @@ -3547,7 +3526,7 @@ dependencies = [ "uuid", "waker-fn", "web-time", - "windows 0.61.3", + "windows 0.62.2", "zed-font-kit", "zed-scap", "ztracing", @@ -3556,7 +3535,7 @@ dependencies = [ [[package]] name = "gpui-base" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5" +source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b" dependencies = [ "aho-corasick", "anyhow", @@ -3592,7 +3571,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5" +source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b" dependencies = [ "anyhow", "chrono", @@ -3669,7 +3648,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5" +source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b" dependencies = [ "anyhow", "gpui", @@ -3683,7 +3662,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5" +source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b" dependencies = [ "proc-macro2", "quote", @@ -3693,21 +3672,21 @@ dependencies = [ [[package]] name = "gpui-fps" version = "0.1.0" -source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5" +source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b" dependencies = [ "gpui", - "instant", "libc", "objc2-core-foundation", "objc2-io-kit", "sysinfo 0.37.2", + "web-time", "windows 0.58.0", ] [[package]] name = "gpui_apple" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "anyhow", "block", @@ -3730,7 +3709,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "accesskit", "accesskit_unix", @@ -3776,7 +3755,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "accesskit", "accesskit_macos", @@ -3822,7 +3801,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3833,7 +3812,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "console_error_panic_hook", "gpui", @@ -3846,7 +3825,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "schemars", "serde", @@ -3856,7 +3835,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "anyhow", "log", @@ -3866,7 +3845,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3890,7 +3869,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "anyhow", "bytemuck", @@ -3916,7 +3895,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "accesskit", "accesskit_windows", @@ -3935,10 +3914,10 @@ dependencies = [ "raw-window-handle", "smallvec", "uuid", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-numerics 0.2.0", - "windows-registry 0.5.3", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-numerics 0.3.1", + "windows-registry 0.6.1", ] [[package]] @@ -4230,7 +4209,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "anyhow", "async-compression", @@ -4250,7 +4229,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "log", "rustls", @@ -5034,9 +5013,9 @@ dependencies = [ [[package]] name = "lru" -version = "0.18.3" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c" +checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" [[package]] name = "lru-slab" @@ -5206,7 +5185,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "anyhow", "bindgen", @@ -5300,9 +5279,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "log", @@ -6119,9 +6098,9 @@ checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9" [[package]] name = "open" -version = "5.4.2" +version = "5.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade3be4664bc1ef537ce133015f04c176b737815c2ba9fd60edf212d6e90dd55" +checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" dependencies = [ "is-wsl", "libc", @@ -6264,7 +6243,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "collections", "serde", @@ -7052,7 +7031,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "derive_refineable", ] @@ -7135,7 +7114,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "anyhow", "bytes", @@ -7513,7 +7492,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "async-task", "backtrace", @@ -7907,11 +7886,7 @@ dependencies = [ "dock", "gpui", "gpui-component", - "gpui_linux", - "gpui_macos", "gpui_platform", - "gpui_windows", - "log", "paths", "reqwest_client", "settings", @@ -7925,7 +7900,6 @@ dependencies = [ name = "signed_core" version = "1.0.0" dependencies = [ - "gpui", "nostr", ] @@ -7945,13 +7919,11 @@ name = "signed_nostr" version = "1.0.0" dependencies = [ "anyhow", - "nostr", "nostr-connect", "nostr-gossip-memory", "nostr-lmdb", "nostr-memory", "nostr-sdk", - "signed_core", "webbrowser", ] @@ -7969,9 +7941,11 @@ dependencies = [ "nostr-connect", "nostr-sdk", "rustls", + "settings", "signed_core", "signed_git", "signed_nostr", + "tempfile", "utils", ] @@ -8227,7 +8201,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "heapless 0.9.3", "log", @@ -8813,9 +8787,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" dependencies = [ "indexmap", "serde_core", @@ -9625,7 +9599,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "perf", "quote", @@ -10505,13 +10479,13 @@ dependencies = [ [[package]] name = "windows-registry" -version = "0.5.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -10819,7 +10793,6 @@ version = "1.0.0" dependencies = [ "anyhow", "assets", - "chrono", "dock", "futures", "gix", @@ -11394,7 +11367,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "anyhow", "chrono", @@ -11411,7 +11384,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" dependencies = [ "tracing", "tracing-subscriber", @@ -11422,7 +11395,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" +source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68" [[package]] name = "zune-core" diff --git a/Cargo.toml b/Cargo.toml index a13c8ec..336d9e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,9 +12,6 @@ publish = false # GPUI gpui = { git = "https://github.com/zed-industries/zed" } gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] } -gpui_linux = { git = "https://github.com/zed-industries/zed" } -gpui_windows = { git = "https://github.com/zed-industries/zed" } -gpui_macos = { git = "https://github.com/zed-industries/zed" } gpui_tokio = { git = "https://github.com/zed-industries/zed" } reqwest_client = { git = "https://github.com/zed-industries/zed" } @@ -37,7 +34,6 @@ nostr-sdk = { git = "https://github.com/rust-nostr/nostr" } gix = { version = "0.87.1", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation"] } -chrono = { version = "0.4.38", features = ["wasmbind"] } smol = "2" futures = "0.3" flume = { version = "0.11.1", default-features = false, features = ["async", "select"] } diff --git a/crates/assets/Cargo.toml b/crates/assets/Cargo.toml index df76dfc..da6bf8a 100644 --- a/crates/assets/Cargo.toml +++ b/crates/assets/Cargo.toml @@ -8,7 +8,6 @@ publish.workspace = true gpui.workspace = true gpui-component.workspace = true anyhow.workspace = true -log.workspace = true rust-embed.workspace = true [dev-dependencies] diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index d6d44b4..68d3d6f 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -151,14 +151,14 @@ mod tests { assert_eq!(theme.border, parse("#27272A")); // neutral-800 assert_eq!(theme.green, parse("#22C55E")); // green-500 } else { - // Light theme chrome is neutral; lime is a brand accent only. + // Light theme chrome is neutral, lime is a brand accent only. assert_eq!(theme.background, parse("#FFFFFF")); assert_eq!(theme.foreground, parse("#18181B")); assert_eq!(theme.border, parse("#E4E4E7")); assert_eq!(theme.green, parse("#16A34A")); } - // Active tab: a paler lime on light, a dim moss on dark — each - // paired with readable, contrasting text. + // Active tab, a paler lime on light and a dim moss on dark. + // Each is paired with readable contrasting text. if config.mode.is_dark() { assert_eq!(theme.tab_active, parse("#19200A")); // dim lime assert_eq!(theme.tab_active_foreground, parse("#C6FF4D")); // nostr-lime diff --git a/crates/dock/src/dock_area.rs b/crates/dock/src/dock_area.rs index 2c604cb..0195955 100644 --- a/crates/dock/src/dock_area.rs +++ b/crates/dock/src/dock_area.rs @@ -1,7 +1,3 @@ -//! The dock-area appearance: the outer frame, the split frames, and one -//! dock's chrome. Ported from the vendored dock's `DockArea`/`Dock` render -//! onto `gpui_base::dock::DockAreaRenderer`. - use std::cell::Cell; use std::ops::Deref as _; use std::rc::Rc; @@ -26,8 +22,7 @@ use crate::tab_panel::SignedTabGroupSkin; use crate::tiles::SignedTilesSkin; use crate::{TAB_BAR_HEIGHT, panel_handle}; -/// What every part of the skin reads, and the dock area it belongs to. -/// Shared by reference with the per-container renderers. +/// State the skin shares with its per-container renderers. pub(crate) struct SkinShared { area: WeakEntity, toggle_button_visible: Cell, @@ -53,16 +48,14 @@ impl SkinShared { &self.resizing_dock } - /// Redraw the area after a setting changed. The skin is not an entity, so - /// nothing else would notice. + /// Redraw the area after a setting changed. The skin is not an entity, so nothing else would. pub(crate) fn notify(&self, cx: &mut App) { _ = self.area.update(cx, |_, cx| cx.notify()); } } /// The Signed appearance for a [`DockArea`]. -/// -/// Install it at construction, where the area's own weak handle is available: +/// Install it in the constructor, the only place the area's weak handle is available. /// /// ```ignore /// let dock = cx.new(|cx| { @@ -90,8 +83,7 @@ impl SignedDockSkin { &self.shared } - /// Whether tab bars offer the affordance that collapses a neighbouring - /// dock. + /// Whether tab bars offer the affordance that collapses a neighbouring dock. pub fn is_toggle_button_visible(&self) -> bool { self.shared.is_toggle_button_visible() } @@ -112,8 +104,9 @@ impl SignedDockSkin { } } -/// The payload a dock's resize handle drags. It draws nothing: the handle -/// itself is the affordance. +/// Payload a dock's resize handle drags. +/// +/// It draws nothing, the handle element is the visible affordance. #[derive(Clone)] struct ResizePanel; @@ -144,9 +137,7 @@ impl DockAreaRenderer for SignedDockSkin { } fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, cx: &mut App) -> Stateful
{ - // `size_full` is what the old `StackPanel::render` carried; `flex_1` - // is belt and braces so the frame never collapses to zero height in - // an unsizing parent. + // `size_full` and `flex_1` stop the frame collapsing in an unsizing parent. div() .id(("dock-split-frame", node.as_u64())) .size_full() @@ -166,8 +157,8 @@ impl DockAreaRenderer for SignedDockSkin { let placement = dock.placement(); let open = dock.is_open(); - // A closed left or right dock takes no space at all; a closed bottom - // dock keeps a strip so its tab bar stays clickable. + // A closed left or right dock takes no space. + // A closed bottom dock keeps a strip so its tab bar stays clickable. if !open && !placement.is_bottom() { return div().into_any_element(); } @@ -183,8 +174,7 @@ impl DockAreaRenderer for SignedDockSkin { // Base never builds a dock for the centre. DockPlacement::Center => this, }) - // The closed bottom dock's strip is the tab bar itself, which is - // a full tab bar tall. + // The closed bottom dock's strip is the tab bar itself, a full tab bar tall. .when(!open && placement.is_bottom(), |this| { this.h(TAB_BAR_HEIGHT) }) @@ -197,9 +187,8 @@ impl DockAreaRenderer for SignedDockSkin { .into_any_element() } - /// The "unknown panel" message the old `InvalidPanel` drew. It answers - /// `dump` with the state it was handed, so a layout written by a build - /// that knows the panel survives a load and save here. + /// Placeholder for a panel this build cannot construct. + /// It dumps the state it was handed, so the layout survives a load and save. fn build_placeholder( &self, state: &PanelState, @@ -241,10 +230,8 @@ impl SignedDockSkin { } } -/// Turns the window's mouse stream into dock resizing. A resize is driven -/// by pointer moves anywhere in the window, so this paints nothing and -/// exists for its `paint` hook — the only place a window-level mouse -/// listener can be registered. +/// Turns the window's mouse stream into dock resizing. +/// It draws nothing, the `paint` hook is the only window listener registration point. struct DockResizeTracker { dock: DockContext, shared: Rc, @@ -310,10 +297,8 @@ impl Element for DockResizeTracker { if !phase.bubble() || shared.resizing_dock().get() != Some(placement) { return; } - // Dragging a closed dock's handle reopens it. The live - // state is read rather than the render-time snapshot in - // `dock`, which would still say closed for the rest of the - // frame and toggle it shut again on the next move. + // Dragging a closed dock's handle reopens it. + // Read the live state, the snapshot in `dock` would toggle it shut again. let open = shared .area() .upgrade() @@ -332,8 +317,8 @@ impl Element for DockResizeTracker { return; } shared.resizing_dock().set(None); - // The size lives on the dock, not in the layout tree, so - // nothing else tells a subscriber to persist it. + // The size lives on the dock, not the layout tree. + // Nothing else tells a subscriber to persist it. _ = shared .area() .update(cx, |_, cx| cx.emit(DockEvent::LayoutChanged)); diff --git a/crates/dock/src/invalid_panel.rs b/crates/dock/src/invalid_panel.rs index 85675a5..e927315 100644 --- a/crates/dock/src/invalid_panel.rs +++ b/crates/dock/src/invalid_panel.rs @@ -7,10 +7,8 @@ use gpui_component::ActiveTheme as _; use crate::Panel; -/// Stands in for a panel this build cannot construct. It reports the -/// original [`PanelState`] from [`dump`](gpui_base::dock::Panel::dump), so -/// a layout written by a build that knows the panel survives a load and -/// save here. +/// Stands in for a panel this build cannot construct. +/// It returns the state it was handed, so the layout survives a load and save. pub(crate) struct InvalidPanel { name: SharedString, focus_handle: FocusHandle, diff --git a/crates/dock/src/lib.rs b/crates/dock/src/lib.rs index 2a566b2..6c262af 100644 --- a/crates/dock/src/lib.rs +++ b/crates/dock/src/lib.rs @@ -1,15 +1,7 @@ -//! The Signed dock skin. -//! -//! The dock engine lives upstream (`gpui_base::dock` owns the layout tree, -//! drags, zoom and persistence); this crate is the appearance the app used -//! to vendor from gpui-component — a 44px tab bar that doubles as the -//! window title bar, with pill tabs, window controls, title-bar dragging -//! and previous/next tab buttons. -//! -//! Everything `gpui_component::dock` exports is re-exported here, so the app -//! keeps importing the dock from a single place. +use std::sync::Arc; -use gpui::{Pixels, px}; +use gpui::{Context, Pixels, Window, px}; +use gpui_base::dock::PanelView; mod dock_area; mod invalid_panel; @@ -19,20 +11,27 @@ mod window_controls; pub use dock_area::SignedDockSkin; pub use gpui_component::dock::{ - AnyDrag, BasePanel, BasePanelView, ClosePanel, DockArea, DockAreaState, DockContext, DockEvent, - DockLayout, DockPlacement, DockState, DragPanel, DropIndicator, DropPlaceholderBounds, - DropTarget, Panel, PanelControl, PanelEvent, PanelHandle, PanelInfo, PanelState, PanelStyle, - PanelView, TitleStyle, ToggleZoom, panel_handle, register_panel, + BasePanel, DockArea, DockEvent, DockLayout, DockPlacement, Panel, PanelEvent, panel_handle, }; +/// Add an already-wrapped panel handle to the center of `area`. +/// +/// Every panel entry point opens its panel there. +pub fn add_center_panel( + area: &mut DockArea, + panel: Arc, + window: &mut Window, + cx: &mut Context, +) { + area.add_panel_view(panel, DockPlacement::Center, None, window, cx); +} + /// The fixed height of the tab bar, which doubles as the window title bar. pub const TAB_BAR_HEIGHT: Pixels = px(44.); -/// Minimal i18n shim replacing gpui-component's `rust_i18n::t!()`, keeping the -/// same `Dock.*` keys resolved to English so the crate has no i18n dependency. +/// i18n shim resolving `Dock.*` keys to English, so the crate has no i18n dependency. pub(crate) fn t(key: &'static str) -> &'static str { match key { - "Dock.Unnamed" => "Unnamed", "Dock.Close" => "Close", "Dock.Zoom In" => "Zoom In", "Dock.Zoom Out" => "Zoom Out", diff --git a/crates/dock/src/tab_panel.rs b/crates/dock/src/tab_panel.rs index c6ed188..1403ae8 100644 --- a/crates/dock/src/tab_panel.rs +++ b/crates/dock/src/tab_panel.rs @@ -1,13 +1,3 @@ -//! The Signed appearance for a tab group. -//! -//! `gpui_base::dock::TabGroup` owns the behavior — membership, the displayed -//! tab, drag hit-testing, the zoom flag — and draws none of it. Everything -//! visible is here, ported from the vendored dock: the pill tab bar that -//! doubles as the window title bar (with window controls, title-bar -//! dragging, and previous/next tab buttons), the toolbar, the ellipsis menu, -//! the dock collapse affordance, the drop placeholder, and the styled drag -//! preview. - use std::cell::Cell; use std::rc::Rc; use std::sync::Arc; @@ -27,6 +17,7 @@ use gpui_base::dock::{ use gpui_base::{ElementExt, InteractiveElementExt, Tab, Tabs}; use gpui_component::animation::{Lerp as _, ease_out_cubic}; use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::dock::{ClosePanel, PanelControl, PanelHandle, ToggleZoom}; use gpui_component::menu::DropdownMenu as _; use gpui_component::{ ActiveTheme as _, Disableable as _, IconName, Selectable as _, Sizable as _, h_flex, v_flex, @@ -34,16 +25,12 @@ use gpui_component::{ use signed_ui::title_bar_drag_handlers; use crate::dock_area::SkinShared; -use crate::{ - ClosePanel, PanelControl, PanelHandle, TAB_BAR_HEIGHT, ToggleZoom, t, window_controls, -}; +use crate::{TAB_BAR_HEIGHT, t, window_controls}; -/// The size the styled drag preview occupies, reported to base so a drop -/// placeholder knows where to fly in from. +/// The drag preview's size, reported to base for the drop placeholder. const DRAG_PREVIEW_SIZE: gpui::Size = size(px(96.), px(30.)); -/// A panel's title, or its registered name when it reached base without this -/// crate's handle and so carries no presentation. See [`PanelHandle::of`]. +/// A panel's title, or its registered name when the panel has no handle. pub(crate) fn panel_title( panel: &Arc, window: &mut Window, @@ -56,9 +43,7 @@ pub(crate) fn panel_title( } /// The preview that follows the cursor while a panel is dragged. -/// -/// `gpui_base::dock::DragPanel` is the payload and draws nothing; this is the -/// appearance half, reintroduced here. +/// Base's `DragPanel` is the payload and draws nothing, this is the appearance half. struct DragPanelPreview { panel: Arc, } @@ -83,10 +68,8 @@ impl Render for DragPanelPreview { } } -/// Where the zoom affordance goes for the group's displayed panel, or `None` -/// when there is none to offer. Both [`Panel::zoom_control`] (where) and -/// [`gpui_base::dock::Panel::zoomable`] (whether) must pass; base refuses a -/// zoom that fails the latter. +/// The zoom affordance for the group's displayed panel, if it offers one. +/// The panel must offer a control and be zoomable, base refuses a zoom otherwise. fn zoom_control(group: &TabGroupContext, cx: &App) -> Option { let panel = group.active_panel()?; panel @@ -95,8 +78,8 @@ fn zoom_control(group: &TabGroupContext, cx: &App) -> Option { .flatten() } -/// The left-most, top-most tab group in a container — where a left dock's -/// collapse affordance goes. Mirrors the old `StackPanel::left_top_tab_panel`. +/// The left-most, top-most tab group in a container. +/// A left dock's collapse button lives in this group. fn left_top_group(node: &PaneNode) -> Option { match node.kind() { PaneRef::Tabs { .. } => Some(node.id()), @@ -105,9 +88,8 @@ fn left_top_group(node: &PaneNode) -> Option { } } -/// The right-most, top-most tab group. A vertical split stacks its children, -/// so its *first* child is the top one; a horizontal split's last child is -/// the right-most. Mirrors the old `StackPanel::right_top_tab_panel`. +/// The right-most, top-most tab group. +/// A vertical split picks its first child, a horizontal split picks its last. fn right_top_group(node: &PaneNode) -> Option { match node.kind() { PaneRef::Tabs { .. } => Some(node.id()), @@ -120,23 +102,17 @@ fn right_top_group(node: &PaneNode) -> Option { } } -/// One tab group's appearance. Built once per container, so the tab bar's -/// scroll position and measured title-bar geometry belong to the group. +/// One tab group's appearance, built once per container so its geometry is its own. pub(crate) struct SignedTabGroupSkin { shared: Rc, scroll_handle: ScrollHandle, - /// The displayed tab the last frame drew, so a change scrolls the new tab - /// into view. + /// The tab shown last frame, so a change scrolls the new one into view. last_active_ix: Cell>, - /// Bounds of the title bar row (the wrapper around the tab bar), in - /// window coordinates. Measured via `on_prepaint` to position the - /// title-bar drag overlay. + /// Bounds of the title bar row, measured to place the title-bar drag overlay. title_bar_bounds: Rc>>>, - /// Bounds of the tab bar's trailing empty space (right after the last - /// tab), which marks where the draggable region starts. + /// Bounds of the empty strip after the last tab, where the drag region starts. title_bar_strip_bounds: Rc>>>, - /// Bounds of the tab bar's suffix (toolbar) area, which marks where the - /// draggable region ends. + /// Bounds of the suffix area, where the drag region ends. title_bar_suffix_bounds: Rc>>>, } @@ -152,9 +128,8 @@ impl SignedTabGroupSkin { } } - /// A group that is the left dock's whole content with a single panel - /// draws no chrome at all — the vendored dock rendered such a panel bare, - /// and the sidebar is one. + /// A group that is the left dock's only group, with one panel, draws no chrome. + /// The vendored dock rendered such a panel bare and the sidebar is one. fn is_plain_sidebar_group(&self, group: &TabGroupContext, cx: &mut App) -> bool { let Some(area) = self.shared.area().upgrade() else { return false; @@ -169,11 +144,8 @@ impl SignedTabGroupSkin { left == group.node() && group.panels().len() == 1 } - /// The bottom or right dock whose root tab group this group is, if any. - /// - /// Base bars a dock's only group from being dragged or closed, so the - /// dock cannot be emptied — but a bottom/right panel is supposed to be - /// closable, so the skin routes around the bar for these groups. + /// The bottom or right dock whose root tab group is this one, if any. + /// Base keeps a dock's last group, so the skin removes these docks as a whole. fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option { let area = self.shared.area().upgrade()?; let area = area.read(cx); @@ -185,10 +157,8 @@ impl SignedTabGroupSkin { }) } - /// The drag payload for the tab at `ix`, or `None` when this group must - /// not be rearranged. A locked group is never draggable; a group that is - /// a bottom/right dock's only content still is, because the center is - /// always there to land in. + /// The tab's drag payload, or `None` when the group must not be rearranged. + /// A locked group never is, a bottom or right dock root always is. fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option { if group.is_locked() { return None; @@ -199,8 +169,8 @@ impl SignedTabGroupSkin { group.drag_panel(ix, cx) } - /// Whether a dock's collapse affordance belongs in *this* group's tab - /// bar, and which way it points. `None` means this group draws none. + /// A dock's collapse button for this group's bar, or `None` when it does not belong. + /// The icon direction depends on whether the dock is open. fn dock_toggle_button( &self, placement: DockPlacement, @@ -213,8 +183,7 @@ impl SignedTabGroupSkin { let area = self.shared.area().upgrade()?; let area = area.read(cx); - // A dock that does not exist is not collapsible, so this covers the - // old `left_dock.is_some()` test too. + // A missing dock is not collapsible, this also covers the old `left_dock.is_some()` test. if !area.is_dock_collapsible(placement) { return None; } @@ -263,8 +232,8 @@ impl SignedTabGroupSkin { ) } - /// The previous/next tab buttons shown in the tab bar's leading prefix. - /// Always rendered, disabled at the ends of the strip (or collapsed). + /// The previous and next tab buttons in the tab bar's leading prefix. + /// Always rendered, disabled at the strip ends or when collapsed. fn render_prev_next_tab_buttons( &self, group: &TabGroupContext, @@ -306,8 +275,7 @@ impl SignedTabGroupSkin { ) } - /// The trailing controls: the panel's own buttons, the zoom affordance, - /// and the ellipsis menu. + /// The trailing controls, the panel's own buttons, zoom and the ellipsis menu. fn render_toolbar( &self, group: &TabGroupContext, @@ -323,9 +291,8 @@ impl SignedTabGroupSkin { let control = zoom_control(group, cx); let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible()); let menu_zoom = control.is_some_and(|control| control.menu_visible()); - // A bottom/right dock's only panel cannot be closed through the - // group (base keeps a dock's last group), but the skin handles that - // close by removing the whole dock, so the item is offered. + // A bottom or right dock's only panel cannot close through the group. + // The close item is offered, the skin removes the whole dock instead. let closable = group.is_closable() || (self.is_dock_root_group(group, cx).is_some() && group.active_panel().is_some_and(|panel| panel.closable(cx))); @@ -397,9 +364,9 @@ impl SignedTabGroupSkin { ) } - /// One tab of the pill strip. While collapsed, tabs lose the active - /// style and all interactions, and the strip becomes the way a closed - /// bottom dock is opened again. + /// One tab of the pill strip. + /// While collapsed, tabs lose the active style and all interactions. + /// The strip is also how a closed bottom dock is opened again. #[allow(clippy::too_many_arguments)] fn render_tab( &self, @@ -433,8 +400,7 @@ impl SignedTabGroupSkin { Some(tab_name) => this.child(tab_name), None => this.child(panel_title(&panel, window, cx)), }) - // Pill presentation: the selected tab is the filled pill, the - // rest are transparent until hovered. + // Pill style, the selected tab is the filled pill, others show only on hover. .styles(|styles| { styles.selected(|style| { style @@ -457,8 +423,7 @@ impl SignedTabGroupSkin { move |_, window, cx| { group.select_tab(ix, window, cx); - // Clicking the strip of a collapsed bottom dock is how it - // is opened again. + // Clicking the strip of a collapsed bottom dock reopens it. if is_bottom_dock && collapsed { _ = area.update(cx, |area, cx| { area.toggle_dock(DockPlacement::Bottom, window, cx); @@ -509,9 +474,8 @@ impl SignedTabGroupSkin { }) } - /// The strip after the last tab: a drop target for panels and host-owned - /// drag items. Its left edge (right after the last tab) marks the start - /// of the title-bar drag overlay. + /// The strip after the last tab, a drop target for panels and other drag items. + /// Its left edge marks where the title-bar drag overlay starts. fn render_empty_space( &self, group: &TabGroupContext, @@ -541,9 +505,8 @@ impl SignedTabGroupSkin { let group = group.clone(); let node = group.node(); move |drag: &DragPanel, window, cx| { - // A panel dropped past its own last tab lands in the - // final slot; one from elsewhere is appended in the - // background. + // A panel dropped past its own last tab lands in the final slot. + // A panel from elsewhere is appended in the background. let ix = (drag.source() == node).then(|| tabs_count - 1); group.drop_panel(drag.clone(), ix, false, window, cx); } @@ -564,37 +527,30 @@ impl SignedTabGroupSkin { impl TabGroupRenderer for SignedTabGroupSkin { fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful
{ let control = zoom_control(group, cx); - // An emptied group — its last panel was dragged away — draws nothing, - // so an emptied dock does not leave a bare tab bar behind. + // An emptied group draws nothing, so no bare tab bar is left behind. if group.panels().is_empty() { return div().id("tab-panel"); } - // Closing the only panel of a bottom/right dock would leave an - // empty dock, which base refuses; the skin removes the dock instead. + // Base refuses an empty dock, so closing its only panel removes the dock. let dock_to_remove = (group.panels().len() <= 1) .then(|| self.is_dock_root_group(group, cx)) .flatten(); let shared = self.shared.clone(); - // `v_flex`, not `div`: gpui's default display is Block, and in block - // layout a child's `flex_grow` is ignored — the content region below - // the tab bar would resolve to zero height. + // `v_flex`, a plain `div` ignores `flex_grow` and the content would collapse. v_flex() .id("tab-panel") .size_full() .overflow_hidden() .bg(cx.theme().tokens.background) - // A collapsed group is a strip of tabs with no content, and the - // actions act on content. + // A collapsed group has no content, so these actions are not registered. .when(!group.is_collapsed(), |this| { this.on_action({ let group = group.clone(); move |_: &ToggleZoom, window, cx| { - // The affordance decides the control, so a panel - // offering none is not zoomed *in* by the keybinding - // either. Zooming out is never refused: a panel that - // stopped offering the control while zoomed would - // otherwise strand the user with no way back. + // A panel with no zoom control is not zoomed in by the keybinding. + // Zooming out is never refused. + // Otherwise a zoomed panel that lost its control would strand the user. if !group.is_zoomed() && control.is_none() { return; } @@ -628,8 +584,7 @@ impl TabGroupRenderer for SignedTabGroupSkin { fn content_frame(&self, group: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful
{ v_flex() .id("active-panel") - // A collapsed group draws its tab strip and nothing else, so the - // content region must not claim any space. + // A collapsed group draws its tab strip only, so the content claims no space. .when(!group.is_collapsed(), |this| this.flex_1()) } @@ -639,14 +594,12 @@ impl TabGroupRenderer for SignedTabGroupSkin { window: &mut Window, cx: &mut App, ) -> AnyElement { - // An emptied group draws no tab bar; the app prunes the emptied - // bottom/right dock a moment later. + // An emptied group draws no tab bar, the app prunes the emptied dock later. if group.panels().is_empty() { return Empty.into_any_element(); } - // The sidebar group draws no chrome at all, like the vendored dock's - // bare `DockItem::Panel`. + // The sidebar group draws no chrome, like the vendored `DockItem::Panel`. if self.is_plain_sidebar_group(group, cx) { return Empty.into_any_element(); } @@ -660,10 +613,9 @@ impl TabGroupRenderer for SignedTabGroupSkin { let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx); let is_bottom_dock = bottom_dock_button.is_some(); - // macOS: the traffic lights overlay the window's top-left corner. - // Only the group whose tab bar actually sits under them reserves the - // space — the center's left-most, top-most group when the left dock - // is closed or absent. + // On macOS the traffic lights overlay the window's top-left corner. + // Only the tab bar that sits under them reserves the space. + // That is the center's top-left group when the left dock is closed or absent. let needs_traffic_light_padding = cfg!(target_os = "macos") && self.shared.area().upgrade().is_some_and(|area| { let area = area.read(cx); @@ -674,8 +626,8 @@ impl TabGroupRenderer for SignedTabGroupSkin { == Some(group.node()) }); - // Bring a newly displayed tab into view. The group owns selection - // now, so the skin notices the change rather than being told about it. + // Bring a newly displayed tab into view. + // The group owns selection, so the skin watches for the change itself. let displayed = group.active_panel().map(|panel| panel.panel_id(cx)); let visible: Vec = group .panels() @@ -690,10 +642,8 @@ impl TabGroupRenderer for SignedTabGroupSkin { self.scroll_handle.scroll_to_item(visible_ix); } - // The tab strip lays out at content width, so the area after the - // last tab has no element. Cover that dead zone (last tab's right - // edge to suffix's left edge) with a measured overlay so the whole - // non-interactive area can drag the window. + // The tab strip ends at the last tab, the bar has no element after it. + // Cover that dead zone with an overlay so it can drag the window. let drag_overlay = match ( self.title_bar_bounds.get(), self.title_bar_strip_bounds.get(), @@ -728,8 +678,7 @@ impl TabGroupRenderer for SignedTabGroupSkin { if !panel.visible(cx) { return None; } - // A collapsed group shows no tab as active: the strip is a - // way back in, not a selection. + // Collapsed tabs never show as active, the strip only reopens the dock. if collapsed { active = false; } @@ -768,7 +717,7 @@ impl TabGroupRenderer for SignedTabGroupSkin { h_flex() .items_center() .top_0() - // Right -1 for avoid border overlap with the first tab + // -1 px so the border does not overlap the first tab. .right(-px(1.)) .h_full() .gap_2() @@ -852,9 +801,7 @@ impl TabGroupRenderer for SignedTabGroupSkin { cx: &mut App, ) -> Option { let (from, to) = (indicator.from(), indicator.to()); - // The placeholder animates from wherever it was to where the drop - // would land, so its own element is positioned at the destination and - // the animation only has to walk the difference back to zero. + // The element sits at the drop target, the animation walks back from the source. let offset = from.origin() - to.origin(); Some( diff --git a/crates/dock/src/tiles.rs b/crates/dock/src/tiles.rs index 1ac8d74..d511d99 100644 --- a/crates/dock/src/tiles.rs +++ b/crates/dock/src/tiles.rs @@ -1,11 +1,3 @@ -//! The Signed appearance for a tiles canvas. -//! -//! `gpui_base::dock::TilesState` owns the geometry — snapping, the resize -//! arithmetic, the undo stack, the zoom flag — and draws none of it. The tile -//! frame, its title bar and its resize affordances are here, ported from -//! gpui-component's `TilesSkin` (the vendored dock had no tiles canvas, so -//! there is no local look to preserve). - use std::rc::Rc; use gpui::prelude::FluentBuilder as _; @@ -18,6 +10,7 @@ use gpui_base::dock::{ DRAG_BAR_HEIGHT, HANDLE_SIZE, NodeId, ResizeSide, TileContext, TilesRenderer, }; use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::dock::PanelHandle; use gpui_component::menu::{DropdownMenu as _, PopupMenuItem}; use gpui_component::scroll::Scrollbar; use gpui_component::{ @@ -25,8 +18,8 @@ use gpui_component::{ }; use crate::dock_area::SkinShared; +use crate::t; use crate::tab_panel::panel_title; -use crate::{PanelHandle, t}; /// How far a resize handle sticks out past the tile's edge. const HANDLE_OFFSET: Pixels = px(-4.); @@ -52,9 +45,7 @@ impl Render for DragResizing { } /// One tiles canvas's appearance. -/// -/// Built per canvas — `DockAreaRenderer::tiles_renderer` is called once per -/// container — so the scroll position belongs to the canvas it scrolls. +/// Built once per container, so its scroll position belongs to the canvas it scrolls. pub(crate) struct SignedTilesSkin { shared: Rc, scroll_handle: ScrollHandle, @@ -101,11 +92,8 @@ impl SignedTilesSkin { }) } - /// The trailing controls of a tile's title bar: zoom, close and the - /// ellipsis menu. They use click handlers rather than the - /// [`ToggleZoom`](crate::ToggleZoom)/[`ClosePanel`](crate::ClosePanel) - /// actions, which are dispatched to a focused tab group — a tile is - /// not one. + /// The trailing controls of a tile's title bar, zoom, close and the ellipsis menu. + /// They use click handlers, the zoom and close actions target a focused tab group. fn render_tile_controls( &self, tile: &TileContext, @@ -213,21 +201,17 @@ impl TilesRenderer for SignedTilesSkin { .border_1() .border_color(cx.theme().border) .rounded(cx.theme().tile_radius) - // Room for the title bar, which is positioned over the padding so - // the panel below it is never covered. Base draws the panel view - // as a plain child, so this is the only way to keep the two from - // overlapping. + // Room for the title bar, which overlays the top padding. + // Base draws the panel as a plain child, this keeps them apart. .pt(DRAG_BAR_HEIGHT) - // Base installs the stored bounds on an ordinary tile and nothing - // at all on a zoomed one — how a zoomed tile fills the dock is - // this skin's decision. + // Base stores no bounds on a zoomed tile, the skin decides how it fills the dock. .when(tile.is_zoomed(), |this| this.size_full()) .on_mouse_down(MouseButton::Left, { let tile = tile.clone(); move |_, window, cx| tile.bring_to_front(window, cx) }) - // A gesture can end with the pointer anywhere, so both halves are - // needed; each is a no-op unless this tile is the one moving. + // A gesture can end anywhere, so both mouse-up hooks run. + // Each is a no-op unless this tile is the one that moved. .on_mouse_up(MouseButton::Left, { let tile = tile.clone(); move |_, window, cx| { @@ -274,8 +258,7 @@ impl TilesRenderer for SignedTilesSkin { ) .children(handle.and_then(|handle| handle.title_suffix(window, cx))) .child(self.render_tile_controls(tile, window, cx)) - // A zoomed tile is not at its stored bounds, so there is nothing - // for a move to mean; base refuses the gesture too. + // A zoomed tile is not at its stored bounds, so moving it would mean nothing. .when(!tile.is_zoomed(), |this| { this.cursor_grab() .on_mouse_down(MouseButton::Left, { @@ -309,10 +292,8 @@ impl TilesRenderer for SignedTilesSkin { ) -> AnyElement { let bounds = tile.bounds(); - // A passive full-tile box so each handle is positioned against the - // tile rather than against whatever the flow put it next to. It - // registers no interaction of its own, so it does not shadow the panel - // underneath. + // A passive full-tile box, so handles sit against the tile, not its flow neighbours. + // It registers no interaction, so it does not shadow the panel underneath. div() .absolute() .top_0() @@ -376,9 +357,7 @@ impl TilesRenderer for SignedTilesSkin { .into_any_element() } - /// The panel of a tile gets `size_full` here; base draws the panel as a - /// plain child, so without it a panel that does not size itself has no - /// size. + /// Gives the tile's panel `size_full`, base draws it as a plain child otherwise. fn panel_frame(&self, tile: &TileContext, _: &mut Window, _: &mut App) -> Stateful
{ h_flex() .id(("tile-panel", tile.panel_id().as_u64())) @@ -386,9 +365,8 @@ impl TilesRenderer for SignedTilesSkin { .size_full() } - /// The canvas scrollbar. It must be an overlay: the frame is the scroll - /// container and base appends the tiles after it, so a scrollbar placed - /// inside would paint and hit-test underneath every tile. + /// The canvas scrollbar, as an overlay. + /// Placed inside the frame it would end up underneath every tile. fn render_overlay( &self, content: Size, diff --git a/crates/dock/src/window_controls.rs b/crates/dock/src/window_controls.rs index 4a695be..1f9e833 100644 --- a/crates/dock/src/window_controls.rs +++ b/crates/dock/src/window_controls.rs @@ -147,8 +147,7 @@ pub(crate) fn window_controls(window: &mut Window, cx: &mut App) -> impl IntoEle .items_center() .flex_shrink_0() .h_full() - // Like native windows apps, the controls span the title bar but never - // grow past the tab bar height. + // The controls span the title bar but never grow past the tab bar height. .when(cfg!(target_os = "windows"), |this| { this.max_h(TAB_BAR_HEIGHT) }) diff --git a/crates/dock/tests/render_smoke.rs b/crates/dock/tests/render_smoke.rs index 02eed70..790077a 100644 --- a/crates/dock/tests/render_smoke.rs +++ b/crates/dock/tests/render_smoke.rs @@ -1,8 +1,3 @@ -//! Render-path smoke tests: the skin reads the dock area while rendering, and -//! GPUI panics if an entity is read while it is leased (being updated). These -//! pin that the first frame — docks, groups, tab bars — renders without -//! tripping the lease check. - use dock::{BasePanel, Panel, SignedDockSkin, panel_handle}; use gpui::{ App, AppContext, Context, Empty, EventEmitter, FocusHandle, Focusable, IntoElement, Render, @@ -84,12 +79,10 @@ fn the_first_frame_renders_the_area_and_its_docks(cx: &mut TestAppContext) { }); }); - // The first frame walks every render hook — the dock frame, each group's - // tab bar, the toolbar — all of which read the dock area. + // The first frame walks every render hook, all of which read the dock area. cx.update(|window, cx| window.draw(cx).clear(cx)); - // Emptying a dock leaves an empty group behind; its render must also be - // safe (and draw nothing). + // Emptying a dock leaves an empty group, its render must also be safe. cx.update(|window, cx| { area.update(cx, |area, cx| { area.remove_panel(bottom, window, cx); diff --git a/crates/paths/src/lib.rs b/crates/paths/src/lib.rs index bc3fe6d..7668e60 100644 --- a/crates/paths/src/lib.rs +++ b/crates/paths/src/lib.rs @@ -1,23 +1,16 @@ -//! Paths to locations used by Signed. -//! -//! Follows the same pattern as Zed's `paths` crate: platform-correct base -//! directories, resolved once and cached, with an optional custom data dir -//! override for portable/dev installs. - use std::path::PathBuf; use std::sync::OnceLock; -/// The application name, used to derive platform-specific data, config and -/// cache directory paths. +/// The application name. +/// +/// It derives the platform-specific data, config and cache directory paths. pub const APP_NAME: &str = "Signed"; -/// Lowercased form of [`APP_NAME`], for use in XDG-style paths on -/// Linux/FreeBSD and the macOS `~/.config` fallback. +/// Lowercased form of [`APP_NAME`]. +/// +/// Used in XDG-style paths on Linux and FreeBSD, and the macOS `~/.config` fallback. pub const APP_NAME_LOWERCASE: &str = "signed"; -/// A custom data directory override, set only by [`set_custom_data_dir`]. -static CUSTOM_DATA_DIR: OnceLock = OnceLock::new(); - /// The resolved data directory. /// On macOS, this is `~/Library/Application Support/Signed`. /// On Linux/FreeBSD, this is `$XDG_DATA_HOME/signed`. @@ -35,45 +28,24 @@ pub fn home_dir() -> PathBuf { dirs::home_dir().expect("failed to determine home directory") } -/// Returns the current user's Desktop folder, falling back to the home -/// directory (or an empty path) when it can't be determined. +/// Returns the current user's Desktop folder. +/// +/// Falls back to the home directory or an empty path when it cannot be determined. pub fn desktop_dir() -> PathBuf { dirs::desktop_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default()) } -/// Returns the current user's Documents folder, falling back to the home -/// directory (or an empty path) when it can't be determined. +/// Returns the current user's Documents folder. +/// +/// Falls back to the home directory or an empty path when it cannot be determined. pub fn documents_dir() -> PathBuf { dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default()) } -/// Sets a custom directory for all user data, overriding the default data -/// directory. Must be called before any other path operation. The directory -/// is created if it doesn't exist and canonicalized to an absolute path. -/// -/// # Panics -/// -/// Panics if called after [`data_dir`] or [`config_dir`] was initialized, or -/// if the directory cannot be created/canonicalized. -pub fn set_custom_data_dir(dir: &str) -> &'static PathBuf { - if CURRENT_DATA_DIR.get().is_some() || CONFIG_DIR.get().is_some() { - panic!("set_custom_data_dir called after data_dir or config_dir was initialized"); - } - - CUSTOM_DATA_DIR.get_or_init(|| { - let path = PathBuf::from(dir); - std::fs::create_dir_all(&path).expect("failed to create custom data directory"); - path.canonicalize() - .expect("failed to canonicalize custom data directory") - }) -} - /// Returns the path to the configuration directory. pub fn config_dir() -> &'static PathBuf { CONFIG_DIR.get_or_init(|| { - if let Some(custom_dir) = CUSTOM_DATA_DIR.get() { - custom_dir.join("config") - } else if cfg!(target_os = "windows") { + if cfg!(target_os = "windows") { dirs::config_dir() .expect("failed to determine RoamingAppData directory") .join(APP_NAME) @@ -93,9 +65,7 @@ pub fn config_dir() -> &'static PathBuf { /// Returns the path to the data directory. pub fn data_dir() -> &'static PathBuf { CURRENT_DATA_DIR.get_or_init(|| { - if let Some(custom_dir) = CUSTOM_DATA_DIR.get() { - custom_dir.clone() - } else if cfg!(target_os = "macos") { + if cfg!(target_os = "macos") { home_dir() .join("Library/Application Support") .join(APP_NAME) @@ -116,50 +86,13 @@ pub fn data_dir() -> &'static PathBuf { }) } -/// Returns the path to the cache directory. -pub fn cache_dir() -> &'static PathBuf { - static CACHE_DIR: OnceLock = OnceLock::new(); - CACHE_DIR.get_or_init(|| { - if cfg!(target_os = "macos") { - dirs::cache_dir() - .expect("failed to determine caches directory") - .join(APP_NAME) - } else if cfg!(target_os = "windows") { - dirs::cache_dir() - .expect("failed to determine LocalAppData directory") - .join(APP_NAME) - } else if cfg!(any(target_os = "linux", target_os = "freebsd")) { - if let Ok(flatpak_xdg_cache) = std::env::var("FLATPAK_XDG_CACHE_HOME") { - flatpak_xdg_cache.into() - } else { - dirs::cache_dir().expect("failed to determine XDG_CACHE_HOME directory") - } - .join(APP_NAME_LOWERCASE) - } else { - home_dir().join(".cache").join(APP_NAME_LOWERCASE) - } - }) -} - -/// Returns the path to the logs directory. -pub fn logs_dir() -> &'static PathBuf { - static LOGS_DIR: OnceLock = OnceLock::new(); - LOGS_DIR.get_or_init(|| { - if cfg!(target_os = "macos") { - home_dir().join("Library/Logs").join(APP_NAME) - } else { - data_dir().join("logs") - } - }) -} - -/// Returns the path to the nostr database directory (LMDB). +/// Returns the path to the nostr database directory, LMDB. pub fn nostr_dir() -> &'static PathBuf { static NOSTR_DIR: OnceLock = OnceLock::new(); NOSTR_DIR.get_or_init(|| data_dir().join("nostr")) } -/// Returns the path to the local git clone cache (grasp mirrors). +/// Returns the path to the local git clone cache, the grasp mirrors. pub fn repos_dir() -> &'static PathBuf { static REPOS_DIR: OnceLock = OnceLock::new(); REPOS_DIR.get_or_init(|| data_dir().join("repos")) @@ -170,9 +103,3 @@ pub fn settings_file() -> &'static PathBuf { static SETTINGS_FILE: OnceLock = OnceLock::new(); SETTINGS_FILE.get_or_init(|| config_dir().join("settings.json")) } - -/// Returns the path to the `keymap.json` file. -pub fn keymap_file() -> &'static PathBuf { - static KEYMAP_FILE: OnceLock = OnceLock::new(); - KEYMAP_FILE.get_or_init(|| config_dir().join("keymap.json")) -} diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 228e05d..36154d0 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -1,9 +1,3 @@ -//! Persisted application settings for Signed. -//! -//! The [`Settings`] model holds the user-configurable values that survive -//! restarts, and [`SettingsStore`] loads them from and saves them to a JSON -//! file on disk (see [`paths::settings_file`]). - mod settings; mod store; diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index 08c0ab1..75406a6 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -2,8 +2,8 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; -/// The default grasp servers offered when the user hasn't published a -/// grasp list (kind `10317`) yet. +/// The default grasp servers, +/// offered while the user has not published a grasp list. pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [ "wss://relay.ngit.dev", "wss://gitnostr.com", @@ -14,7 +14,7 @@ pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [ #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AppearanceMode { - /// Follow the system appearance (light/dark) at runtime. + /// Follow the system appearance, light or dark, at runtime. #[default] System, /// Always use the light theme. @@ -23,12 +23,8 @@ pub enum AppearanceMode { Dark, } -/// Theme configuration. -/// -/// The fields mirror the gpui-component `Theme` surface the application -/// customizes at startup, so applying the settings is a plain field-for-field -/// copy. The theme names identify entries in the gpui-component theme -/// registry. +/// Theme configuration, +/// fields mirror the gpui-component `Theme` surface customized at startup. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ThemeSettings { @@ -42,7 +38,7 @@ pub struct ThemeSettings { pub mono_font_size: f32, /// Corner radius for general elements in pixels. pub radius: f32, - /// Corner radius for large elements (dialogs, notifications) in pixels. + /// Corner radius for large elements, dialogs and notifications, in pixels. pub radius_lg: f32, /// Whether focused controls draw a ring outside their border. pub focus_ring: bool, @@ -69,8 +65,7 @@ impl Default for ThemeSettings { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct GraspServersSettings { - /// The servers offered when the user hasn't published a grasp list - /// (kind `10317`) yet. + /// Servers offered while the user has not published a grasp list, kind `10317`. pub default_servers: Vec, } @@ -89,9 +84,8 @@ impl Default for GraspServersSettings { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct LocalReposSettings { - /// The directories scanned for local git repositories. - /// - /// Defaults to the user's Desktop and Documents folders. + /// The directories scanned for local git repositories, + /// defaults to the user's Desktop and Documents folders. pub scan_paths: Vec, } @@ -107,12 +101,35 @@ impl Default for LocalReposSettings { } } +/// A remembered association between a local checkout folder and an announced repository, +/// recorded when the user clones a repository or picks a folder in the New PR panel. +/// +/// The panel can then prefill the folder later without asking again. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct CheckoutRecord { + /// Local folder of the checkout. + pub path: PathBuf, + /// Repository address as a string, `30617::`. + pub addr: String, + /// Unix seconds of the last use, for freshest-first ordering. + pub last_used: u64, +} + +/// Remembered local checkouts, see [`CheckoutRecord`]. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct CheckoutsSettings { + /// The remembered records. + /// The latest use of a path and repo pair replaces the older record. + pub records: Vec, +} + /// The create-repository dialog. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct CreateRepositorySettings { - /// The folder the create-repository dialog defaults to; the user's - /// Desktop when unset. + /// The folder the create-repository dialog defaults to, the user's Desktop when unset. pub default_folder: Option, } @@ -128,6 +145,8 @@ pub struct Settings { pub grasp_servers: GraspServersSettings, /// Local repository scanning. pub local_repos: LocalReposSettings, + /// Remembered local checkouts. + pub checkouts: CheckoutsSettings, /// The create-repository dialog. pub create_repository: CreateRepositorySettings, } diff --git a/crates/settings/src/store.rs b/crates/settings/src/store.rs index 6245bfe..87bc1de 100644 --- a/crates/settings/src/store.rs +++ b/crates/settings/src/store.rs @@ -9,7 +9,9 @@ struct GlobalSettingsStore(Entity); impl Global for GlobalSettingsStore {} -/// The application settings, loaded from disk at startup and persisted whenever they change. +/// The application settings, +/// loaded from disk at startup and saved whenever they change. +/// /// Installed as a global by the app so any part of the UI can read and edit them. pub struct SettingsStore { path: PathBuf, @@ -17,7 +19,7 @@ pub struct SettingsStore { } impl SettingsStore { - /// Retrieve the global settings store (created at startup by the app). + /// Retrieve the global settings store, created at startup by the app. pub fn global(cx: &App) -> Entity { cx.global::().0.clone() } @@ -27,9 +29,11 @@ impl SettingsStore { cx.set_global(GlobalSettingsStore(entity)); } - /// Load the settings from `path`, falling back to the defaults when the - /// file is missing or unreadable. Missing keys merge with the defaults, - /// so older settings files keep working as new settings are added. + /// Load the settings from `path`, + /// falls back to defaults when the file is missing or unreadable. + /// + /// Missing keys merge with the defaults, + /// older settings files keep working as new settings are added. pub fn new(path: impl AsRef, _cx: &mut Context) -> Self { Self { path: path.as_ref().to_path_buf(), @@ -78,8 +82,7 @@ impl SettingsStore { } } - /// Write the settings to disk, replacing the file atomically - /// so a crash mid-write cannot corrupt the settings. + /// Write the settings to disk, replacing the file atomically. fn save(&self) -> Result<()> { if let Some(parent) = self.path.parent() { std::fs::create_dir_all(parent)?; diff --git a/crates/signed_core/Cargo.toml b/crates/signed_core/Cargo.toml index bd77e36..5b1e5ca 100644 --- a/crates/signed_core/Cargo.toml +++ b/crates/signed_core/Cargo.toml @@ -5,5 +5,4 @@ edition.workspace = true publish.workspace = true [dependencies] -gpui.workspace = true nostr.workspace = true diff --git a/crates/signed_core/src/addr.rs b/crates/signed_core/src/addr.rs index 8617de8..729322b 100644 --- a/crates/signed_core/src/addr.rs +++ b/crates/signed_core/src/addr.rs @@ -1,9 +1,9 @@ use nostr::prelude::*; -/// Address of a NIP-34 repository announcement: `30617::`. +/// Address of a NIP-34 repository announcement, `30617::`. /// -/// The Rust Nostr SDK's [`Coordinate`] already provides parsing, formatting and hashing for this. -/// The alias keeps the repository-specific vocabulary while reusing the SDK type. +/// The Rust Nostr SDK's [`Coordinate`] parses, formats and hashes this, +/// the alias reuses the SDK type while keeping repository-specific vocabulary. pub type RepoAddr = Coordinate; /// Build the address of a NIP-34 repository announcement. diff --git a/crates/signed_core/src/annotations.rs b/crates/signed_core/src/annotations.rs index 1541b22..affa928 100644 --- a/crates/signed_core/src/annotations.rs +++ b/crates/signed_core/src/annotations.rs @@ -1,13 +1,15 @@ use nostr::prelude::*; -/// ngit / GitWorkshop cover-note extension (kind 1624): a markdown note -/// attached to an issue, patch or PR by its author or a repository -/// maintainer. Not part of the NIP-34 draft; read support for interop. +/// GitWorkshop and `ngit` cover-note extension, kind 1624. +/// +/// A markdown note attached to an issue, patch or PR by its author or a maintainer, +/// not part of the NIP-34 draft, read support for interop. pub const COVER_NOTE_KIND: Kind = Kind::Custom(1624); -/// Whether a kind-1985 label event is a valid annotation of `root`: it -/// references the root via a lowercase `e` tag and was authored by the root -/// author or a maintainer. +/// Whether a kind-1985 label event is a valid annotation of `root`. +/// +/// The event references the root with a lowercase `e` tag, +/// its author must be the root author or a maintainer. fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) -> bool { if event.kind != Kind::Label { return false; @@ -22,8 +24,8 @@ fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) -> .any(|tag| tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id)) } -/// Whether a kind-1985 label event declares the `#t` namespace and carries at -/// least one `["l", "", "#t"]` label. +/// Whether a kind-1985 label event declares the `#t` namespace, +/// it must also carry at least one `["l", "", "#t"]` label. fn has_hashtag_labels(event: &Event) -> bool { event.tags.iter().any(|tag| tag.as_slice() == ["L", "#t"]) && event.tags.iter().any(|tag| { @@ -32,10 +34,12 @@ fn has_hashtag_labels(event: &Event) -> bool { }) } -/// The effective hashtag labels of `root`: the `t` tags on the event itself -/// (self-reported by its author) plus all labels attached via authorized -/// NIP-32 kind-1985 events in the `#t` namespace. Labels are additive — all -/// valid label events contribute (no latest-wins semantics). +/// Effective hashtag labels of `root`, +/// the `t` tags on the event itself, self-reported by its author, +/// authorized NIP-32 kind-1985 events in the `#t` namespace add more. +/// +/// Labels are additive, so all valid label events contribute, +/// there is no latest-wins semantics. pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -> Vec { let mut labels: Vec = root .tags @@ -61,10 +65,10 @@ pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) - labels } -/// The effective subject/title override of `root`, from authorized kind-1985 -/// label events in the `#subject` namespace. Only the latest event wins -/// (tiebreak: lexicographically larger event id, per NIP-01 replaceable -/// semantics). Returns `None` when no valid override exists. +/// Subject or title override of `root` from authorized kind-1985 label events, +/// only label events in the `#subject` namespace count. +/// +/// Returns `None` when no valid override exists. pub fn subject_override( root: &Event, label_events: &[Event], @@ -103,8 +107,8 @@ pub fn subject_override( }) } -/// The effective hashtag labels and subject override of `root` in one pass -/// (mirrors ngit's `get_labels_and_subject`). +/// Effective hashtag labels and subject override of `root` in one pass, +/// mirrors ngit's `get_labels_and_subject`. pub fn labels_and_subject( root: &Event, label_events: &[Event], @@ -116,9 +120,9 @@ pub fn labels_and_subject( ) } -/// The effective cover note of `root`: the latest authorized kind-1624 event -/// (tiebreak: lexicographically larger event id, per NIP-01 replaceable -/// semantics). Returns `None` when no valid cover note exists. +/// Effective cover note of `root`. +/// +/// Returns `None` when no valid cover note exists. pub fn cover_note<'a>( root: &Event, cover_notes: &'a [Event], diff --git a/crates/signed_core/src/clone_url.rs b/crates/signed_core/src/clone_url.rs index ca5cc94..e212b28 100644 --- a/crates/signed_core/src/clone_url.rs +++ b/crates/signed_core/src/clone_url.rs @@ -2,10 +2,10 @@ use nostr::prelude::*; use crate::RepoAddr; -/// Target of a `nostr://` clone URL (NIP-34 "Nostr Clone URL format"). +/// Target of a `nostr://` clone URL, as defined by NIP-34. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CloneTarget { - /// `nostr://` — direct repository address. + /// `nostr://` encodes a direct repository address. Addr(RepoAddr), /// `nostr:///[relay-hint/]` UserRepo { diff --git a/crates/signed_core/src/comments.rs b/crates/signed_core/src/comments.rs deleted file mode 100644 index 1715cf6..0000000 --- a/crates/signed_core/src/comments.rs +++ /dev/null @@ -1,161 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use nostr::prelude::*; - -/// A NIP-22 comment thread: a top-level comment on the root event and its -/// nested replies (oldest first at every level). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CommentThread { - /// The thread's top-level comment. - pub comment: Event, - /// Replies to [`Self::comment`], nested recursively. - pub replies: Vec, -} - -/// The direct parent of a comment (NIP-22 lowercase `e` tag), or `None` for -/// comments without one. -fn comment_parent(event: &Event) -> Option { - event - .tags - .iter() - .find(|tag| tag.kind() == "e") - .and_then(Tag::content) - .and_then(|id| EventId::parse(id).ok()) -} - -/// Group the comments on a root event (issue / patch / PR) into NIP-22 -/// threads. A comment whose parent is the root itself starts a thread; other -/// comments nest under their parent comment. Threads and replies are ordered -/// oldest-first. Replies whose parent comment is missing (e.g. not fetched) -/// are placed as top-level threads so they are not dropped. -pub fn comment_threads(root: &Event, comments: &[Event]) -> Vec { - // Index comments by their parent id. Comments without a parent tag are - // treated as replying to the root event itself. - let mut children: HashMap> = HashMap::new(); - for comment in comments { - let parent = comment_parent(comment).unwrap_or(root.id); - children.entry(parent).or_default().push(comment); - } - for list in children.values_mut() { - list.sort_by_key(|event| event.created_at); - } - - let mut visited: HashSet = HashSet::new(); - - fn build( - id: EventId, - children: &HashMap>, - visited: &mut HashSet, - ) -> Vec { - let Some(list) = children.get(&id) else { - return Vec::new(); - }; - let mut threads = Vec::new(); - for event in list { - // Guards against malformed reply cycles. - if visited.insert(event.id) { - threads.push(CommentThread { - comment: (*event).clone(), - replies: build(event.id, children, visited), - }); - } - } - threads - } - - let mut threads = build(root.id, &children, &mut visited); - - // Orphan replies: their parent comment is unknown, so they never appear - // in the tree rooted at the root event; surface them as top-level threads. - let mut orphans: Vec<&Event> = comments - .iter() - .filter(|event| !visited.contains(&event.id)) - .collect(); - orphans.sort_by_key(|event| event.created_at); - for comment in orphans { - if visited.insert(comment.id) { - threads.push(CommentThread { - comment: comment.clone(), - replies: build(comment.id, &children, &mut visited), - }); - } - } - - threads -} - -#[cfg(test)] -mod tests { - use super::*; - - fn comment(keys: &Keys, parent: Option<&Event>, content: &str, created_at: u64) -> Event { - let tags = parent - .map(|parent| vec![Tag::parse(["e", &parent.id.to_hex()]).expect("valid e tag")]) - .unwrap_or_default(); - EventBuilder::new(Kind::Comment, content) - .tags(tags) - .custom_created_at(Timestamp::from(created_at)) - .finalize(keys) - .expect("signed event") - } - - fn flatten(threads: &[CommentThread]) -> Vec { - let mut out = Vec::new(); - for thread in threads { - out.push(thread.comment.content.clone()); - out.extend(flatten(&thread.replies)); - } - out - } - - #[test] - fn nests_replies_under_their_parents() { - let keys = Keys::generate(); - let root = EventBuilder::new(Kind::GitIssue, "issue") - .finalize(&keys) - .expect("signed event"); - - let a = comment(&keys, Some(&root), "a", 100); - let a1 = comment(&keys, Some(&a), "a1", 200); - let a2 = comment(&keys, Some(&a), "a2", 300); - let b = comment(&keys, Some(&root), "b", 150); - - let threads = comment_threads(&root, &[a2, b, a, a1]); - - assert_eq!(flatten(&threads), vec!["a", "a1", "a2", "b"]); - } - - #[test] - fn comments_without_a_parent_tag_attach_to_the_root() { - let keys = Keys::generate(); - let root = EventBuilder::new(Kind::GitIssue, "issue") - .finalize(&keys) - .expect("signed event"); - - // Old-style comments carried no `e` tag at all. - let orphan = comment(&keys, None, "no parent", 100); - - let threads = comment_threads(&root, &[orphan]); - - assert_eq!(flatten(&threads), vec!["no parent"]); - } - - #[test] - fn orphan_replies_are_surfaced_as_top_level_threads() { - let keys = Keys::generate(); - let root = EventBuilder::new(Kind::GitIssue, "issue") - .finalize(&keys) - .expect("signed event"); - let a = comment(&keys, Some(&root), "a", 100); - - // `missing` is not in the comment set; its reply should still show up. - let missing = EventBuilder::new(Kind::Comment, "missing") - .finalize(&keys) - .expect("signed event"); - let reply_to_missing = comment(&keys, Some(&missing), "reply to missing", 200); - - let threads = comment_threads(&root, &[a, reply_to_missing]); - - assert_eq!(flatten(&threads), vec!["a", "reply to missing"]); - } -} diff --git a/crates/signed_core/src/deletions.rs b/crates/signed_core/src/deletions.rs index 131c1b0..1fbcd66 100644 --- a/crates/signed_core/src/deletions.rs +++ b/crates/signed_core/src/deletions.rs @@ -2,15 +2,17 @@ use std::collections::HashSet; use nostr::prelude::*; -/// NIP-09 deletion requests and NIP-62 vanish requests, used to hide -/// deleted events before they reach the UI. +/// NIP-09 deletion requests and NIP-62 vanish requests, +/// built from the kind-5 and kind-62 events in the local database. /// -/// Built from the kind-5 / kind-62 events stored in the local database; -/// pass any event through [`Deletions::is_deleted`] before displaying it. +/// Deleted events are hidden before they reach the UI. +/// +/// Pass any event through [`Deletions::is_deleted`] before showing it. pub struct Deletions { /// `(deleted event id, expected author)` from `e` tags of kind-5 events. ids: HashSet<(EventId, PublicKey)>, /// `(coordinate, expected author, cutoff)` from `a` tags of kind-5 events. + /// /// All versions of the addressable event up to `cutoff` are deleted. coords: Vec<(Coordinate, PublicKey, Timestamp)>, /// `(author, cutoff)` from kind-62 vanish requests. @@ -34,8 +36,8 @@ impl Deletions { .map(|c| (c, event.pubkey, event.created_at)), ); } else if event.kind == Kind::RequestToVanish { - // Client-side we can't verify which relay the request targeted, - // so any vanish request is honored for the author's events. + // Client-side we can't verify which relay the request targeted. + // Any vanish request is then honored for the author's events. vanished.push((event.pubkey, event.created_at)); } } @@ -48,10 +50,9 @@ impl Deletions { } /// Whether the event is covered by a valid deletion or vanish request. + /// A request is valid when its author matches the deleted event's author, per NIP-09. /// - /// A request is only valid when its author matches the deleted event's - /// author (NIP-09); addressable events are deleted up to the request's - /// `created_at`. + /// Addressable events are deleted up to the request's `created_at`. pub fn is_deleted(&self, event: &Event) -> bool { if self .vanished diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index b8f3253..e6485cf 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -25,7 +25,7 @@ pub fn announcement(addr: &RepoAddr) -> Filter { .identifier(addr.identifier.clone()) } -/// Latest state event (refs / HEAD) for a repository. +/// Latest state event for a repository, carrying refs and HEAD. pub fn state(addr: &RepoAddr) -> Filter { Filter::new() .kind(Kind::RepoState) @@ -33,18 +33,18 @@ pub fn state(addr: &RepoAddr) -> Filter { .identifier(addr.identifier.clone()) } -/// All NIP-34 activity addressed to a repository (`#a` tag): issues, PRs, -/// patches, statuses and comments (kind 1111). -/// -/// Note: the `a` tag on status events is optional per NIP-34, so statuses -/// published without it won't be matched here. +/// All NIP-34 activity addressed to a repository via its `#a` tag. +/// Covers issues, PRs, patches, statuses and kind-1111 comments. +/// The `a` tag is optional on status events per NIP-34. +/// Statuses published without it are not matched here. pub fn activity(addr: &RepoAddr) -> Filter { Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr) } -/// Status events (`1630..=1633`) referencing any of the given root events -/// (`#e` tag). Batched: one filter covers all roots, so a negentropy sync -/// reconciles them in a single session instead of one per root. +/// Status events, kinds `1630..=1633`, referencing any of the given root events. +/// They are matched via the `#e` tag. One filter covers all roots. +/// +/// A negentropy sync reconciles them in a single session, not one per root. pub fn statuses_for(roots: impl IntoIterator) -> Filter { Filter::new() .kinds([ @@ -56,33 +56,29 @@ pub fn statuses_for(roots: impl IntoIterator) -> Filter { .events(roots) } -/// Cover notes (kind 1624) and NIP-32 label events (kind 1985) referencing -/// any of the given root events (`#e` tag), fetched per root like comments -/// and statuses because they carry no repository `a` tag. Batched, like -/// [`statuses_for`]. +/// Cover notes and NIP-32 label events referencing any of the given root events. +/// These are kinds 1624 and 1985, matched via the `#e` tag. +/// +/// Because they carry no repository `a` tag, they are fetched by root like comments. +/// +/// Batched, like [`statuses_for`]. pub fn annotations_for(roots: impl IntoIterator) -> Filter { Filter::new() .kinds([crate::COVER_NOTE_KIND, Kind::Label]) .events(roots) } -/// A user's grasp list (kind `10317`). +/// A user's grasp list, kind `10317`. pub fn grasp_list(public_key: PublicKey) -> Filter { Filter::new() .kind(Kind::GitUserGraspList) .author(public_key) } -/// NIP-22 comments (kind `1111`) referencing any of the given root events -/// (issues, patches, PRs). +/// NIP-22 comments, kind `1111`, referencing any of the given root events. +/// The roots are issues, patches and PRs. /// -/// Comments carry no repository `a` tag, so they must be fetched by their -/// root reference. NIP-22 defines the uppercase `E` tag as the thread root -/// (used by ngit), but some clients (including Signed) use a lowercase `e` -/// tag, so both are matched. -/// -/// Returns two filters because `#E` and `#e` conditions would be ANDed if -/// combined into one. +/// Returns two filters, since combining `#E` and `#e` would AND the conditions. pub fn comments_for(roots: impl IntoIterator) -> Vec { let roots: Vec = roots.into_iter().map(|id| id.to_hex()).collect(); if roots.is_empty() { @@ -98,49 +94,38 @@ pub fn comments_for(roots: impl IntoIterator) -> Vec { ] } -/// All repositories announced by an author. -pub fn announcements_by(public_key: PublicKey) -> Filter { - Filter::new() - .kind(Kind::GitRepoAnnouncement) - .author(public_key) -} - -/// All repository announcements (for global discovery). -/// -/// Unbounded: intended for negentropy sync, which reconciles sets -/// efficiently regardless of size. Local database queries with this -/// filter are served by LMDB, so they stay fast as the database grows. +/// All repository announcements, for global discovery. pub fn all_announcements() -> Filter { Filter::new().kind(Kind::GitRepoAnnouncement) } /// How far back deletion requests are fetched and stored. -/// -/// A deletion request can only target events created before it, and NIP-34 -/// events are all far younger than this window, so older requests can never -/// match anything shown. Bounding the window keeps the kind-5/62 set (one of -/// the largest on public relays) from being fully reconciled on every sync. const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400); -/// `now` minus [`DELETIONS_LOOKBACK`], quantized to whole days so identical -/// filters hash the same and the backend's sync dedup can match them. +/// `now` minus [`DELETIONS_LOOKBACK`]. +/// Quantized to whole days so identical filters hash the same. +/// +/// This lets the backend's sync dedup match identical filters. fn deletions_since() -> Timestamp { let now = Timestamp::now().as_secs(); Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK } -/// All deletion-related events (NIP-09 kind `5`, NIP-62 kind `62`) within -/// [`DELETIONS_LOOKBACK`]. Deletion requests must be known before any other -/// event can be shown. +/// All deletion-related events within [`DELETIONS_LOOKBACK`]. +/// These are NIP-09 kind `5` and NIP-62 kind `62`. +/// +/// Deletion requests must be known before any other event is shown. pub fn deletions() -> Filter { Filter::new() .kinds([Kind::EventDeletion, Kind::RequestToVanish]) .since(deletions_since()) } -/// Deletion events relevant to a single repository: requests authored by -/// the repository owner and requests addressed to the repository -/// coordinate (`#a` tag). +/// Deletion events relevant to a single repository. +/// +/// Requests authored by the repository owner. +/// +/// Requests addressed to the repository coordinate via its `#a` tag. pub fn deletions_for_repo(addr: &RepoAddr) -> Vec { vec![ Filter::new() diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index 5948d4d..c73a298 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -1,7 +1,6 @@ pub mod addr; pub mod annotations; pub mod clone_url; -pub mod comments; pub mod deletions; pub mod filters; pub mod model; @@ -11,7 +10,6 @@ pub mod status; pub use addr::{RepoAddr, identifier_from_name, repo_addr}; pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override}; pub use clone_url::{CloneTarget, parse_clone_url}; -pub use comments::{CommentThread, comment_threads}; pub use deletions::Deletions; pub use model::{Announcement, activity_subject, pull_request_patch, pull_request_patches}; pub use state::{build_state, parse_state}; diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 7a33340..8fc87c2 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -1,59 +1,52 @@ use std::collections::HashSet; -use gpui::SharedString; use nostr::prelude::*; -use crate::RepoAddr; +use crate::{RepoAddr, repo_addr}; -/// Parsed NIP-34 repository announcement (plain data, ready for the UI). +/// Parsed NIP-34 repository announcement, plain data ready for the UI. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Announcement { /// ID of the announcement event itself. pub event_id: EventId, - /// Repository ID (`d` tag). + /// Repository ID, the `d` tag. pub id: String, /// Author of the announcement event. pub owner: PublicKey, - /// When the announcement was published (for latest-wins resolution). + /// When the announcement was published, used for latest-wins resolution. pub created_at: Timestamp, - pub name: Option, - pub description: Option, + pub name: Option, + pub description: Option, /// Webpage URLs for browsing. pub web: Vec, /// URLs for `git clone`. pub clone: Vec, /// Relays the repository monitors for patches and issues. pub relays: Vec, - /// Earliest unique commit ID (`r` tag with `euc` marker). + /// Earliest unique commit ID, the `r` tag with `euc` marker. pub euc: Option, /// Other recognized maintainers. pub maintainers: Vec, - /// Value of a `u` tag, if any: this repository is a subordinate fork of - /// the referenced upstream (NIP-34). + /// Marks the repository as a subordinate fork of the upstream, per NIP-34. pub upstream: Option, - /// Hashtags labelling the repository (`t` tags). + /// Hashtags labelling the repository, the `t` tags. pub hashtags: Vec, } -/// The `u` tag of a fork announcement (NIP-34) -/// the repository this one is a subordinate fork of. The first value is -/// the upstream coordinate (`30617::`) or a git URL. -/// The second is an optional relay hint for the upstream. +/// The `u` tag of a fork announcement, per NIP-34. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Upstream { - /// Raw first value of the `u` tag (coordinate or git URL). + /// Raw first value of the `u` tag, a coordinate or git URL. pub raw: String, - /// The upstream `30617::` coordinate, when the `u` tag - /// references a NIP-34 repository; `None` for the git-URL form. + /// Upstream repository coordinate when the `u` tag names a NIP-34 repository. + /// `None` for the git-URL form. pub addr: Option, /// Relay hint for the upstream, if the `u` tag carries one. pub relay_hint: Option, } impl Upstream { - /// Parse the `u` tag values. The first is the upstream coordinate or a - /// git URL (the coordinate form may append `|git-url`; the coordinate is - /// the part before the first `|`), the second an optional relay hint. + /// Parse the `u` tag values. fn parse(raw: &str, relay_hint: Option<&str>) -> Self { let coordinate = raw.split('|').next().unwrap_or(raw); let addr = coordinate @@ -67,19 +60,18 @@ impl Upstream { } } - /// Text for display: the upstream coordinate when it is a NIP-34 - /// repository, otherwise the raw `u` value (git-URL form). - pub fn display(&self) -> SharedString { + /// Text for display. + pub fn display(&self) -> String { match &self.addr { - Some(addr) => SharedString::from(addr.to_string()), - None => SharedString::from(self.raw.clone()), + Some(addr) => addr.to_string(), + None => self.raw.clone(), } } } -/// Subject of a NIP-34 issue or pull request event: the `subject` tag, -/// falling back to the first non-empty line of the content. -pub fn activity_subject(event: &Event) -> SharedString { +/// Subject of a NIP-34 issue or pull request event. +/// Taken from the `subject` tag, else the first non-empty line of the content. +pub fn activity_subject(event: &Event) -> String { let subject = event .tags .iter() @@ -89,24 +81,18 @@ pub fn activity_subject(event: &Event) -> SharedString { }); subject - .map(SharedString::from) .or_else(|| { event .content .lines() .map(str::trim) .find(|line| !line.is_empty()) - .map(SharedString::from) + .map(|value| value.to_string()) }) - .unwrap_or(SharedString::from("Untitled")) + .unwrap_or("Untitled".to_string()) } -/// The patch set of a pull request: the root patch event (kind `1617`) the -/// PR references via its `e` tag, plus every patch of the set chained to it -/// with NIP-10 `e` reply tags, in series order (oldest first). When the PR -/// has no `e` tag, falls back to the patch producing the PR's tip commit -/// (its `commit`/`r` tag, per NIP-34) and walks the reply chain backward to -/// the root. +/// The patch set of a pull request. /// /// Returns an empty list when no patch event can be linked to the PR. pub fn pull_request_patches<'a>( @@ -115,17 +101,18 @@ pub fn pull_request_patches<'a>( ) -> Vec<&'a Event> { let patches: Vec<&'a Event> = patches.into_iter().collect(); - // The PR references its root patch via an `e` tag; follow the NIP-10 - // reply chain forward from there (each patch of the set replies to the - // previous one). Among several replies (a revision), the newest wins. + // The PR references its root patch via an `e` tag. + // Follow the NIP-10 reply chain forward from there. + // Each patch replies to the previous one, and among several replies the newest wins. if let Some(root_id) = pr.tags.event_ids().next() && let Some(root) = patches.iter().find(|patch| patch.id == root_id) { return forward_series(root, &patches); } - // No `e` tag: the last patch of the set carries the PR's tip commit in - // its `commit`/`r` tag; walk the reply chain backward to the root. + // The PR has no `e` tag. + // The last patch of the set carries the tip commit in its `commit` or `r` tag. + // Walk the reply chain backward to the root. let Some(tip) = current_commit_of(pr) else { return Vec::new(); }; @@ -156,10 +143,7 @@ pub fn pull_request_patches<'a>( series } -/// The patch content of a pull request: the contents of every patch event of -/// its patch set (see [`pull_request_patches`]) joined in series order, -/// falling back to the PR's own content for older PRs that carried the -/// patch inline. +/// The patch content of a pull request. pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator) -> String { let patches: Vec<&'a Event> = patches.into_iter().collect(); let series = pull_request_patches(pr, patches.iter().copied()); @@ -173,7 +157,7 @@ pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> { let mut series = vec![root]; loop { @@ -195,7 +179,7 @@ fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> series } -/// The `c` tag of an event (tip of the proposed branch), as hex. +/// The `c` tag of an event, the tip of the proposed branch, as hex. fn current_commit_of(event: &Event) -> Option { event .tags @@ -206,8 +190,9 @@ fn current_commit_of(event: &Event) -> Option { }) } -/// Whether `patch` produces `commit` (its `commit` or `r` tag), so clients -/// can find existing patches for a specific commit. +/// Whether `patch` produces `commit`, found via its `commit` or `r` tag. +/// +/// It lets clients find existing patches for a specific commit. fn patch_produces_commit(patch: &Event, commit: &str) -> bool { patch .tags @@ -219,7 +204,9 @@ fn patch_produces_commit(patch: &Event, commit: &str) -> bool { } impl Announcement { - /// Parse a kind `30617` event. Returns `None` if the kind is wrong or the `d` tag is missing. + /// Parse a kind `30617` event. + /// + /// Returns `None` when the kind is wrong or the `d` tag is missing. pub fn from_event(event: &Event) -> Option { if event.kind != Kind::GitRepoAnnouncement { return None; @@ -230,8 +217,8 @@ impl Announcement { let mut hashtags: Vec = Vec::new(); hashtags.extend(event.tags.hashtags().map(|t| t.to_string())); - let mut name: Option = None; - let mut description: Option = None; + let mut name: Option = None; + let mut description: Option = None; let mut web: Vec = Vec::new(); let mut clone: Vec = Vec::new(); let mut relays: Vec = Vec::new(); @@ -241,8 +228,8 @@ impl Announcement { for tag in event.tags.iter() { match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Name(value)) => name = Some(value.into()), - Ok(Nip34Tag::Description(value)) => description = Some(value.into()), + Ok(Nip34Tag::Name(value)) => name = Some(value), + Ok(Nip34Tag::Description(value)) => description = Some(value), Ok(Nip34Tag::Web(urls)) => web.extend(urls), Ok(Nip34Tag::Clone(urls)) => clone.extend(urls), Ok(Nip34Tag::Relays(urls)) => relays.extend(urls), @@ -251,8 +238,8 @@ impl Announcement { _ => {} } - // The `u` tag is not modelled by the SDK's `Nip34Tag`; parse it - // manually (first wins). + // The SDK's `Nip34Tag` does not model the `u` tag, so parse it manually. + // Only the first `u` tag is used. if upstream.is_none() && tag.kind() == "u" { let values = tag.as_slice(); let raw = values.get(1).map(String::as_str).unwrap_or_default(); @@ -280,21 +267,40 @@ impl Announcement { } /// The repository address of this announcement. - pub fn addr(&self) -> crate::RepoAddr { - crate::repo_addr(self.owner, self.id.clone()) + pub fn addr(&self) -> RepoAddr { + repo_addr(self.owner, self.id.clone()) + } + + /// The name of the repository, or a default if none is provided. + pub fn name(&self) -> String { + self.name.clone().unwrap_or("Untitled".into()) + } + + /// Whether this announcement is a fork of the repository at `base`. + /// Its `u` tag points at `base`, which also covers permanent forks whose EUC diverged. + /// + /// Or it shares `base`'s earliest unique commit and is not the base itself. + pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool { + if self.addr() == *base { + return false; + } + if self.upstream.as_ref().and_then(|u| u.addr.as_ref()) == Some(base) { + return true; + } + base_euc.is_some_and(|euc| self.euc.as_deref() == Some(euc)) } /// The description of the repository, or a default if none is provided. - pub fn description(&self) -> SharedString { + pub fn description(&self) -> String { self.description .clone() - .unwrap_or(SharedString::from("No description")) + .unwrap_or("No description".to_string()) } - /// The effective maintainers of this repository: the announced - /// `maintainers` plus the announcement author, who asserts themselves as - /// a maintainer of the primary project unless a `u` tag marks this - /// repository as a subordinate fork (NIP-34). + /// The effective maintainers of this repository, + /// the announced `maintainers` plus the announcement author. + /// + /// A `u` tag that marks the repository as a subordinate fork excludes them, per NIP-34. pub fn effective_maintainers(&self) -> Vec { let mut maintainers = self.maintainers.clone(); if self.upstream.is_none() && !maintainers.contains(&self.owner) { @@ -303,13 +309,12 @@ impl Announcement { maintainers } - /// The `git clone` URLs for this repository, deduplicated while - /// preserving the announced order (deterministic across calls). - pub fn clone_urls(&self) -> Vec { + /// The `git clone` URLs for this repository, deduplicated. + pub fn clone_urls(&self) -> Vec { let mut seen = HashSet::new(); self.clone .iter() - .map(|url| SharedString::from(format!("git clone {url}"))) + .map(|url| format!("git clone {url}")) .filter(|command| seen.insert(command.clone())) .collect() } @@ -449,8 +454,8 @@ mod tests { let announcement = Announcement::from_event(&event).expect("parses"); let upstream = announcement.upstream.expect("parses the u tag"); - // The coordinate part resolves to a repository address; the raw - // value keeps the `|git-url` suffix. + // The coordinate part resolves to a repository address. + // The raw value keeps the `|git-url` suffix. assert_eq!( upstream.addr, Some(crate::repo_addr( @@ -474,8 +479,8 @@ mod tests { #[test] fn parses_git_url_upstream() { - // The `u` tag may reference a non-nostr upstream by git URL only; - // there is no repository address to navigate to. + // The `u` tag may reference a non-nostr upstream by git URL only. + // There is no repository address to navigate to. let event = announcement_event(&[ &["d", "my-fork"], &["u", "https://example.com/upstream.git"], @@ -491,6 +496,77 @@ mod tests { ); } + #[test] + fn is_fork_of_matches_the_u_tag_coordinate() { + // The base repository, announced by the `u` tag's owner. + let base = crate::repo_addr( + PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"), + "upstream", + ); + let event = announcement_event(&[&["d", "my-fork"], &["u", &base.to_string()]]); + let fork = Announcement::from_event(&event).expect("parses"); + + // A `u` tag pointing at the base address marks a fork. + // This holds even when neither side announces an EUC. + assert!(fork.is_fork_of(&base, None)); + } + + #[test] + fn is_fork_of_matches_a_shared_euc() { + let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; + // The base repo has no `u` tag. It announces the family EUC. + let base_event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]); + let base = Announcement::from_event(&base_event).expect("parses"); + let base_addr = base.addr(); + + // A fork with no `u` tag, a pure mirror or cross-hosted clone, shares the EUC. + // Clients of the family can then find it. + let fork_event = announcement_event(&[&["d", "mirror"], &["r", euc, "euc"]]); + let fork = Announcement::from_event(&fork_event).expect("parses"); + assert!(fork.is_fork_of(&base_addr, base.euc.as_deref())); + + // An unrelated repository with a different EUC is not a fork. + let other_event = announcement_event(&[ + &["d", "other"], + &["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"], + ]); + let other = Announcement::from_event(&other_event).expect("parses"); + assert!(!other.is_fork_of(&base_addr, base.euc.as_deref())); + + // Without a base EUC there is nothing to compare against. + assert!(!fork.is_fork_of(&base_addr, None)); + } + + #[test] + fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() { + // A permanent fork re-announces its EUC, the first commit after the fork. + // Only the `u` tag still relates it to the base. + let base = crate::repo_addr( + PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"), + "upstream", + ); + let base_euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; + let event = announcement_event(&[ + &["d", "my-fork"], + &["u", &base.to_string()], + &["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"], + ]); + let fork = Announcement::from_event(&event).expect("parses"); + + assert!(fork.is_fork_of(&base, Some(base_euc))); + } + + #[test] + fn is_fork_of_excludes_the_base_itself() { + let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; + let event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]); + let base = Announcement::from_event(&event).expect("parses"); + let base_addr = base.addr(); + + // The base announcement matches its own EUC but is not a fork of itself. + assert!(!base.is_fork_of(&base_addr, base.euc.as_deref())); + } + #[test] fn effective_maintainers_include_owner_for_primary_repos() { let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]); @@ -498,8 +574,8 @@ mod tests { let announcement = Announcement::from_event(&event).expect("parses"); let maintainers = announcement.effective_maintainers(); - // The owner asserts themselves as a maintainer of the primary - // project (NIP-34), alongside the announced co-maintainers. + // The owner asserts themselves as a maintainer of the primary project, per NIP-34. + // Announced co-maintainers are included too. assert_eq!(maintainers.len(), 2); assert!(maintainers.contains(&announcement.owner)); assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"))); @@ -516,8 +592,8 @@ mod tests { let announcement = Announcement::from_event(&event).expect("parses"); let maintainers = announcement.effective_maintainers(); - // A `u` tag marks the repository as a subordinate fork: the author - // is not a maintainer of the primary project (NIP-34). + // A `u` tag marks the repository as a subordinate fork. + // The author is then not a maintainer of the primary project, per NIP-34. assert!(!maintainers.contains(&announcement.owner)); assert_eq!( maintainers, @@ -545,7 +621,7 @@ mod tests { #[test] fn pull_request_patch_falls_back_to_inline_content() { - // Older PRs carried the patch in the content; no linked patch event. + // Older PRs carried the patch in the content and link no patch event. let pr = pr_event("patch-inline", vec![]); assert_eq!(pull_request_patch(&pr, [] as [&Event; 0]), "patch-inline"); @@ -572,8 +648,8 @@ mod tests { #[test] fn pull_request_patch_joins_the_whole_patch_set() { - // NIP-34: a PR references the root patch; later patches of the set - // reply to the previous one (NIP-10 `e` tags). + // A PR references the root patch, per NIP-34. + // Later patches of the set reply to the previous one via NIP-10 `e` tags. let root = patch_event("patch-one", vec![], 100); let second = patch_event("patch-two", vec![Tag::event(root.id)], 200); let pr = pr_event("description", vec![Tag::event(root.id)]); @@ -625,8 +701,8 @@ mod tests { #[test] fn pull_request_patches_finds_the_set_via_the_tip_commit() { - // PRs without an `e` tag: the last patch of the set carries the tip - // commit in its `r` tag; walk the reply chain backward to the root. + // PRs without an `e` tag fall back to the patch producing the tip commit. + // Walk the reply chain backward to the root. let root = patch_event("patch-one", vec![], 100); let tip = "1111111111111111111111111111111111111111"; let last = patch_event( diff --git a/crates/signed_core/src/state.rs b/crates/signed_core/src/state.rs index 6032748..53e9c9d 100644 --- a/crates/signed_core/src/state.rs +++ b/crates/signed_core/src/state.rs @@ -1,10 +1,9 @@ use nostr::prelude::*; -/// Build a kind `30618` repository state event from refs and HEAD. +/// Build a kind `30618` repository state event from refs and HEAD, +/// it is published as `ref: refs/heads/`. /// -/// `refs` are `(refname, commit-id)` pairs (e.g. `refs/heads/main`); `head` -/// is the short branch name HEAD points to, published as -/// `ref: refs/heads/`. The `d` tag matches the repository id. +/// The `d` tag matches the repository id. pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder { let mut tags: Vec = vec![Tag::identifier(id.to_owned())]; for (name, commit) in refs { @@ -20,8 +19,8 @@ pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> E /// Parse a kind `30618` repository state event into refs and HEAD. /// -/// `refs` are `(refname, commit-id)` pairs; `head` is the branch pointed to -/// by the `HEAD` tag, if any. +/// `refs` are `(refname, commit-id)` pairs. +/// `head` is the branch pointed to by the `HEAD` tag, if any. pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option) { let mut refs = Vec::new(); let mut head = None; diff --git a/crates/signed_core/src/status.rs b/crates/signed_core/src/status.rs index 01b3d3f..abc24d2 100644 --- a/crates/signed_core/src/status.rs +++ b/crates/signed_core/src/status.rs @@ -1,6 +1,6 @@ use nostr::prelude::*; -/// Status of a root patch, pull request or issue (kinds `1630..=1633`). +/// Status of a root patch, pull request or issue, kinds `1630..=1633`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum RepoStatus { Open, @@ -30,9 +30,9 @@ impl RepoStatus { } } -/// Check whether an event references the given root event via an `e` or `E` -/// tag. NIP-10 / NIP-34 use the lowercase `e` tag; NIP-22 comments (kind -/// `1111`) use the uppercase `E` tag for the root of the thread. +/// NIP-10 and NIP-34 use the lowercase `e` tag. +/// +/// NIP-22 comments, kind `1111`, use the uppercase `E` tag for the thread root. pub fn references_root(event: &Event, root: &EventId) -> bool { let root = root.to_hex(); event @@ -41,8 +41,8 @@ pub fn references_root(event: &Event, root: &EventId) -> bool { .any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str())) } -/// Resolve the status of a root event per NIP-34: -/// the most recent status event from the root author or a maintainer wins. +/// Resolve the status of a root event per NIP-34. +/// /// Defaults to [`RepoStatus::Open`]. pub fn resolve_status<'a, I>( status_events: I, diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 835ec77..b021782 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -20,6 +20,11 @@ impl GitCache { Self { root } } + /// The root directory holding the mirror clones. + pub fn root(&self) -> &Path { + &self.root + } + /// Local path of the clone for a repository. pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf { self.root @@ -38,8 +43,7 @@ impl GitCache { } } - /// Open the local clone if it exists (fetching first), otherwise clone - /// from the first working URL in `clone_urls` (the announcement's `clone` tag). + /// Open the existing clone, fetching it first. pub fn ensure_clone(&self, addr: &RepoAddr, clone_urls: &[String]) -> Result { let path = self.repo_path(addr); @@ -59,20 +63,17 @@ impl GitCache { } } -/// Maximum directory nesting depth when scanning for local repositories, -/// so pathological trees can't stall the scan. +/// Maximum directory nesting depth when scanning for local repositories. +/// +/// Pathological trees can't stall the scan. const SCAN_MAX_DEPTH: usize = 12; -/// Directories never descended into during a scan: dependency caches that -/// can be enormous without ever containing user repositories. -const SCAN_SKIPPED_DIRS: [&str; 1] = ["node_modules"]; - -/// Walk `root` recursively and collect the paths of git repositories -/// (directories containing a `.git` entry) below it. +/// Directories never descended into during a scan. /// -/// Hidden entries and symlinks are skipped; repositories are not descended -/// into, so nested ones (e.g. submodule worktrees) are not reported. -/// Results are canonicalized, deduplicated and sorted. +/// Dependency caches can be enormous without ever containing user repositories. +const SCAN_SKIPPED_DIR: &str = "node_modules"; + +/// Walk `root` recursively and collect the paths of git repositories below it. pub fn find_git_repos(root: &Path) -> Vec { let mut repos = Vec::new(); if !root.is_dir() { @@ -84,8 +85,9 @@ pub fn find_git_repos(root: &Path) -> Vec { if depth > SCAN_MAX_DEPTH { continue; } - // A directory containing a `.git` entry is a repository (a linked - // worktree has a `.git` file instead of a directory); don't descend. + // A directory containing a `.git` entry is a repository. + // A linked worktree has a `.git` file instead of a directory. + // Don't descend into repositories. if dir.join(".git").exists() { if let Ok(path) = dir.canonicalize() { repos.push(path); @@ -105,7 +107,7 @@ pub fn find_git_repos(root: &Path) -> Vec { } let file_name = entry.file_name(); let name = file_name.to_string_lossy(); - if name.starts_with('.') || SCAN_SKIPPED_DIRS.contains(&name.as_ref()) { + if name.starts_with('.') || name == SCAN_SKIPPED_DIR { continue; } stack.push((entry.path(), depth + 1)); @@ -117,10 +119,7 @@ pub fn find_git_repos(root: &Path) -> Vec { repos } -/// Clone a repository into `path` from the first working URL in -/// `clone_urls` (the announcement's `clone` tag), then fetch the -/// `refs/nostr/*` PR refs like the cache clone does. The destination must -/// not exist yet. When no URL works, the last error is returned. +/// Clone into `path` from the first working URL in `clone_urls`. /// /// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache. pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> { @@ -128,29 +127,16 @@ pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> { bail!("destination {} already exists", path.display()); } - let mut last_err: Option = None; - - for url in clone_urls { - match clone(url, path) { - Ok(repo) => { - // The initial clone uses the default refspecs; also - // fetch the `refs/nostr/*` PR refs. - fetch_all(&repo).ok(); - return Ok(()); - } - Err(e) => last_err = Some(e), - } - } - - match last_err { - Some(e) => Err(e).context("failed to clone from any mirror"), - None => bail!("no clone URLs provided"), - } + try_each_url(clone_urls, "clone", |url| { + let repo = clone(url, path)?; + // The initial clone uses the default refspecs. + // Also fetch the `refs/nostr/*` PR refs. + fetch_all(&repo).ok(); + Ok(()) + }) } -/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` -/// namespace where GRASP mirrors serve pull request branches (one ref per -/// PR event id, as used by ngit). +/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace. pub fn fetch_all(repo: &gix::Repository) -> Result<()> { let options = gix::remote::ref_map::Options { extra_refspecs: vec![ @@ -169,10 +155,10 @@ pub fn fetch_all(repo: &gix::Repository) -> Result<()> { Ok(()) } -/// Apply a `git format-patch` patch (or series) with `git am`. +/// Apply a `git format-patch` patch or series with `git am`, +/// uses the git CLI because it handles the mbox format natively. /// -/// Uses the git CLI because it handles the mbox format natively; can be -/// replaced with a pure-Rust implementation later without changing callers. +/// TODO: Replaced with a pure-Rust implementation later without changing callers. pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> { let mut child = Command::new("git") .arg("am") @@ -195,35 +181,28 @@ pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> { Ok(()) } -/// The merge base of two revisions (branch names, remote-tracking refs or -/// commit ids) in the repository at `repo_path`. `Ok(None)` when the -/// revisions share no common ancestor; unresolvable revisions are errors. +/// The merge base of two revisions in the repository at `repo_path`, +/// revisions may be branch names, remote-tracking refs or commit ids. +/// +/// `Ok(None)` when the revisions share no common ancestor. +/// +/// Unresolvable revisions are errors. pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result> { - let output = Command::new("git") - .arg("-C") - .arg(repo_path) - .args(["merge-base", a, b]) - .env("GIT_TERMINAL_PROMPT", "0") - .stderr(Stdio::piped()) - .output() - .context("failed to spawn `git merge-base`")?; - - match output.status.code() { - // Exit 1: no common ancestor (a valid outcome for a proposal). - Some(1) => Ok(None), - Some(0) => Ok(Some( - String::from_utf8_lossy(&output.stdout).trim().to_owned(), - )), - _ => bail!( - "git merge-base failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ), + let repo = open_with_cache(repo_path)?; + let a = repo.rev_parse_single(a.as_bytes())?; + let b = repo.rev_parse_single(b.as_bytes())?; + match repo.merge_base(a, b) { + Ok(id) => Ok(Some(id.to_string())), + // No common ancestor, a valid outcome for a proposal. + Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None), + Err(e) => Err(e.into()), } } -/// The `git format-patch` series of `base..tip` (mbox), like -/// `git format-patch --stdout`. Fails when the range has no commits. The -/// mbox is returned untrimmed; trailing newlines are part of the format. +/// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`. +/// Fails when the range has no commits. +/// +/// The mbox is returned untrimmed. Trailing newlines are part of the format. pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result { let output = Command::new("git") .arg("-C") @@ -247,41 +226,7 @@ pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result Result<()> { - let mut child = Command::new("git") - .arg("apply") - .args(["--check", "--3way", "--whitespace=nowarn", "-"]) - .current_dir(repo_path) - .stdin(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .context("failed to spawn `git apply --check`")?; - - child - .stdin - .as_mut() - .expect("stdin piped") - .write_all(patch.as_bytes())?; - - let output = child.wait_with_output()?; - if !output.status.success() { - bail!( - "patch does not apply: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - Ok(()) -} - -/// Push `commit` to `reference` (e.g. `refs/nostr/`) on the git -/// server at `url`, from the repository at `repo_path`. GRASP servers host -/// the `refs/nostr` namespace so anyone can contribute a commit; nak pushes -/// pull request tips there before publishing the PR event, and readers -/// fetch the ref to get the commit behind a PR's `c` tag. +/// Push `commit` to `reference` on the server at `url`, from `repo_path`. pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> { let output = Command::new("git") .arg("-C") @@ -303,10 +248,10 @@ pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &st Ok(()) } -/// Split a `git format-patch` series into its individual patches (mbox -/// messages). Each message begins with a `From <40-hex> ` boundary line; -/// `>From` quoting inside bodies means no false positives. A single patch -/// yields one element; a malformed input yields one element covering it. +/// Split a `git format-patch` series into its individual patches, mbox messages. +/// +/// A single patch yields one element. +/// A malformed input yields one element covering it. pub fn split_patch_series(patch: &str) -> Vec<&str> { let mut starts = vec![0usize]; let mut search_from = 1; @@ -332,17 +277,11 @@ pub fn split_patch_series(patch: &str) -> Vec<&str> { .collect() } -/// The commit HEAD points to in the repository at `repo_path`, or `None` -/// when the repository has no commits yet (unborn HEAD). +/// The commit HEAD points to in the repository at `repo_path`. +/// +/// `None` when the repository has no commits yet, an unborn HEAD. pub fn head_commit_id(repo_path: &Path) -> Result> { - let output = Command::new("git") - .arg("-C") - .arg(repo_path) - .args(["rev-parse", "HEAD"]) - .env("GIT_TERMINAL_PROMPT", "0") - .stderr(Stdio::piped()) - .output() - .context("failed to spawn `git rev-parse`")?; + let output = git_output(repo_path, &["rev-parse", "HEAD"], "git rev-parse")?; if !output.status.success() { return Ok(None); @@ -352,16 +291,17 @@ pub fn head_commit_id(repo_path: &Path) -> Result> { )) } -/// The commits in `base..HEAD` of the repository at `repo_path`, oldest -/// first (the order `git am` creates them); `HEAD` alone when `base` is -/// `None`. An empty range yields an empty list. +/// The commits in `base..HEAD` of the repository at `repo_path`, oldest first. +/// This is the order `git am` creates them. +/// +/// `HEAD` alone when `base` is `None`. pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result> { let output = match base { Some(base) => git_in( repo_path, &["rev-list", "--reverse", &format!("{base}..HEAD")], )?, - // No `base` (unborn HEAD): there is nothing to walk yet. + // Without `base`, an unborn HEAD means there is nothing to walk. None => match git_in(repo_path, &["rev-parse", "HEAD"]) { Ok(head) => head, Err(_) => return Ok(Vec::new()), @@ -374,13 +314,41 @@ pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result .collect()) } -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://") +/// Rewrite a grasp server URL to the https URL the git transport actually uses. +/// +/// GRASP servers announce `grasp:////` clone URLs. +/// The transport is git smart HTTP, so the scheme is rewritten for gix. +fn transport_url(url: &str) -> String { + url.strip_prefix("grasp://") .map(|rest| format!("https://{rest}")) - .unwrap_or_else(|| url.to_owned()); + .unwrap_or_else(|| url.to_owned()) +} + +/// Run `attempt` against each URL in `urls` until one succeeds. +/// +/// Returns the last error wrapped in `failed to {verb} from any mirror`, +/// or `no clone URLs provided` when the list is empty. +fn try_each_url(urls: &[String], verb: &str, mut attempt: F) -> Result<()> +where + F: FnMut(&str) -> Result<()>, +{ + let mut last_err: Option = None; + + for url in urls { + match attempt(url) { + Ok(()) => return Ok(()), + Err(e) => last_err = Some(e), + } + } + + match last_err { + Some(e) => Err(e).context(format!("failed to {verb} from any mirror")), + None => bail!("no clone URLs provided"), + } +} + +fn clone(url: &str, path: &Path) -> Result { + let url = transport_url(url); let url = gix::url::parse(url).context("invalid clone URL")?; let mut prepare = gix::prepare_clone(url, path)?; @@ -390,12 +358,12 @@ 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. +/// Create a repository at `path` with an initial `main` branch. +/// Write a `README.md` from `name` and `description`, then create the initial commit. /// -/// Uses the git CLI (like [`apply_patch`]), which handles index writes, -/// ref updates and default branch selection natively. +/// Returns the initial commit id. +/// +/// Uses the git CLI, like [`apply_patch`]. 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()))?; @@ -410,8 +378,8 @@ pub fn init_repository(path: &Path, name: &str, description: &str) -> Result Result 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(()) + push_refspecs( + repo_path, + base_url, + owner, + repo_id, + &["refs/heads/main:refs/heads/main"], + ) } -/// Push every local branch and tag of the repository at `repo_path` to a grasp server, -/// so an initialized repository's whole history is mirrored. +/// Push every local branch and tag of the repository at `repo_path` to a grasp server. +/// +/// This mirrors an initialized repository's whole history. pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { + push_refspecs( + repo_path, + base_url, + owner, + repo_id, + &["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], + ) +} + +/// Push `refspecs` to the grasp server URL derived from `base_url`, `owner` and `repo_id`. +fn push_refspecs( + repo_path: &Path, + base_url: &str, + owner: &str, + repo_id: &str, + refspecs: &[&str], +) -> Result<()> { let url = format!("{base_url}/{owner}/{repo_id}.git"); - let output = Command::new("git") - .arg("-C") - .arg(repo_path) - .args(["push"]) - .arg(&url) - .args(["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"]) - .env("GIT_TERMINAL_PROMPT", "0") - .stderr(Stdio::piped()) - .output() - .context("failed to spawn `git push`")?; + let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2); + args.push("push"); + args.push(&url); + args.extend_from_slice(refspecs); + + let output = git_output(repo_path, &args, "git push")?; if !output.status.success() { bail!( @@ -482,25 +451,22 @@ pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> String::from_utf8_lossy(&output.stderr).trim() ); } - Ok(()) } -/// The earliest unique commit of the repository at `repo_path` (a root -/// commit, like `git rev-list --max-parents=0 HEAD`), used as the NIP-34 -/// announcement's `euc` marker. `None` for a repository without commits. +/// The earliest unique commit of the repository at `repo_path`. +/// Used as the NIP-34 announcement's `euc` marker. +/// +/// `None` for a repository without commits. pub fn root_commit(repo_path: &Path) -> Result> { - let output = Command::new("git") - .arg("-C") - .arg(repo_path) - .args(["rev-list", "--max-parents=0", "HEAD"]) - .env("GIT_TERMINAL_PROMPT", "0") - .stderr(Stdio::piped()) - .output() - .context("failed to spawn `git rev-list`")?; + let output = git_output( + repo_path, + &["rev-list", "--max-parents=0", "HEAD"], + "git rev-list", + )?; - // An unborn HEAD (no commits yet) makes `rev-list` fail, - // there is no unique commit to report then. + // An unborn HEAD with no commits yet makes `rev-list` fail. + // There is no unique commit to report then. if !output.status.success() { return Ok(None); } @@ -513,27 +479,198 @@ pub fn root_commit(repo_path: &Path) -> Result> { } /// 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 remote add` already configures the default fetch refspec. 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") +/// Point `origin` at `url`, replacing an existing remote, +/// used after a clone whose `origin` points at the cloned-from path. +/// +/// A working copy cloned from a local mirror is re-targeted at the grasp server. +pub fn set_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() { + git_in(repo_path, &["remote", "set-url", "origin", url])?; + } else { + git_in(repo_path, &["remote", "add", "origin", url])?; + } + Ok(()) +} + +/// Fetch `refspec` into `repo_path` from the first working URL in `urls`. +/// When no URL works, the last error is returned. +/// +/// Never touches the checked-out refs or the worktree. +pub fn fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) -> Result<()> { + try_each_url(urls, "fetch", |url| { + let url = transport_url(url); + + let output = git_output(repo_path, &["fetch", &url, refspec], "git fetch")?; + + if output.status.success() { + return Ok(()); + } + bail!( + "git fetch from {url} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ) + }) +} + +/// Full ref names under `prefix`, sorted lexicographically, like `git for-each-ref`. +/// `prefix` is a ref namespace like `refs/fork//`. +/// +/// Returns an empty list when nothing matches. +pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result> { + // `for-each-ref` patterns match whole path components. + // A trailing slash would silently change what is matched. + let pattern = prefix.trim_end_matches('/'); + let output = git_output( + repo_path, + &["for-each-ref", "--format=%(refname)", pattern], + "git for-each-ref", + )?; + + if !output.status.success() { + bail!( + "git for-each-ref failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::to_owned) + .filter(|name| !name.is_empty()) + .collect()) +} + +/// Delete every ref under `prefix` of the repository at `repo_path`. +/// `prefix` is a ref namespace like `refs/fork//`. +/// +/// Lets a stale import be pruned before a re-import. +/// +/// No-op when nothing matches. +pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> { + let refs = refs_with_prefix(repo_path, prefix)?; + if refs.is_empty() { + return Ok(()); + } + + let mut child = Command::new("git") + .arg("-C") + .arg(repo_path) + .args(["update-ref", "--stdin"]) + .env("GIT_TERMINAL_PROMPT", "0") + .stdin(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("failed to spawn `git update-ref --stdin`")?; + + for name in refs { + child + .stdin + .as_mut() + .expect("stdin piped") + .write_all(format!("delete {name}\n").as_bytes())?; + } + + let output = child.wait_with_output()?; + if !output.status.success() { + bail!( + "git update-ref failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +/// The URL of the `origin` remote of the repository at `workdir`. +/// +/// `None` when it has no `origin` yet. +pub fn origin_url(workdir: &Path) -> Result> { + let output = git_output( + workdir, + &["remote", "get-url", "origin"], + "git remote get-url", + )?; + + if !output.status.success() { + return Ok(None); + } + let url = String::from_utf8_lossy(&output.stdout); + Ok((!url.trim().is_empty()).then(|| url.trim().to_owned())) +} + +/// Fast-forward local branches that trail their remote-tracking counterpart. +/// +/// Returns whether any branch moved. +pub fn fast_forward_branches(workdir: &Path) -> Result { + let current = git_in(workdir, &["branch", "--show-current"]).unwrap_or_default(); + let heads = refs_with_prefix(workdir, "refs/heads")?; + let mut moved = false; + + for head in heads { + let Some(branch) = head.strip_prefix("refs/heads/") else { + continue; + }; + let remote = format!("refs/remotes/origin/{branch}"); + // No remote-tracking counterpart means the remote lacks this branch. + let Ok(remote_oid) = git_in(workdir, &["rev-parse", "--verify", "--quiet", &remote]) else { + continue; + }; + let Ok(local_oid) = git_in(workdir, &["rev-parse", "--verify", "--quiet", &head]) else { + continue; + }; + if local_oid == remote_oid { + continue; + } + // Only fast-forward. + // Local-only commits or diverged history must never be rewritten by a refresh. + if git_in(workdir, &["merge-base", "--is-ancestor", &head, &remote]).is_err() { + continue; + } + if current == branch { + // Merge so the checked-out worktree follows the branch. + if git_in(workdir, &["merge", "--ff-only", &remote]).is_ok() { + moved = true; + } + } else { + git_in(workdir, &["update-ref", &head, &remote_oid])?; + moved = true; + } + } + + Ok(moved) +} + +/// Run `git -C dir args`, disabling the terminal prompt and capturing stderr. +/// +/// `what` names the command in the spawn error. +fn git_output(dir: &Path, args: &[&str], what: &str) -> Result { + Command::new("git") .arg("-C") .arg(dir) .args(args) .env("GIT_TERMINAL_PROMPT", "0") .stderr(Stdio::piped()) .output() - .context("failed to spawn `git`")?; + .with_context(|| format!("failed to spawn `{what}`")) +} + +/// 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 = git_output(dir, args, "git")?; if !output.status.success() { bail!( @@ -546,9 +683,10 @@ fn git_in(dir: &Path, args: &[&str]) -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) } -/// Map an untrusted repository id (or display name) to a safe single path -/// component: everything outside `[A-Za-z0-9._-]` becomes `_`, and the -/// special components `.` and `..` are rejected. +/// Map an untrusted repository id or display name to a safe single path component. +/// +/// Everything outside `[A-Za-z0-9._-]` becomes `_`. +/// An id that maps to exactly `.` or `..` becomes `_`. pub fn sanitize_path_component(id: &str) -> String { let sanitized: String = id .chars() @@ -568,20 +706,21 @@ pub fn sanitize_path_component(id: &str) -> String { sanitized } -/// In-memory object cache for history walks (see [`open_with_cache`]). -/// Without one, every walk re-decodes the same commit objects from the -/// object database. +/// In-memory object cache for history walks, see [`open_with_cache`]. +/// +/// Without one, a walk re-decodes the same commit objects from the object database. +/// Sized generously: a walk can cover a large portion of the repository's history. const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024; /// Metadata of a commit, as shown in the repository browser's file header. #[derive(Debug, Clone)] pub struct FileCommit { - /// Shortened commit id (7+ hex chars, disambiguated if needed). + /// Shortened commit id, 7+ hex chars, disambiguated if needed. pub id: String, /// First line of the commit message. pub summary: String, - /// Rest of the commit message after the title; `None` when there is no - /// body (single-line commit messages). + /// Rest of the commit message after the title. + /// `None` for single-line commit messages. pub description: Option, /// Author name. pub author: String, @@ -589,9 +728,9 @@ pub struct FileCommit { pub time: i64, } -/// Relative paths of all entries in the worktree (files and directories), -/// directories first, then alphabetically within each group. The `.git` -/// directory is skipped. +/// Relative paths of all entries in the worktree, files and directories. +/// +/// The `.git` directory is skipped. pub fn worktree_entries(repo: &gix::Repository) -> Result> { let workdir = repo.workdir().context("repository has no worktree")?; @@ -606,8 +745,9 @@ pub fn worktree_entries(repo: &gix::Repository) -> Result> { Ok(entries.into_iter().map(|(path, _)| path).collect()) } -/// Read a file from the worktree. Returns `Ok(None)` if the path is missing -/// or not a regular file. +/// Read a file from the worktree. +/// +/// Returns `Ok(None)` if the path is missing or not a regular file. pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result>> { let workdir = repo.workdir().context("repository has no worktree")?; let path = workdir.join(rel); @@ -620,9 +760,9 @@ pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result Result> { let Some(workdir) = repo.workdir() else { return Ok(None); @@ -658,19 +798,35 @@ pub fn find_readme(repo: &gix::Repository) -> Result> { .and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf))) } -/// Open the repository at `workdir` with an in-memory object cache sized for -/// history walks. +/// Open the repository at `workdir` with an in-memory object cache. +/// +/// Only history walks use it, they re-decode the same commit objects repeatedly. +/// Single-object reads open the repository plain. fn open_with_cache(workdir: &Path) -> Result { let mut repo = gix::open(workdir)?; repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES); Ok(repo) } -/// A [`FileCommit`] from a walk commit: author, message title and shortened -/// id. `include_description` controls whether the message body is copied; -/// history lists never display it, so skipping it saves a string allocation -/// per listed commit (the diff panel fetches the full commit on demand). -fn file_commit(commit: &gix::Commit<'_>, include_description: bool) -> Result { +/// A [`FileCommit`] from a commit, with author, message title, body and shortened id. +/// +/// The diff panel fetches the full commit on demand. +fn file_commit(commit: &gix::Commit<'_>) -> Result { + file_commit_with_description(commit, true) +} + +/// A [`FileCommit`] without the message body, for history lists that never display it. +/// +/// Skipping the body saves an allocation per listed commit. +fn file_commit_summary(commit: &gix::Commit<'_>) -> Result { + file_commit_with_description(commit, false) +} + +/// [`file_commit`] and [`file_commit_summary`], `include_description` picks the body. +fn file_commit_with_description( + commit: &gix::Commit<'_>, + include_description: bool, +) -> Result { let author = commit.author()?; let message = commit.message()?; Ok(FileCommit { @@ -689,10 +845,9 @@ fn file_commit(commit: &gix::Commit<'_>, include_description: bool) -> Result`: the first commit, walking from -/// `HEAD` newest-first, whose tree entry for `rel` differs from its first -/// parent's. `Ok(None)` when no commit touched the file (e.g. untracked). +/// Find the most recent commit that changed `rel`, a path relative to the worktree. +/// +/// `Ok(None)` when no commit touched the file, e.g. an untracked file. pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result> { let rel = rel.to_path_buf(); Ok(last_commits(repo, std::slice::from_ref(&rel))? @@ -701,10 +856,10 @@ pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result` per path, found in a single history walk: every -/// commit is decoded once and shared across all paths. Paths without any -/// commit (e.g. untracked files) are absent from the result. +/// Newest commit touching each of `rels`, like `git log -1 -- ` per path. +/// `rels` are paths relative to the worktree. +/// +/// Paths without any commit, like untracked files, are absent from the result. pub fn worktree_last_commits( workdir: &Path, rels: &[PathBuf], @@ -712,8 +867,9 @@ pub fn worktree_last_commits( last_commits(&open_with_cache(workdir)?, rels) } -/// The walk behind [`last_commit`] and [`worktree_last_commits`], stopping as -/// soon as every pending path has its commit. +/// The walk behind [`last_commit`] and [`worktree_last_commits`]. +/// +/// Stops as soon as every pending path has its commit. fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result> { use gix::traverse::commit::simple::CommitTimeOrder; @@ -749,8 +905,8 @@ fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result None, }; - // Compare each still-unresolved path against this commit and its - // first parent; resolved paths leave the pending set. + // Compare each unresolved path against this commit and its first parent. + // Resolved paths leave the pending set. let mut ix = 0; while ix < pending.len() { let rel = &pending[ix]; @@ -762,7 +918,7 @@ fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result Result Result { use gix::traverse::commit::simple::CommitTimeOrder; @@ -811,14 +967,15 @@ pub fn all_commits(repo: &gix::Repository) -> Result { let info = info?; total += 1; if commits.len() < MAX_LISTED_COMMITS { - commits.push(file_commit(&info.object()?, false)?); + commits.push(file_commit_summary(&info.object()?)?); } } Ok(CommitList { total, commits }) } -/// Like [`all_commits`], but opens the repository located at `workdir` -/// (for non-bare clones the clone root is the worktree) first. +/// Like [`all_commits`], but opens the repository at `workdir` first. +/// +/// For non-bare clones the clone root is the worktree. pub fn worktree_all_commits(workdir: &Path) -> Result { all_commits(&open_with_cache(workdir)?) } @@ -846,8 +1003,7 @@ pub struct DiffLine { pub text: String, } -/// A hunk of a file diff, like `@@ -a,b +c,d @@`, with the lines between the -/// two headers (context around the change, then removals and additions). +/// A hunk of a file diff, like `@@ -a,b +c,d @@`. #[derive(Debug, Clone)] pub struct DiffHunk { /// 1-based start line in the old version. @@ -874,33 +1030,32 @@ pub enum DiffStatus { /// The diff of one file in a commit. #[derive(Debug, Clone)] pub struct FileDiff { - /// Path of the file relative to the repo root (the destination path for - /// renames and copies). + /// Path of the file relative to the repo root. + /// For renames and copies, this is the destination path. pub path: String, /// Previous path, for renames and copies. pub old_path: Option, pub status: DiffStatus, - /// Number of added lines; 0 for binary files. + /// Number of added lines, 0 for binary files. pub insertions: usize, - /// Number of removed lines; 0 for binary files. + /// Number of removed lines, 0 for binary files. pub deletions: usize, - /// True if either version is binary (then `hunks` is empty). + /// True if either version is binary, then `hunks` is empty. pub binary: bool, pub hunks: Vec, } -/// The changes of one commit: every file it added, modified, deleted or -/// renamed, with line-level hunks for text files. +/// The changes of one commit. #[derive(Debug, Clone)] pub struct CommitDiff { pub files: Vec, } -/// The changes of the commit `id` (short or full) in the repository at -/// `workdir`, compared against its first parent (the empty tree for the -/// root commit), like `git show`. Files are sorted by path. +/// The changes of the commit `id`, short or full, in the repository at `workdir`. +/// +/// Compared against its first parent, the empty tree for the root commit. pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result { - commit_diff(&open_with_cache(workdir)?, id) + commit_diff(&gix::open(workdir)?, id) } fn commit_diff(repo: &gix::Repository, id: &str) -> Result { @@ -914,11 +1069,11 @@ fn commit_diff(repo: &gix::Repository, id: &str) -> Result { tree_diff(repo, old_tree.as_ref(), &new_tree) } -/// The changes between two commits (`base`..`tip`), like `git diff base tip`. -/// Same file handling as [`worktree_commit_diff`] (directories and -/// submodules are skipped, files are sorted by path). +/// The changes between two commits, `base`..`tip`, like `git diff base tip`. +/// +/// Directories and submodules are skipped, files are sorted by path. pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result { - let repo = open_with_cache(workdir)?; + let repo = gix::open(workdir)?; let base_tree = repo .rev_parse_single(base.as_bytes())? .object()? @@ -953,13 +1108,12 @@ pub fn worktree_commit_range_commits( let mut commits = Vec::new(); for info in walk.all()? { let info = info?; - commits.push(file_commit(&info.object()?, false)?); + commits.push(file_commit_summary(&info.object()?)?); } Ok(commits) } -/// The changes between two trees, used by both [`commit_diff`] and -/// [`worktree_commit_range_diff`]. +/// The changes between two trees. Used by both [`commit_diff`] and [`worktree_commit_range_diff`]. fn tree_diff( repo: &gix::Repository, old_tree: Option<&gix::Tree<'_>>, @@ -976,8 +1130,7 @@ fn tree_diff( for change in changes { let attached = Change::from_change_ref(change.to_ref(), repo, repo); - // The tree diff also reports directory entries; only their contents - // are listed, so skip trees and submodule gitlinks. + // Skip directory trees and submodule gitlinks, only files are listed. let (path, old_path, status) = match attached { Change::Addition { location, @@ -1033,8 +1186,8 @@ fn tree_diff( _ => continue, }; - // Always diff with the built-in algorithm: external diff drivers - // would shell out, which is out of scope for a read-only viewer. + // Always diff with the built-in algorithm. + // External diff drivers would shell out, out of scope for a read-only viewer. let platform = attached.diff(&mut cache)?; platform .resource_cache @@ -1078,14 +1231,7 @@ fn tree_diff( Ok(CommitDiff { files }) } -/// Parse a `git format-patch` output (a single patch or a patch series) -/// into the same [`CommitDiff`] structure used for commit diffs. -/// -/// The mbox envelope (From/Subject/... headers, commit body and diffstat) -/// is skipped; every `diff --git` section becomes one [`FileDiff`]. Paths -/// are taken from the section headers, with git's C-style quoting undone. -/// Sections without hunks (pure renames, mode changes, binary files) are -/// reported without lines. +/// Parse `git format-patch` output, a single patch or a series. pub fn patch_diffs(patch: &str) -> Result { let lines: Vec<&str> = patch.lines().collect(); let mut files = Vec::new(); @@ -1104,10 +1250,9 @@ pub fn patch_diffs(patch: &str) -> Result { Ok(CommitDiff { files }) } -/// Commits of a `git format-patch` output (a single patch or a patch -/// series), parsed from the mbox envelope headers of each patch: commit id, -/// author, summary and author time. Entries appear in patch order (oldest -/// first, as produced by `git format-patch`). +/// Commits of a `git format-patch` output, a single patch or a series. +/// +/// Entries appear in patch order, oldest first as `git format-patch` produces them. pub fn patch_commits(patch: &str) -> Vec { let lines: Vec<&str> = patch.lines().collect(); let mut commits = Vec::new(); @@ -1132,8 +1277,7 @@ pub fn patch_commits(patch: &str) -> Vec { let mut summary = String::new(); let mut time = 0i64; - // Envelope headers of this patch, up to the blank line separating - // them from the commit message. + // Envelope headers run up to the blank line before the commit message. i += 1; while i < lines.len() && !lines[i].is_empty() { let header = lines[i]; @@ -1169,8 +1313,9 @@ fn name_from_address(from: &str) -> String { } } -/// Strip the `[PATCH]`, `[PATCH 1/2]`, `[RFC PATCH]` ... prefix from a patch -/// `Subject:` header. +/// Strip the patch prefix from a `Subject:` header. +/// +/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`. fn strip_patch_prefix(subject: &str) -> String { let trimmed = subject.trim(); let Some(rest) = trimmed.strip_prefix('[') else { @@ -1186,14 +1331,14 @@ fn strip_patch_prefix(subject: &str) -> String { } } -/// Parse one file's diff section: everything after its `diff --git` header -/// up to the next section (or the end of the patch). Returns the section -/// and the index of the first unconsumed line. +/// Parse one file's diff section. +/// +/// Returns the section and the index of the first unconsumed line. fn parse_diff_section(header: &str, lines: &[&str], start: usize) -> Result<(FileDiff, usize)> { let (header_old, header_new) = header_paths(header)?; - // The `---`/`+++` lines name the two sides unambiguously (the header - // can't distinguish spaces); fall back to the header for sections - // without them (pure renames, mode changes). + // The `---` and `+++` lines name the two sides unambiguously. + // The `diff --git` header cannot distinguish spaces in paths. + // Fall back to the header for sections without them, pure renames and mode changes. let mut old_path = header_old; let mut new_path = header_new; @@ -1249,14 +1394,14 @@ fn parse_diff_section(header: &str, lines: &[&str], start: usize) -> Result<(Fil status = DiffStatus::Renamed; } else if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") { binary = true; - // A literal binary patch may follow; skip it without consuming - // the next section's header. + // A literal binary patch may follow. + // Skip it without consuming the next section's header. while i < lines.len() && !lines[i].starts_with("diff --git ") { i += 1; } break; } - // Everything else (index/mode/similarity lines) is ignored. + // Everything else, index, mode and similarity lines, is ignored. } Ok(( @@ -1274,9 +1419,9 @@ fn parse_diff_section(header: &str, lines: &[&str], start: usize) -> Result<(Fil )) } -/// Parse one hunk: the `@@ -a,b +c,d @@` header plus every body line up to -/// the next hunk header, the next `diff --git` section or the end of the -/// patch. Returns the hunk and the index of the first unconsumed line. +/// Parse one hunk, the `@@ -a,b +c,d @@` header plus every body line. +/// +/// Returns the hunk and the index of the first unconsumed line. fn parse_hunk(lines: &[&str], start: usize) -> Result<(DiffHunk, usize)> { let (old_start, old_lines, new_start, new_lines) = hunk_header(lines[start])?; @@ -1292,9 +1437,9 @@ fn parse_hunk(lines: &[&str], start: usize) -> Result<(DiffHunk, usize)> { }; i += 1; - // Context lines advance both counters, deletions only the old one - // and additions only the new one, so every line ends up with its - // real line number in both versions. + // Context lines advance both counters. + // Deletions advance only the old counter, additions only the new one. + // Every line then carries its real number in both versions. let (old_no, new_no) = match kind { DiffLineKind::Context => { let numbers = (Some(old), Some(new)); @@ -1333,9 +1478,9 @@ fn parse_hunk(lines: &[&str], start: usize) -> Result<(DiffHunk, usize)> { )) } -/// The kind of a hunk body line, from its first character; lines that don't -/// belong to the hunk (headers, `\ No newline...`, the next section) yield -/// `None`. +/// The kind of a hunk body line, from its first character. +/// +/// Lines outside a hunk, headers, `\ No newline...` and the next section, yield `None`. fn line_prefix_kind(line: &str) -> Option { match line.as_bytes().first()? { b' ' => Some(DiffLineKind::Context), @@ -1345,8 +1490,9 @@ fn line_prefix_kind(line: &str) -> Option { } } -/// Parse a unified-diff hunk header `@@ -a,b +c,d @@`; omitted line counts -/// default to 1. +/// Parse a unified-diff hunk header, `@@ -a,b +c,d @@`. +/// +/// Omitted line counts default to 1. fn hunk_header(header: &str) -> Result<(u32, u32, u32, u32)> { let rest = header .strip_prefix("@@ ") @@ -1368,12 +1514,7 @@ fn hunk_header(header: &str) -> Result<(u32, u32, u32, u32)> { Ok((old_start, old_lines, new_start, new_lines)) } -/// The old and new paths of a `diff --git a/X b/Y` header, with git's -/// C-style quoting undone. -/// -/// Git only quotes paths containing characters that need escaping (non-ASCII -/// bytes, `"`, `\`); plain spaces are left unquoted, so the two sides of an -/// unquoted header are split at the last ` b/`. +/// The old and new paths of a `diff --git a/X b/Y` header. fn header_paths(header: &str) -> Result<(String, String)> { if header.starts_with('"') { // Quoted paths include the `a/` / `b/` prefix inside the quotes. @@ -1402,10 +1543,7 @@ fn header_paths(header: &str) -> Result<(String, String)> { } } -/// The path of a `--- a/X` / `+++ b/Y` line: the prefix stripped, git's -/// trailing padding tab (for paths containing spaces) removed and C-style -/// quoting undone. These lines name the two sides unambiguously, unlike the -/// `diff --git` header. +/// The path of a `--- a/X` or `+++ b/Y` line. fn diff_line_path(line: &str, prefix: &str) -> Result { let line = line.trim_end_matches('\t'); if line.starts_with('"') { @@ -1422,11 +1560,10 @@ fn diff_line_path(line: &str, prefix: &str) -> Result { } } -/// The content of a git C-style quoted path (opening `"`, escaped content, -/// closing `"`) and the rest of the input; `None` if unterminated. +/// The content of a git C-style quoted path and the rest of the input. +/// The path spans the opening `"`, escaped content and closing `"`. /// -/// Iterates by character so the returned slices always land on UTF-8 -/// boundaries, even for non-ASCII paths. +/// `None` if unterminated. fn take_quoted(input: &str) -> Option<(&str, &str)> { let mut end = 1; // byte after the opening quote let mut rest = &input[1..]; @@ -1434,7 +1571,7 @@ fn take_quoted(input: &str) -> Option<(&str, &str)> { let len = ch.len_utf8(); match ch { '\\' => { - // Consume the escaped character too (it may be multi-byte). + // Consume the escaped character too, it may be multi-byte. let escaped = rest[len..].chars().next()?; let consumed = len + escaped.len_utf8(); end += consumed; @@ -1450,64 +1587,22 @@ fn take_quoted(input: &str) -> Option<(&str, &str)> { None } -/// Undo git's C-style path quoting (`\NNN` octal escapes, `\"`, `\\`). +/// Undo git's C-style path quoting, `\NNN` octal escapes, `\"` and `\\`. +/// +/// Delegates to gitoxide's C-style quote implementation, `gix::quote::ansi_c::undo`. +/// It expects the surrounding double quotes, which are re-added around the interior. fn unquote_path(path: &str) -> Result { if !path.contains('\\') { return Ok(path.to_owned()); } - let mut out = Vec::with_capacity(path.len()); - let mut bytes = path.as_bytes(); - while let Some((&b, rest)) = bytes.split_first() { - bytes = rest; - if b == b'\\' { - match bytes.split_first() { - Some((&b'"', rest)) | Some((&b'\\', rest)) => { - out.push(b); - bytes = rest; - } - Some((&b'n', rest)) => { - out.push(b'\n'); - bytes = rest; - } - Some((&b't', rest)) => { - out.push(b'\t'); - bytes = rest; - } - Some((&d1, rest)) if (b'0'..=b'7').contains(&d1) => { - let Some((&d2, rest)) = rest.split_first() else { - bail!("malformed octal escape in quoted path"); - }; - let Some((&d3, rest)) = rest.split_first() else { - bail!("malformed octal escape in quoted path"); - }; - if !(b'0'..=b'7').contains(&d2) || !(b'0'..=b'7').contains(&d3) { - bail!("malformed octal escape in quoted path"); - } - let code = - (d1 - b'0') as u16 * 64 + (d2 - b'0') as u16 * 8 + (d3 - b'0') as u16; - if code > u8::MAX as u16 { - bail!("octal escape out of range in quoted path"); - } - out.push(code as u8); - bytes = rest; - } - _ => bail!("malformed escape in quoted path"), - } - } else { - out.push(b); - } - } - - String::from_utf8(out).context("invalid UTF-8 in quoted path") + let quoted = format!("\"{path}\""); + let (unquoted, _) = gix::quote::ansi_c::undo(gix::bstr::BStr::new(quoted.as_bytes())) + .map_err(|e| anyhow::anyhow!("malformed quoted path: {e}"))?; + String::from_utf8(unquoted.into_owned().to_vec()).context("invalid UTF-8 in quoted path") } /// Collects the hunks of one blob diff while tracking per-line numbers. -/// -/// The unified-diff headers give the 1-based start line of the hunk in each -/// file; context lines advance both counters, removals only the old one and -/// additions only the new one, so each line ends up with its real line -/// numbers in both versions. struct HunkCollector<'a> { hunks: &'a mut Vec, insertions: &'a mut usize, @@ -1579,35 +1674,33 @@ impl ConsumeHunk for HunkCollector<'_> { fn finish(self) {} } -/// The commit HEAD points to, like `git log -1`. Returns `Ok(None)` for a -/// repository without commits yet (unborn HEAD). +/// The commit HEAD points to, like `git log -1`. +/// +/// `Ok(None)` for a repository without commits yet, an unborn HEAD. pub fn head_commit(repo: &gix::Repository) -> Result> { let Some(head) = repo.head_id().ok() else { return Ok(None); }; let commit = head.object()?.into_commit(); - Ok(Some(file_commit(&commit, true)?)) + Ok(Some(file_commit(&commit)?)) } -/// Full metadata of the commit `id` (short or full) in the repository at -/// `workdir`, like [`head_commit`] for an arbitrary commit. Returns -/// `Ok(None)` when the id cannot be resolved. +/// Full metadata of the commit `id`, short or full, in the repository at `workdir`. +/// Like [`head_commit`] for an arbitrary commit. /// -/// The commit list ([`all_commits`]) omits message bodies to keep the walk -/// cheap; the diff panel uses this to fetch the full commit on demand. +/// `Ok(None)` when the id cannot be resolved. pub fn worktree_commit(workdir: &Path, id: &str) -> Result> { - let repo = open_with_cache(workdir)?; + let repo = gix::open(workdir)?; match repo.rev_parse_single(id.as_bytes()) { Ok(commit_id) => { let commit = commit_id.object()?.into_commit(); - Ok(Some(file_commit(&commit, true)?)) + Ok(Some(file_commit(&commit)?)) } Err(_) => Ok(None), } } -/// Short names of local branches (`refs/heads/*`) of `repo`, sorted -/// alphabetically. +/// Short names of local branches, `refs/heads/*`, of `repo`, sorted alphabetically. pub fn repo_branches(repo: &gix::Repository) -> Result> { let mut names = Vec::new(); for reference in repo.references()?.local_branches()? { @@ -1618,7 +1711,7 @@ pub fn repo_branches(repo: &gix::Repository) -> Result> { Ok(names) } -/// Short names of tags (`refs/tags/*`) of `repo`, sorted alphabetically. +/// Short names of tags, `refs/tags/*`, of `repo`, sorted alphabetically. pub fn repo_tags(repo: &gix::Repository) -> Result> { let mut names = Vec::new(); for reference in repo.references()?.tags()? { @@ -1629,18 +1722,14 @@ pub fn repo_tags(repo: &gix::Repository) -> Result> { Ok(names) } -/// Short names of local branches (`refs/heads/*`), sorted alphabetically. +/// Short names of local branches, `refs/heads/*`, sorted alphabetically. pub fn worktree_branches(workdir: &Path) -> Result> { - repo_branches(&open_with_cache(workdir)?) + repo_branches(&gix::open(workdir)?) } -/// Short names of tags (`refs/tags/*`), sorted alphabetically. -pub fn worktree_tags(workdir: &Path) -> Result> { - repo_tags(&open_with_cache(workdir)?) -} - -/// Short name of the branch HEAD points to, or `None` when detached (e.g. -/// after checking out a tag or a commit directly). +/// Short name of the branch HEAD points to, or `None` when detached. +/// +/// Detached after checking out a tag or a commit directly. pub fn current_branch(repo: &gix::Repository) -> Result> { let head = repo.head()?; let Some(name) = head.referent_name() else { @@ -1649,8 +1738,9 @@ pub fn current_branch(repo: &gix::Repository) -> Result> { Ok(Some(String::from_utf8_lossy(name.shorten()).into_owned())) } -/// Branch, tag and HEAD refs of a repository, ready for a NIP-34 kind-30618 -/// repository state announcement. +/// Branch, tag and HEAD refs of a repository. +/// +/// Ready for a NIP-34 kind-30618 repository state announcement. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RepoRefState { /// `(full refname, commit id)` pairs for heads and tags, sorted. @@ -1659,8 +1749,10 @@ pub struct RepoRefState { pub head: Option, } -/// Collect the refs of `repo`: local branches and tags as -/// `(refname, commit-id)` pairs, plus the branch HEAD points to. +/// Collect the refs of `repo`. +/// +/// Local branches and tags become `(refname, commit-id)` pairs. +/// Also reports the branch HEAD points to. pub fn repo_ref_state(repo: &gix::Repository) -> Result { let mut refs = Vec::new(); @@ -1693,7 +1785,7 @@ pub fn repo_ref_state(repo: &gix::Repository) -> Result { /// [`repo_ref_state`] for the repository at `workdir`. pub fn worktree_ref_state(workdir: &Path) -> Result { - repo_ref_state(&open_with_cache(workdir)?) + repo_ref_state(&gix::open(workdir)?) } /// Everything the browser needs to refresh after a branch or tag switch. @@ -1704,16 +1796,17 @@ pub struct WorktreeSnapshot { pub readme_path: Option, /// Contents of the README, if any. pub readme: Option>, - /// Branch HEAD points to (`None` when detached, e.g. on a tag). + /// Branch HEAD points to, `None` when detached, for example on a tag. pub current_branch: Option, - /// Commit HEAD points to, if any (see [`head_commit`]). + /// Commit HEAD points to, if any, see [`head_commit`]. pub head_commit: Option, } -/// Snapshot the worktree after a branch/tag switch: entries, README, the -/// branch HEAD points to and its commit, opening the repository once. +/// Snapshot the worktree after a branch or tag switch. +/// +/// Collects entries, the README, the branch HEAD points to and its commit. pub fn worktree_snapshot(workdir: &Path) -> Result { - let repo = open_with_cache(workdir)?; + let repo = gix::open(workdir)?; let readme_path = find_readme(&repo)?; let readme = match &readme_path { Some(path) => worktree_read(&repo, path)?, @@ -1728,9 +1821,9 @@ pub fn worktree_snapshot(workdir: &Path) -> Result { }) } -/// Switch the checked-out ref and update the worktree to match, like -/// `git checkout --force`. Local modifications are discarded since these -/// clones are read-only browser copies. +/// Switch the checked-out ref and update the worktree, like `git checkout --force`. +/// +/// Local modifications are discarded, these clones are read-only browser copies. fn checkout(workdir: &Path, args: &[&str]) -> Result<()> { let output = Command::new("git") .arg("checkout") @@ -1749,15 +1842,15 @@ fn checkout(workdir: &Path, args: &[&str]) -> Result<()> { Ok(()) } -/// Check out the local branch `name`; HEAD stays attached to it. +/// Check out the local branch `name`, HEAD stays attached to it. pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> { - // The short name (not `refs/heads/`) keeps HEAD attached; the - // full ref name would be treated as a commit-ish and detach it. + // The short name, not `refs/heads/`, keeps HEAD attached. + // The full ref name would be treated as a commit-ish and detach it. checkout(workdir, &[name]) } -/// Check out the tag `name`; HEAD becomes detached at the tagged commit, -/// which [`current_branch`] reports as `None`. +/// Check out the tag `name`, HEAD becomes detached at the tagged commit. +/// [`current_branch`] reports this as `None`. pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> { // `--detach` pins the full tag ref so HEAD always ends up detached. checkout(workdir, &["--detach", &format!("refs/tags/{name}")]) @@ -1831,8 +1924,8 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let root = temp.path(); - // Repositories are found at any depth; a linked worktree (a `.git` - // file instead of a directory) counts too. + // Repositories are found at any depth. + // A linked worktree, with a `.git` file instead of a directory, counts too. let nested = root.join("a/b/project"); std::fs::create_dir_all(nested.join(".git")).unwrap(); let worktree = root.join("wt"); @@ -1850,8 +1943,8 @@ mod tests { std::fs::create_dir_all(root.join(".hidden/repo/.git")).unwrap(); std::fs::create_dir_all(root.join("node_modules/pkg/.git")).unwrap(); - // A repository is not descended into, so repositories inside it - // (submodule worktrees) are not reported. + // A repository is not descended into. + // Repositories inside it, like submodule worktrees, are not reported. let outer = root.join("outer"); std::fs::create_dir_all(outer.join(".git")).unwrap(); std::fs::create_dir_all(outer.join("sub/other/.git")).unwrap(); @@ -1895,8 +1988,8 @@ mod tests { #[test] fn push_all_mirrors_branches_and_tags() { - // A bare "server" repository reachable via a `file://` URL, like a - // grasp server's `{base}/{owner}/{repo-id}.git` layout. + // A bare server repository reachable via a `file://` URL. + // Mirrors a grasp server's `{base}/{owner}/{repo-id}.git` layout. let server = tempfile::tempdir().unwrap(); let server_repo = server.path().join("npub1test").join("my-repo.git"); std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap(); @@ -1929,8 +2022,8 @@ mod tests { #[test] fn push_all_tolerates_a_missing_ref_kind() { - // A repository with only tags (no branches) still pushes: wildcard - // refspecs without a local match are ignored. + // A repository with only tags and no branches still pushes. + // Wildcard refspecs without a local match are ignored. let server = tempfile::tempdir().unwrap(); let server_repo = server.path().join("npub1test").join("my-repo.git"); std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap(); @@ -1996,7 +2089,7 @@ mod tests { assert_eq!(state.refs.len(), 3); } - /// Build a throwaway non-bare repository with the given files (rel → bytes). + /// Build a throwaway non-bare repository from `(rel, bytes)` file pairs. fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) { let dir = tempfile::tempdir().expect("tempdir"); let repo = gix::init(&dir).expect("init"); @@ -2056,8 +2149,8 @@ mod tests { ); } - /// Stage everything and create a commit with the git CLI (like - /// [`apply_patch`], the crate already shells out to the CLI). + /// Stage everything and create a commit with the git CLI. + /// Like [`apply_patch`], the crate already shells out to the CLI. fn commit_all(repo: &gix::Repository, message: &str) { git_run(repo.workdir().expect("workdir"), &["add", "-A"]); git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]); @@ -2069,8 +2162,8 @@ mod tests { let path = dir.path().join("repo"); let initial = init_repository(&path, "My Repo", "desc").expect("init"); - // A feature branch and a mainline commit diverge from the initial - // commit; it is their merge base. + // A feature branch and a mainline commit diverge from the initial commit. + // The initial commit is their merge base. git_run(&path, &["checkout", "-b", "feature"]); std::fs::write(path.join("feature.txt"), "feature\n").expect("write"); commit_all(&gix::open(&path).expect("open"), "feature commit"); @@ -2085,7 +2178,7 @@ mod tests { Some(initial.as_str()) ); - // An orphan branch shares no history with main: `Ok(None)`. + // An orphan branch shares no history with main, so `Ok(None)`. git_run(&path, &["checkout", "--orphan", "orphan"]); std::fs::write(path.join("orphan.txt"), "orphan\n").expect("write"); commit_all(&gix::open(&path).expect("open"), "orphan commit"); @@ -2113,42 +2206,10 @@ mod tests { assert!(format_patch_between(&path, "feature", "feature").is_err()); } - #[test] - fn patch_applies_checks_without_modifying_the_tree() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("repo"); - let initial = init_repository(&path, "My Repo", "desc").expect("init"); - - git_run(&path, &["checkout", "-b", "feature"]); - std::fs::write(path.join("feature.txt"), "feature\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "feature commit"); - let patch = format_patch_between(&path, &initial, "feature").expect("patch"); - - // A clone of the initial state accepts the series... - let clone = dir.path().join("clone"); - git_run( - dir.path(), - &[ - "clone", - "-q", - path.to_str().unwrap(), - clone.to_str().unwrap(), - ], - ); - git_run(&clone, &["checkout", "-q", &initial]); - assert!(patch_applies(&clone, &patch).is_ok()); - // ...and the check must not have modified the working tree. - assert!(!clone.join("feature.txt").exists()); - - // A conflicting file makes the same series fail the check. - std::fs::write(clone.join("feature.txt"), "conflicting\n").expect("write"); - assert!(patch_applies(&clone, &patch).is_err()); - } - #[test] fn push_commit_ref_pushes_to_the_event_namespace() { - // A bare "server" repository reachable via a `file://` URL, like a - // grasp server's `{base}/{owner}/{repo-id}.git` layout. + // A bare server repository reachable via a `file://` URL. + // Mirrors a grasp server's `{base}/{owner}/{repo-id}.git` layout. let server = tempfile::tempdir().unwrap(); let server_repo = server.path().join("npub1test").join("my-repo.git"); std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap(); @@ -2214,7 +2275,7 @@ mod tests { head_commit_id(&path).expect("head").as_deref(), Some(initial.as_str()) ); - // No commits yet: `HEAD` alone. + // No commits yet, `HEAD` alone. assert_eq!( commits_since(&path, None).expect("commits"), vec![initial.clone()] @@ -2275,7 +2336,7 @@ mod tests { 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. + // [`FileCommit`] carries the short id, the full id is 40 chars. assert_eq!( head_commit(&repo).expect("head").expect("commit").id, &commit[..7] @@ -2312,6 +2373,12 @@ mod tests { git_in(&path, &["remote", "get-url", "origin"]).expect("url"), "https://gitnostr.com/npub1test/repo.git" ); + // The standard fetch mapping is configured with the remote. + // Later `git fetch origin` updates `refs/remotes/origin/*`. + assert_eq!( + git_in(&path, &["config", "remote.origin.fetch"]).expect("refspec"), + "+refs/heads/*:refs/remotes/origin/*" + ); // A second call must not override the existing remote. ensure_origin(&path, "https://other.example/repo.git").expect("keep"); @@ -2321,6 +2388,274 @@ mod tests { ); } + #[test] + fn origin_url_reads_the_remote_or_reports_none() { + let (dir, _repo) = fixture(&[("a.txt", b"one")]); + commit_all(&_repo, "initial"); + let dir = dir.path(); + + // No remote configured yet. + assert_eq!(origin_url(dir).expect("read"), None); + + ensure_origin(dir, "https://gitnostr.com/npub1test/repo.git").expect("add"); + assert_eq!( + origin_url(dir).expect("read").as_deref(), + Some("https://gitnostr.com/npub1test/repo.git") + ); + } + + #[test] + fn set_origin_creates_or_replaces_the_remote() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("my-repo"); + init_repository(&path, "My Repo", "").expect("init"); + + // No origin yet, so one is added. + set_origin(&path, "https://gitnostr.com/npub1test/repo.git").expect("add"); + assert_eq!( + origin_url(&path).expect("url").as_deref(), + Some("https://gitnostr.com/npub1test/repo.git") + ); + + // An existing origin is replaced, not duplicated. + // A clone's origin points at the cloned-from path. + // It is re-targeted at the grasp server. + set_origin(&path, "https://grasp.example/npub1test/repo.git").expect("replace"); + assert_eq!( + origin_url(&path).expect("url").as_deref(), + Some("https://grasp.example/npub1test/repo.git") + ); + } + + #[test] + fn working_copy_cloned_from_the_mirror_matches_head_and_origin() { + // The mirror is a freshly initialized repository. + // Its `origin` points at the grasp server. + // `Backend::create_repository` leaves it in the GitCache. + let dir = tempfile::tempdir().expect("tempdir"); + let mirror = dir.path().join("mirror"); + let commit = init_repository(&mirror, "My Repo", "Does things.").expect("init"); + ensure_origin(&mirror, "https://gitnostr.com/npub1test/my-repo.git").expect("origin"); + + // The working copy is cloned from the mirror. + // It then shares the announced history exactly. + // `origin` is re-pointed at the grasp server instead of the mirror path. + let destination = dir.path().join("folder").join("My_Repo"); + std::fs::create_dir_all(destination.parent().unwrap()).expect("parent"); + clone_repo(&[format!("file://{}", mirror.display())], &destination).expect("clone"); + set_origin(&destination, "https://gitnostr.com/npub1test/my-repo.git").expect("set origin"); + + assert_eq!( + origin_url(&destination).expect("url").as_deref(), + Some("https://gitnostr.com/npub1test/my-repo.git") + ); + assert_eq!( + head_commit_id(&destination).expect("head").as_deref(), + Some(commit.as_str()) + ); + assert!(destination.join("README.md").is_file()); + } + + #[test] + fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { + // A bare server, like a grasp server's `{base}/{owner}/{repo}.git` layout. + let dir = tempfile::tempdir().expect("tempdir"); + let base_server = dir.path().join("npub1test").join("repo.git"); + std::fs::create_dir_all(base_server.parent().unwrap()).unwrap(); + let init_status = Command::new("git") + .args(["init", "--bare", "-q"]) + .arg(&base_server) + .status() + .expect("spawn git init --bare"); + assert!(init_status.success()); + + // The owner's working repo pushes the initial commit. + let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]); + commit_all(&work_repo, "initial"); + let work = work_dir.path(); + let base_url = format!("file://{}", dir.path().display()); + push_all(work, &base_url, "npub1test", "repo").expect("push"); + + // A mirror clone, like the app's GitCache clones. + let mirror = dir.path().join("mirror"); + git_run( + dir.path(), + &[ + "clone", + "-q", + &format!("{base_url}/npub1test/repo.git"), + mirror.to_str().unwrap(), + ], + ); + let initial = git_in(&mirror, &["rev-parse", "HEAD"]).expect("initial"); + + // The owner pushes a new commit. + // The mirror fetches it, but its local `main` and worktree stay behind. + std::fs::write(work.join("new.txt"), b"new\n").expect("write"); + commit_all(&gix::open(work).expect("open"), "new commit"); + push_all(work, &base_url, "npub1test", "repo").expect("push"); + git_run(&mirror, &["fetch", "origin"]); + let remote = git_in(&mirror, &["rev-parse", "refs/remotes/origin/main"]).expect("remote"); + assert_eq!( + git_in(&mirror, &["rev-parse", "HEAD"]).expect("local"), + initial + ); + assert_ne!(remote, initial); + + // Fast-forwarding catches the branch and its worktree up. + // The second call has nothing left to move. + assert!(fast_forward_branches(&mirror).expect("ff")); + assert_eq!( + git_in(&mirror, &["rev-parse", "HEAD"]).expect("local"), + remote + ); + assert!(mirror.join("new.txt").is_file()); + assert!(!fast_forward_branches(&mirror).expect("idle")); + + // A branch with local commits of its own is never touched. + git_run(&mirror, &["checkout", "-b", "wip"]); + std::fs::write(mirror.join("wip.txt"), b"wip\n").expect("write"); + commit_all(&gix::open(&mirror).expect("open"), "local wip"); + let wip = git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip"); + assert!(!fast_forward_branches(&mirror).expect("wip skipped")); + assert_eq!( + git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip kept"), + wip + ); + } + + #[test] + fn fetch_repo_refs_imports_heads_under_a_prefix() { + let dir = tempfile::tempdir().expect("tempdir"); + + // A bare base server holding the initial commit. + // Like a grasp server's `{base}/{owner}/{repo-id}.git` layout. + let base_server = dir.path().join("npub1base").join("base.git"); + std::fs::create_dir_all(base_server.parent().unwrap()).unwrap(); + let init_status = Command::new("git") + .args(["init", "--bare", "-q"]) + .arg(&base_server) + .status() + .expect("spawn git init --bare"); + assert!(init_status.success()); + + let (upstream_dir, upstream_repo) = fixture(&[("a.txt", b"one")]); + commit_all(&upstream_repo, "initial"); + let upstream_path = upstream_dir.path(); + let initial = git_in(upstream_path, &["rev-parse", "HEAD"]).expect("initial"); + push_all( + upstream_path, + &format!("file://{}", dir.path().display()), + "npub1base", + "base", + ) + .expect("push"); + + // The base mirror is a plain clone of the base server. + let base_url = format!("file://{}", base_server.display()); + let mirror = dir.path().join("mirror"); + git_run( + dir.path(), + &["clone", "-q", &base_url, mirror.to_str().unwrap()], + ); + + // The fork server has the same initial commit. + // It also carries a feature commit on its own `feature` branch. + let fork_work = dir.path().join("fork-work"); + git_run( + dir.path(), + &["clone", "-q", &base_url, fork_work.to_str().unwrap()], + ); + git_run(&fork_work, &["checkout", "-b", "feature"]); + std::fs::write(fork_work.join("feature.txt"), "feature\n").expect("write"); + commit_all(&gix::open(&fork_work).expect("open"), "feature commit"); + let tip = git_in(&fork_work, &["rev-parse", "HEAD"]).expect("tip"); + + let fork_server = dir.path().join("npub1fork").join("fork.git"); + std::fs::create_dir_all(fork_server.parent().unwrap()).unwrap(); + let init_status = Command::new("git") + .args(["init", "--bare", "-q"]) + .arg(&fork_server) + .status() + .expect("spawn git init --bare"); + assert!(init_status.success()); + push_commit_ref( + &fork_work, + &format!("file://{}", fork_server.display()), + &tip, + "refs/heads/feature", + ) + .expect("push"); + + // Import the fork's heads into the mirror under a private prefix. + // The first dead URL is skipped, the second works. + let dead = format!("file://{}/missing.git", dir.path().display()); + fetch_repo_refs( + &mirror, + &[dead, format!("file://{}", fork_server.display())], + "+refs/heads/*:refs/fork/npub1fork/fork/*", + ) + .expect("fetch"); + + // The imported refs are listed under the prefix only. + assert_eq!( + refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"), + vec!["refs/fork/npub1fork/fork/feature"] + ); + // Nothing leaked into the normal ref namespaces. + assert_eq!( + refs_with_prefix(&mirror, "refs/heads/fork").expect("refs"), + Vec::::new() + ); + + // The mirror can now range across both histories. + // The fork point is the shared initial commit, the proposal covers the fork commit. + assert_eq!( + merge_base( + &mirror, + "refs/remotes/origin/main", + "refs/fork/npub1fork/fork/feature", + ) + .expect("merge base") + .as_deref(), + Some(initial.as_str()) + ); + let patch = format_patch_between(&mirror, &initial, "refs/fork/npub1fork/fork/feature") + .expect("patch"); + assert!(patch.contains("Subject: [PATCH] feature commit")); + assert!(patch.contains("feature.txt")); + + // Pruning the prefix removes the import again. + delete_refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("delete"); + assert_eq!( + refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"), + Vec::::new() + ); + } + + #[test] + fn fetch_repo_refs_fails_when_every_url_fails() { + let (_dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + let dir = _dir.path(); + + let dead = format!("file://{}/missing.git", dir.display()); + let err = fetch_repo_refs(dir, &[dead], "+refs/heads/*:refs/fork/x/*") + .expect_err("all URLs fail"); + assert!(err.to_string().contains("failed to fetch")); + + // Without any URL there is nothing to try. + let err = fetch_repo_refs(dir, &[], "+refs/heads/*:refs/fork/x/*").expect_err("no URLs"); + assert!(err.to_string().contains("no clone URLs")); + } + + #[test] + fn delete_refs_with_prefix_is_a_noop_without_matches() { + let (_dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + delete_refs_with_prefix(_dir.path(), "refs/fork/nothing").expect("noop"); + } + /// Run a git command in `dir`, asserting success. fn git_run(dir: &Path, args: &[&str]) { let status = Command::new("git") @@ -2420,7 +2755,7 @@ mod tests { std::fs::write(dir.path().join("a.txt"), b"feature").expect("write"); commit_all(&repo, "feature change"); run(&["checkout", "-"]); - // --no-ff forces a merge commit; it is the latest commit changing a.txt. + // `--no-ff` forces a merge commit, it is the latest commit changing a.txt. run(&["merge", "--no-ff", "--no-edit", "feature"]); let commit = last_commit(&repo, Path::new("a.txt")) @@ -2448,7 +2783,7 @@ mod tests { &[ PathBuf::from("a.txt"), PathBuf::from("b.txt"), - // Untracked paths are simply absent from the result. + // Untracked paths are absent from the result. PathBuf::from("missing.txt"), ], ) @@ -2494,7 +2829,7 @@ mod tests { fn head_commit_reports_head() { let (_dir, repo) = fixture(&[("a.txt", b"one")]); - // Unborn HEAD: no commit yet. + // Unborn HEAD means no commit yet. assert!(head_commit(&repo).expect("head").is_none()); commit_all(&repo, "initial"); @@ -2517,15 +2852,15 @@ mod tests { git_run(dir, &["tag", "v0.9"]); git_run(dir, &["tag", "v1.0"]); - // The initial branch name depends on git configuration; only the - // branch we created is fixed. + // The initial branch name depends on git configuration. + // Only the branch we created is fixed. let branches = worktree_branches(dir).expect("branches"); assert_eq!(branches.len(), 2); assert!(branches.contains(&"feature".to_string())); assert!(branches.windows(2).all(|pair| pair[0] <= pair[1]), "sorted"); assert_eq!( - worktree_tags(dir).expect("tags"), + repo_tags(&repo).expect("tags"), vec!["v0.9".to_string(), "v1.0".to_string()] ); } @@ -2655,8 +2990,8 @@ mod tests { assert_eq!(modified.deletions, 1); assert!(!modified.binary); let lines = &modified.hunks[0].lines; - // One hunk with context around the single-line change: the removed - // line is old 2, the added line is new 2. + // One hunk with context around the single-line change. + // The removed line is old 2, the added line is new 2. assert!(lines.iter().any(|line| { line.kind == DiffLineKind::Deletion && line.old == Some(2) @@ -2753,7 +3088,7 @@ mod tests { let (dir, repo) = fixture(&[("a.txt", b"one\n")]); commit_all(&repo, "initial"); - // The root commit diffs against the empty tree: everything is added. + // The root commit diffs against the empty tree, everything is added. let head = repo.head_id().expect("head").shorten_or_id().to_string(); let diff = worktree_commit_diff(dir.path(), &head).expect("diff"); assert_eq!(diff.files.len(), 1); @@ -2815,7 +3150,7 @@ mod tests { .expect("file"); assert_eq!(file.status, DiffStatus::Renamed); assert_eq!(file.old_path.as_deref(), Some("old.txt")); - // A pure rename has no content change; the file is still listed. + // A pure rename has no content change, the file is still listed. assert!(file.hunks.is_empty()); assert_eq!(file.insertions, 0); assert_eq!(file.deletions, 0); @@ -3021,8 +3356,8 @@ Subject: [RFC PATCH v3 4/7] the real title #[test] fn patch_commits_handles_missing_headers() { - // A hand-written patch without author/date headers still lists a - // commit; time stays 0 and the author falls back to the raw value. + // A hand-written patch without author or date headers still lists a commit. + // Time stays 0 and the author stays empty. let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001 Subject: [PATCH] plain @@ -3040,7 +3375,7 @@ Subject: [PATCH] plain fn patch_commits_ignores_non_patch_lines() { assert!(patch_commits("").is_empty()); assert!(patch_commits("just some text\nFrom 123\n").is_empty()); - // A diff-only body (no mbox envelope) has no commits. + // A diff-only body without an mbox envelope has no commits. let patch = "diff --git a/x b/x\n--- a/x\n+++ b/x\n"; assert!(patch_commits(patch).is_empty()); } @@ -3120,10 +3455,10 @@ index 123..456 100644 #[test] fn parses_real_format_patch_output() { - // Build a commit touching a mix of file kinds, then feed genuine - // `git format-patch` output through the parser: quoted paths (space - // in the name), octal-escaped paths (UTF-8 name), a rename-free - // modification, an addition and a binary deletion. + // Build a commit touching a mix of file kinds. + // Feed genuine `git format-patch` output through the parser. + // It covers quoted and octal-escaped paths. + // There are also a rename-free modification, an addition and a binary deletion. let (dir, repo) = fixture(&[ ("src/main.rs", b"fn main() {\n println!(\"one\");\n}\n"), ("my file.txt", b"hello\n"), @@ -3168,12 +3503,12 @@ index 123..456 100644 .unwrap_or_else(|| panic!("missing file {path:?}")) }; - // Space in the name: git quotes the path in the header. + // Space in the name makes git quote the path in the header. let file = by_path("my file.txt"); assert_eq!(file.status, DiffStatus::Modified); assert_eq!(file.insertions, 1); - // UTF-8 name: git emits the path as octal escapes. + // UTF-8 names are emitted as octal escapes. let file = by_path("\u{8bf4}\u{660e}.md"); assert_eq!(file.status, DiffStatus::Modified); assert_eq!(file.insertions, 1); @@ -3188,8 +3523,8 @@ index 123..456 100644 assert_eq!(file.status, DiffStatus::Added); assert_eq!(file.insertions, 1); - // Binary deletion: git emits no ---/+++ lines, only the mode and - // the "Binary files" marker. + // A binary deletion emits no `---` or `+++` lines. + // Only the mode line and the `Binary files` marker remain. let file = by_path("img.png"); assert_eq!(file.status, DiffStatus::Deleted); assert!(file.binary); diff --git a/crates/signed_nostr/Cargo.toml b/crates/signed_nostr/Cargo.toml index 4ee5f32..5308c8e 100644 --- a/crates/signed_nostr/Cargo.toml +++ b/crates/signed_nostr/Cargo.toml @@ -5,9 +5,6 @@ edition.workspace = true publish.workspace = true [dependencies] -signed_core = { path = "../signed_core" } - -nostr.workspace = true nostr-sdk.workspace = true nostr-connect.workspace = true nostr-gossip-memory.workspace = true diff --git a/crates/signed_nostr/src/backend.rs b/crates/signed_nostr/src/backend.rs index 2770f29..ee66bfa 100644 --- a/crates/signed_nostr/src/backend.rs +++ b/crates/signed_nostr/src/backend.rs @@ -12,11 +12,6 @@ use nostr_sdk::prelude::*; use crate::signer::UniversalSigner; -/// Open (or create) the LMDB database at `db_path` and build a client -/// configured for Signed, together with a fresh signer. -/// -/// The SDK manages its own internal tokio runtime. -/// the returned client can be driven by GPUI's executors. #[cfg(not(target_arch = "wasm32"))] pub async fn new_backend(db_path: impl AsRef) -> Result<(Client, UniversalSigner)> { let signer = UniversalSigner::new(Keys::generate()); @@ -26,7 +21,7 @@ pub async fn new_backend(db_path: impl AsRef) -> Result<(Client, Universal Ok(with_database(signer, database)) } -/// In-memory database on wasm (no LMDB available). +/// In-memory database on wasm, LMDB is unavailable there. #[cfg(target_arch = "wasm32")] pub fn new_backend() -> Result<(Client, UniversalSigner)> { let signer = UniversalSigner::new(Keys::generate()); diff --git a/crates/signed_nostr/src/signer.rs b/crates/signed_nostr/src/signer.rs index 9f8e592..6f00bf6 100644 --- a/crates/signed_nostr/src/signer.rs +++ b/crates/signed_nostr/src/signer.rs @@ -31,8 +31,7 @@ impl UniversalSignerError { } } -/// A type-erased signer whose inner signer can be swapped in-place -/// (e.g. after login/logout). All clones see the swap. +/// A type-erased signer whose inner signer can be swapped in-place. #[derive(Clone, Debug)] pub struct UniversalSigner { inner: Arc>>, diff --git a/crates/signed_nostr/src/update.rs b/crates/signed_nostr/src/update.rs index 1b3f1de..b5a070f 100644 --- a/crates/signed_nostr/src/update.rs +++ b/crates/signed_nostr/src/update.rs @@ -1,15 +1,12 @@ use nostr_sdk::prelude::*; -/// A lightweight "something changed" signal for the UI. -/// -/// Heavy data stays in the database; consumers re-query on receipt. +/// A lightweight change notification for the UI. #[derive(Debug, Clone)] pub struct Update { pub kind: Kind, - /// First `a` tag value of the event, if any (e.g. the repository coordinate). + /// First `a` tag value of the event, if any, for example the repository coordinate. pub coordinate: Option, pub author: PublicKey, - pub event_id: EventId, } impl Update { @@ -21,7 +18,6 @@ impl Update { kind: event.kind, coordinate, author: event.pubkey, - event_id: event.id, } } } diff --git a/crates/signed_state/Cargo.toml b/crates/signed_state/Cargo.toml index fedbe76..dc60179 100644 --- a/crates/signed_state/Cargo.toml +++ b/crates/signed_state/Cargo.toml @@ -8,6 +8,7 @@ publish.workspace = true signed_core = { path = "../signed_core" } signed_git = { path = "../signed_git" } signed_nostr = { path = "../signed_nostr" } +settings = { path = "../settings" } utils = { path = "../utils" } nostr.workspace = true @@ -24,3 +25,6 @@ log.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rustls = "0.23" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 190130c..cfb68e1 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -1,11 +1,13 @@ -use std::collections::HashMap; use std::collections::hash_map::DefaultHasher; +use std::collections::{HashMap, HashSet}; +use std::future::Future; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use std::str::FromStr; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use anyhow::{Error, anyhow, bail}; +use anyhow::{Context as AnyhowContext, Error, anyhow, bail}; use bitcoin_hashes::sha1::Hash as Sha1Hash; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task}; use nostr::event::IntoEventBuilder; @@ -17,8 +19,7 @@ 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). +/// Keyring entry for the user credential. pub const USER_KEYRING: &str = "Signed Safe Storage"; /// Timeout for NIP-46 signer responses. pub const NOSTR_CONNECT_TIMEOUT: u64 = 60; @@ -31,34 +32,25 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [ "wss://profiles.nostr1.com", ]; -/// Relays used for indexing user's relay list (NIP-65). +/// Relays used to index the user's NIP-65 relay list. pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"]; -/// How long an identical fetch/sync request is suppressed after it started. -/// A second panel for the same repository (or the global and per-author -/// list stores at login) doesn't duplicate a sync that just ran; after the -/// window, re-fetching is allowed again so data stays fresh. +/// How long an identical fetch or sync request is suppressed after it started. const FETCH_DEDUP_WINDOW: Duration = Duration::from_secs(5 * 60); #[derive(Debug, Clone)] pub enum BackendEvent { /// User has no signer configured. SignerRequired, - /// The stored identity is NIP-49 encrypted (`ncryptsec1...`); a - /// passphrase is required to decrypt it before the session can resume. + /// The stored identity is NIP-49 encrypted key. PassphraseRequired, - /// The signer has changed (login/logout/account switch). + /// The signer changed on login, logout or account switch. SignerChanged, - /// Relay bootstrap finished. - Connected, /// A new event was received from a relay and stored in the database. NostrUpdate(Update), - /// A negentropy sync completed; the database was updated directly, - /// so stores should re-query (no [`BackendEvent::NostrUpdate`] is fired - /// for synced events). + /// A negentropy sync completed. Synced, - /// A negentropy sync is in flight. Stores may re-query to render - /// incrementally; UI can show `current`/`total` progress. + /// A negentropy sync is in flight. SyncProgress { /// Total events to process. total: u64, @@ -80,22 +72,20 @@ impl BackendEvent { } } -/// Global backend entity: owns the nostr client, the signer and the -/// notification pump. Stores subscribe to [`BackendEvent`] and re-query the -/// local database when relevant updates arrive. +/// The global backend entity. +/// +/// Owns the nostr client, the signer and the notification pump. pub struct Backend { client: Client, signer: UniversalSigner, current_user: Option, - connected: bool, sync_progress: Option<(u64, u64)>, - /// Whether the stored credential is NIP-49 encrypted and a passphrase - /// is still needed to resume the session. + /// True when the stored credential is NIP-49 encrypted. passphrase_required: bool, - /// Fingerprints of recently started fetches/syncs (relay + filter set), - /// so duplicate requests within [`FETCH_DEDUP_WINDOW`] collapse into - /// one. Entries are pruned lazily on the next request. + /// Fingerprints of recently started fetches and syncs, a relay plus filter set. recent_fetches: HashMap, + /// Repositories with a push in flight, mirror or checkout based. + pushing_repos: Arc>>, tasks: Vec>>, } @@ -103,6 +93,22 @@ struct GlobalBackend(Entity); impl Global for GlobalBackend {} +/// Removes its repository from the in-flight push set when dropped. +/// +/// A push task cancelled by its panel closing cannot leave the repository locked. +struct PushGuard { + repos: Arc>>, + addr: RepoAddr, +} + +impl Drop for PushGuard { + fn drop(&mut self) { + if let Ok(mut repos) = self.repos.lock() { + repos.remove(&self.addr); + } + } +} + impl EventEmitter for Backend {} impl Backend { @@ -143,10 +149,10 @@ impl Backend { client, signer, current_user: None, - connected: false, sync_progress: None, passphrase_required: false, recent_fetches: HashMap::new(), + pushing_repos: Arc::new(Mutex::new(HashSet::new())), tasks: vec![pump], }; @@ -154,8 +160,17 @@ impl Backend { this } - /// Bootstrap the client: connect to the default relays (indexers as - /// discovery-only) and restore the saved session, if any. + /// Track a spawned task, pruning finished tasks first. + /// + /// Keeps the store's task list bounded by the number of in-flight tasks. + fn push_task(&mut self, task: Task>) { + self.tasks.retain(|task| !task.is_ready()); + self.tasks.push(task); + } + + /// Bootstrap the client. + /// + /// Restore the saved session, if any. fn bootstrap(&mut self, cx: &mut Context) { let client = self.client.clone(); @@ -173,14 +188,10 @@ impl Backend { Ok::<(), Error>(()) }); - self.tasks.push(cx.spawn(async move |this, cx| { + self.push_task(cx.spawn(async move |this, cx| { match task.await { Ok(()) => { - this.update(cx, |this, cx| { - this.connected = true; - cx.emit(BackendEvent::Connected); - cx.notify(); - })?; + this.update(cx, |_this, cx| cx.notify())?; } Err(e) => { this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; @@ -192,10 +203,11 @@ impl Backend { self.restore_session(cx); } - /// Restore the saved session from the keyring. Emits - /// [`BackendEvent::SignerRequired`] if no credential is stored, or - /// [`BackendEvent::PassphraseRequired`] if the stored identity is - /// NIP-49 encrypted. + /// Restore the saved session from the keyring. + /// + /// Emits [`BackendEvent::SignerRequired`] when no credential is stored. + /// + /// Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity. pub fn restore_session(&mut self, cx: &mut Context) { if cfg!(target_arch = "wasm32") { cx.emit(BackendEvent::SignerRequired); @@ -204,7 +216,7 @@ impl Backend { let user = cx.read_credentials(USER_KEYRING); - self.tasks.push(cx.spawn(async move |this, cx| { + self.push_task(cx.spawn(async move |this, cx| { let content = match user.await { Ok(Some((_username, secret))) => String::from_utf8(secret)?, _ => { @@ -229,8 +241,8 @@ impl Backend { signer.auth_url_handler(SignedAuthUrlHandler); this.update(cx, |this, cx| this.set_signer(signer, cx))?; } else if content.starts_with("ncryptsec1") { - // Encrypted identity: a passphrase is required to - // decrypt it before the session can resume. + // Encrypted identity. + // A passphrase is required to decrypt it before the session can resume. log::warn!("stored identity is ncryptsec-encrypted; waiting for passphrase"); this.update(cx, |this, cx| { this.passphrase_required = true; @@ -255,11 +267,7 @@ impl Backend { })); } - /// Decrypt the NIP-49 encrypted credential stored in the keyring with - /// the given passphrase and resume the session. - /// - /// The scrypt decryption runs off the UI thread. The task yields the - /// public key, or the failure reason (e.g. wrong passphrase). + /// Decrypt the NIP-49 keyring credential with the given passphrase. pub fn restore_with_passphrase( &mut self, password: &str, @@ -294,12 +302,7 @@ impl Backend { }) } - /// Create a new identity: generate keys, encrypt the secret key with the - /// passphrase (NIP-49) and persist it in the keyring, then publish the - /// user's NIP-65 relay list, metadata and grasp list. - /// - /// The encryption runs off the UI thread; the task yields the new - /// public key. + /// Create a new identity. pub fn create_identity( &mut self, name: &str, @@ -335,8 +338,7 @@ impl Backend { write.await?; this.update(cx, |this, cx| { - // Become the new identity, so the publishes below are - // signed with the new keys. + // Become the new identity so later publishes are signed with the new keys. this.signer.swap_inner(keys); this.current_user = Some(public_key); this.bootstrap_user(public_key, cx); @@ -387,47 +389,45 @@ 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. + /// Initialize a local clone with a `main` branch and a `README.md`. /// - /// 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 runs on background threads; the task yields the - /// published announcement. + /// The task yields the announcement and the path of the working copy. pub fn create_repository( &mut self, name: &str, description: &str, + folder: PathBuf, grasp_servers: Vec, cx: &mut Context, - ) -> Task> { + ) -> 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"))); } + + if !folder.is_dir() { + return Task::ready(Err(anyhow!("Choose a folder for the repository"))); + } + 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. + // The repository identifier is derived from the name. 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" @@ -437,15 +437,14 @@ impl Backend { 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 owner = public_key.to_bech32().unwrap(); let servers = grasp_servers.clone(); cx.spawn(async move |this, cx| { - // Initialize the local clone (main branch + README + initial commit). + // Initialize the local clone and create the user's working copy from it. let work = cx.background_spawn({ let path = path.clone(); + let folder = folder.clone(); let name = name.clone(); let description = description.clone(); let owner = owner.clone(); @@ -459,20 +458,48 @@ impl Backend { 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. + // Point `origin` at the first grasp server. + // 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) + // A working copy at `/`, like the header's Clone action. + // Cloned from the mirror above so it shares the announced history. + // `origin` is set to the first grasp server, not the mirror path. + let destination = { + let dir_name = signed_git::sanitize_path_component(&name); + let dir_name = if dir_name.is_empty() { + "repository".to_owned() + } else { + dir_name + }; + folder.join(dir_name) + }; + + let mirror_url = Url::from_file_path(&path) + .map_err(|_| anyhow!("invalid mirror path"))? + .to_string(); + + signed_git::clone_repo(&[mirror_url], &destination).with_context(|| { + format!( + "failed to create the working copy at {}", + destination.display() + ) + })?; + + if let Some(base) = servers.first().and_then(grasp_base_url) { + let url = format!("{base}/{owner}/{repo_id}.git"); + signed_git::set_origin(&destination, &url)?; + } + + Ok::<_, Error>((commit, destination)) } }); - let commit = work.await?; - let commit_sha = - Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid initial commit id"))?; + let (commit, checkout_path) = work.await?; + let commit_sha = Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid id"))?; // The nostr client queues events until each relay is connected. this.update(cx, |this, cx| { @@ -480,8 +507,7 @@ impl Backend { this.add_relays(urls, cx); })?; - // The state event is the push authorization ("purgatory"), so - // it must be accepted before the push below. + // The state event is the push authorization. It must be accepted before the push below. let announcement = GitRepositoryAnnouncement { id: repo_id.clone(), name: Some(name.clone()), @@ -527,8 +553,7 @@ impl Backend { } }; - // Push to every grasp server; creation only fails when no - // server accepted it. + // Push to every grasp server. Creation fails only when no server accepted it. let push = cx.background_spawn({ let path = path.clone(); let owner = owner.clone(); @@ -536,9 +561,9 @@ impl Backend { let servers = servers.clone(); push_to_grasp_servers(path, owner, repo_id, servers, signed_git::push_main) }); + if let Err(e) = push.await { - // The events are already published; retract them so the - // repository doesn't remain announced without content. + // The events are already published. Retract them so the repository is not left announced without content. this.update(cx, |this, cx| { this.retract_events(&[event.clone(), state_event.clone()], cx); }) @@ -550,17 +575,14 @@ impl Backend { )); } - Announcement::from_event(&event).ok_or_else(|| anyhow!("failed to parse announcement")) + let announcement = Announcement::from_event(&event) + .ok_or_else(|| anyhow!("failed to parse announcement"))?; + + Ok((announcement, checkout_path)) }) } - /// Publish an existing local repository to NIP-34: read its current - /// branches, tags and HEAD, publish the announcement and the repository - /// state to the grasp relays, then push every branch and tag to each - /// grasp server. Also points `origin` at the first grasp server. - /// - /// Same ordering constraint as [`Self::create_repository`]: the state - /// event ("purgatory") must be accepted before the push. + /// Publish an existing local repository to NIP-34. pub fn publish_local_repo( &mut self, path: PathBuf, @@ -584,8 +606,7 @@ impl Backend { return Task::ready(Err(anyhow!("Sign in to publish a repository"))); }; - // The repository identifier is derived from the name as in - // [`Self::create_repository`]. + // The identifier derives from the name, as in [`Self::create_repository`]. let repo_id = identifier_from_name(&name); if repo_id.is_empty() || repo_id.len() > 100 { @@ -620,8 +641,7 @@ impl Backend { this.add_relays(urls, cx); })?; - // The state event is the push authorization ("purgatory"), so - // it must be accepted before the push below. + // The state event is the push authorization. It must be accepted before the push below. let announcement = GitRepositoryAnnouncement { id: repo_id.clone(), name: Some(name.clone()), @@ -665,9 +685,9 @@ impl Backend { } }; - // Push every branch and tag to each grasp server; the init - // only fails when no server accepted it. An empty repository - // has nothing to push. + // Push every branch and tag to each grasp server. The push fails only when no server accepted it. + // + // An empty repository has nothing to push. if !refs.is_empty() { let push = cx.background_spawn({ let path = path.clone(); @@ -689,8 +709,7 @@ impl Backend { } } - // Point `origin` at the first grasp server so later pushes - // have a target. + // Point `origin` at the first grasp server so later pushes have a target. if let Some(base) = servers.first().and_then(grasp_base_url) { let url = format!("{base}/{owner}/{repo_id}.git"); let path = path.clone(); @@ -704,35 +723,92 @@ impl Backend { }) } - /// Re-push the repository's current refs to the grasp servers announced - /// in its `relays` tag: publishes a fresh state event (the push - /// authorization), then pushes every branch and tag, like the init - /// flow. The repository must have a local clone in the cache. + /// Re-push the repository's current refs to the grasp servers in its `relays` tag. pub fn push_repository( &mut self, announcement: Announcement, cx: &mut Context, ) -> Task> { - let addr = announcement.addr(); let cache = GitStore::global(cx).cache().clone(); - let path = cache.repo_path(&addr); - let owner = announcement - .owner - .to_bech32() - .unwrap_or_else(|_| announcement.owner.to_hex()); + let path = cache.repo_path(&announcement.addr()); + self.push_repo_from(announcement, path, None, cx) + } + + /// Push the refs of a local checkout to the grasp servers in its `relays` tag. + /// The checkout is the working copy of the user's own repository. + /// + /// Publish a fresh state event, then push every branch and tag of the checkout. + pub fn push_checkout( + &mut self, + announcement: Announcement, + checkout: PathBuf, + announced_head: Option, + cx: &mut Context, + ) -> Task> { + self.push_repo_from(announcement, checkout, announced_head, cx) + } + + /// Shared body of the mirror-based and checkout-based pushes. + fn push_repo_from( + &mut self, + announcement: Announcement, + path: PathBuf, + announced_head: Option, + cx: &mut Context, + ) -> Task> { + let addr = announcement.addr(); + let guard = { + let mut pushing = self + .pushing_repos + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !pushing.insert(addr.clone()) { + return Task::ready(Err(anyhow!( + "A push to this repository is already in progress" + ))); + } + PushGuard { + repos: self.pushing_repos.clone(), + addr: addr.clone(), + } + }; + + let owner = announcement.owner.to_bech32().unwrap(); let repo_id = announcement.id.clone(); let relays = announcement.relays.clone(); cx.spawn(async move |this, cx| { - let work = cx.background_spawn({ - let path = path.clone(); - async move { signed_git::worktree_ref_state(&path) } - }); - let state = work.await?; + // Held for the whole task. Dropped on completion, on error and on cancellation alike. + let _guard = guard; + + let mut state = { + let work = cx.background_spawn({ + let path = path.clone(); + async move { signed_git::worktree_ref_state(&path) } + }); + work.await? + }; + + // The state event announces the pushed refs. + // Keep the announced default branch in `HEAD` when it is among the pushed refs. + // + // Otherwise `HEAD` stays the checkout's current branch. + let heads: Vec<&str> = state + .refs + .iter() + .filter_map(|(name, _)| name.strip_prefix("refs/heads/")) + .collect(); + + if let Some(head) = announced_head + && heads.iter().any(|branch| *branch == head) + { + state.head = Some(head); + } // Grasp servers authorize a push by the state they have seen. let refs = state.refs.clone(); let head = state.head.clone(); + this.update(cx, |this, cx| { let builder = build_state(&repo_id, &refs, head.as_deref()); this.send(builder, cx) @@ -757,10 +833,9 @@ impl Backend { }) } - /// Delete the repository from nostr: publish NIP-09 deletions for its - /// announcement, state and activity events (issues, pull requests, - /// patches, statuses, comments). Only the repository owner may delete - /// it. + /// Delete the repository from nostr. + /// + /// Only the repository owner may delete it. pub fn delete_repository( &mut self, addr: RepoAddr, @@ -801,8 +876,7 @@ impl Backend { }) } - /// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on - /// the credential's prefix. + /// Login with an `nsec1...` key or a `bunker://...` URI. pub fn login(&mut self, credential: &str, cx: &mut Context) { let credential = credential.trim(); @@ -817,8 +891,7 @@ impl Backend { } } - /// Create a fresh identity and login with it. The generated key is - /// persisted in the keyring like any other `nsec` credential. + /// Create a fresh identity and login with it. pub fn login_with_new_identity(&mut self, cx: &mut Context) { let nsec = Keys::generate() .secret_key() @@ -827,8 +900,7 @@ impl Backend { self.login_with_nsec(&nsec, cx); } - /// Login with an `nsec1...` secret key. The credential is verified by - /// the signer flow and persisted in the keyring. + /// Login with an `nsec1...` secret key. pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context) { let keys = match SecretKey::parse(nsec) { Ok(secret) => Keys::new(secret), @@ -842,7 +914,7 @@ impl Backend { let pubkey = keys.public_key().to_hex(); let write = cx.write_credentials(USER_KEYRING, &pubkey, nsec.as_bytes()); - self.tasks.push(cx.spawn(async move |this, cx| { + self.push_task(cx.spawn(async move |this, cx| { if let Err(e) = write.await { this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?; return Ok(()); @@ -852,11 +924,7 @@ impl Backend { })); } - /// Login with a `bunker://...` URI (NIP-46). A fresh session key is - /// generated and embedded into the stored URI as `?master=`, so - /// no separate keyring entry is needed. The auth URL, if any, is opened - /// in the default browser. The credential is persisted in the keyring - /// after the signer proves reachable. + /// Login with a `bunker://...` URI, NIP-46. pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context) { let uri_string = uri.trim().to_owned(); @@ -872,7 +940,7 @@ impl Backend { let credential = with_master_key(&uri_string, &keys); let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes()); - self.tasks.push(cx.spawn(async move |this, cx| { + self.push_task(cx.spawn(async move |this, cx| { let result = async { let mut signer = NostrConnect::new( connect_uri, @@ -904,7 +972,7 @@ impl Backend { pub fn logout(&mut self, cx: &mut Context) { let delete = cx.delete_credentials(USER_KEYRING); - self.tasks.push(cx.spawn(async move |this, cx| { + self.push_task(cx.spawn(async move |this, cx| { delete.await.ok(); this.update(cx, |this, cx| { @@ -920,29 +988,20 @@ impl Backend { })); } - /// Fetch the user's grasp list (kind `10317`) and add the listed grasp - /// servers as relays. + /// Fetch the user's grasp list and add the listed grasp servers as relays. fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context) { let client = self.client.clone(); - self.tasks.push(cx.spawn(async move |this, cx| { + self.push_task(cx.spawn(async move |this, cx| { let result = async { - let events = client.fetch_events(filters::grasp_list(public_key)).await?; - - let urls: Vec = events + let events: Vec = client + .fetch_events(filters::grasp_list(public_key)) + .await? .into_iter() - .max_by_key(|e| e.created_at) - .map(|e| { - e.tags - .iter() - .filter(|t| t.kind() == "g") - .filter_map(|t| t.content().map(str::to_owned)) - .collect() - }) - .unwrap_or_default(); + .collect(); - for url in urls { - client.add_relay(&url).await.ok(); + for url in latest_grasp_list_servers(events) { + client.add_relay(url.as_str()).await.ok(); } client.connect().await; @@ -973,8 +1032,7 @@ impl Backend { self.current_user } - /// Whether the stored credential is NIP-49 encrypted and a passphrase - /// is still needed to resume the session. + /// True when the stored credential is NIP-49 encrypted. pub fn passphrase_required(&self) -> bool { self.passphrase_required } @@ -984,18 +1042,12 @@ impl Backend { cx.emit(BackendEvent::error(message)); } - /// Whether the relay bootstrap has completed. - pub fn is_connected(&self) -> bool { - self.connected - } - - /// Progress of the in-flight negentropy sync, if any: `(total, current)`. + /// Progress of the in-flight negentropy sync, if any. pub fn sync_progress(&self) -> Option<(u64, u64)> { self.sync_progress } - /// Update the signer (any type implementing the async signer traits, - /// e.g. `Keys`, `NostrConnect`, a browser extension proxy). + /// Update the signer. pub fn set_signer(&mut self, new_signer: T, cx: &mut Context) where T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static, @@ -1024,7 +1076,7 @@ impl Backend { Ok(()) }); - self.tasks.push(task); + self.push_task(task); } /// Add relays and connect to them. @@ -1039,14 +1091,10 @@ impl Backend { Ok::<(), Error>(()) }); - self.tasks.push(cx.spawn(async move |this, cx| { + self.push_task(cx.spawn(async move |this, cx| { match task.await { Ok(()) => { - this.update(cx, |this, cx| { - this.connected = true; - cx.emit(BackendEvent::Connected); - cx.notify(); - })?; + this.update(cx, |_this, cx| cx.notify())?; } Err(e) => { this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; @@ -1056,48 +1104,9 @@ impl Backend { })); } - /// Add relays used only for discovery (e.g. NIP-65 indexers) and - /// connect to them. No subscriptions or writes are routed through them. - pub fn add_discovery_relays(&mut self, urls: Vec, cx: &mut Context) { - let client = self.client.clone(); - - let task = cx.background_spawn(async move { - for url in urls { - client - .add_relay(&url) - .capabilities(RelayCapabilities::DISCOVERY) - .await?; - } - client.connect().await; - Ok::<(), Error>(()) - }); - - self.tasks.push(cx.spawn(async move |this, cx| { - if let Err(e) = task.await { - this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; - } - Ok(()) - })); - } - - /// Start a persistent subscription. Matching events are stored in the - /// database automatically and surface as [`BackendEvent::NostrUpdate`]. - pub fn subscribe(&mut self, filter: Filter, cx: &mut Context) { - let client = self.client.clone(); - - let task = cx.background_spawn(async move { client.subscribe(filter).await.map(|_| ()) }); - - self.tasks.push(cx.spawn(async move |this, cx| { - if let Err(e) = task.await { - this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; - } - Ok(()) - })); - } - - /// Whether an identical fetch was started within [`FETCH_DEDUP_WINDOW`] - /// and is still recent enough to suppress a duplicate. Records the - /// fingerprint (after pruning expired entries) when returning `false`. + /// Whether an identical fetch started within [`FETCH_DEDUP_WINDOW`] is still recent. + /// + /// Records the fingerprint when returning `false`, pruning expired entries first. fn fetch_recently_started(&mut self, fingerprint: u64) -> bool { self.recent_fetches .retain(|_, started| started.elapsed() < FETCH_DEDUP_WINDOW); @@ -1108,17 +1117,7 @@ impl Backend { false } - /// Connect to relays announced by a repository (NIP-34 `relays` tag) and - /// fetch its events from them: a one-shot auto-closing subscription for - /// `filters`, plus a negentropy sync so issues, patches and PRs stored - /// only on those relays are not missed. - /// - /// Deduplicated: an identical request (same relays and filters) started - /// within [`FETCH_DEDUP_WINDOW`] is skipped, so a second panel for the - /// same repository doesn't re-run the fetch. - /// - /// Best-effort: failures are logged, not surfaced. The relays stay in - /// the pool, so later publishes for this repository also reach them. + /// Connect to a repository's announced relays, its NIP-34 `relays` tag. pub fn connect_repo_relays( &mut self, relays: Vec, @@ -1134,7 +1133,7 @@ impl Backend { let client = self.client.clone(); - self.tasks.push(cx.spawn(async move |this, cx| { + self.push_task(cx.spawn(async move |this, cx| { if let Err(e) = connect_repo_relays_only(&client, relays, filters).await { log::warn!("repo relay fetch failed: {e}"); // Allow an immediate retry after a failure. @@ -1147,17 +1146,14 @@ impl Backend { })); } - /// Start a one-shot subscription targeted only at the bootstrap relays, - /// auto-closing after EOSE or a short timeout. Matching events are stored - /// in the database and surface as [`BackendEvent::NostrUpdate`] while the - /// subscription is open. + /// One-shot subscription on the bootstrap relays only. pub fn subscribe_bootstrap(&mut self, filters: Vec, cx: &mut Context) { let client = self.client.clone(); let task = cx.background_spawn(async move { subscribe_bootstrap_only(&client, filters).await }); - self.tasks.push(cx.spawn(async move |this, cx| { + self.push_task(cx.spawn(async move |this, cx| { if let Err(e) = task.await { this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } @@ -1165,14 +1161,7 @@ impl Backend { })); } - /// Negentropy-sync the given filter against the bootstrap relays: - /// reconciles the local database with the relays in both directions. - /// Emits [`BackendEvent::SyncProgress`] while running (throttled to - /// whole-percent changes) and [`BackendEvent::Synced`] on completion. - /// - /// Deduplicated: an identical sync started within - /// [`FETCH_DEDUP_WINDOW`] is skipped. Observers still see the original - /// sync's progress and completion events. + /// Negentropy-sync the given filter against the bootstrap relays. pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context) { let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter)); if self.fetch_recently_started(fingerprint) { @@ -1187,7 +1176,7 @@ impl Backend { let (tx, mut rx) = SyncProgress::channel(); - self.tasks.push(cx.spawn(async move |this, cx| { + self.push_task(cx.spawn(async move |this, cx| { let mut last_percent: u64 = 0; while rx.changed().await.is_ok() { @@ -1220,7 +1209,7 @@ impl Backend { sync_bootstrap_only(&client, filter, opts).await }); - self.tasks.push(cx.spawn(async move |this, cx| { + self.push_task(cx.spawn(async move |this, cx| { match task.await { Ok(summary) => { log::debug!( @@ -1247,12 +1236,7 @@ impl Backend { })); } - /// Sign, broadcast and locally store an event. Emits - /// [`BackendEvent::Published`] on success so stores can refresh. - /// - /// The task yields the outcome of this specific action (for inline - /// progress/errors) and is owned by the caller; dropping it cancels - /// the publish. + /// Sign, broadcast and locally store an event. pub fn send( &mut self, builder: EventBuilder, @@ -1261,76 +1245,32 @@ impl Backend { let client = self.client.clone(); let signer = self.signer.clone(); - cx.spawn(async move |this, cx| { - // Sign with the current signer, broadcast, and save locally so - // the event is immediately visible to database queries. - let work = cx.background_spawn(async move { - let event = builder.finalize_async(&signer).await?; - let output = client.send_event(&event).await?; - - if output.success.is_empty() && !output.failed.is_empty() { - let reasons = output - .failed - .values() - .cloned() - .collect::>() - .join(", "); - return Err(anyhow!("event not accepted by any relay: {reasons}")); - } - - Ok(event) - }); - - let result = work.await; - - match &result { - Ok(event) => { - this.update(cx, |_this, cx| { - cx.emit(BackendEvent::Published(Box::new(event.clone()))); - }) - .ok(); - } - Err(e) => { - this.update(cx, |_this, cx| { - cx.emit(BackendEvent::error(e.to_string())); - }) - .ok(); - } - } - - result + self.publish_task(cx, async move { + // Sign with the current signer, broadcast and save locally. + // The event is immediately visible to database queries. + let event = builder.finalize_async(&signer).await?; + broadcast_event(&client, &event).await }) } - /// Broadcast and locally store an already-signed event, like - /// [`Self::send`] without the signing step. Callers that signed early - /// (e.g. to learn the event id before pushing a commit to the grasp - /// servers) publish through this. + /// Broadcast and locally store an already-signed event. pub fn publish_event( &mut self, event: Event, cx: &mut Context, ) -> Task> { let client = self.client.clone(); + self.publish_task(cx, async move { broadcast_event(&client, &event).await }) + } + /// Run `work` in the background, then emit its outcome as a [`BackendEvent`]. + fn publish_task( + &mut self, + cx: &mut Context, + work: impl Future> + 'static + Send, + ) -> Task> { cx.spawn(async move |this, cx| { - let work = cx.background_spawn(async move { - let output = client.send_event(&event).await?; - - if output.success.is_empty() && !output.failed.is_empty() { - let reasons = output - .failed - .values() - .cloned() - .collect::>() - .join(", "); - return Err(anyhow!("event not accepted by any relay: {reasons}")); - } - - Ok(event.clone()) - }); - - let result = work.await; + let result = cx.background_spawn(work).await; match &result { Ok(event) => { @@ -1351,24 +1291,11 @@ impl Backend { }) } - /// Publish a NIP-34 repository announcement (kind 30617) with the - /// current signer. The returned task yields the published event, so - /// callers can show inline progress/errors. - pub fn publish_announcement( - &mut self, - announcement: GitRepositoryAnnouncement, - cx: &mut Context, - ) -> Task> { - self.send(announcement.into_event_builder(), cx) - } - - /// Sign, broadcast and store an event without awaiting the result; - /// failures surface through [`BackendEvent::Error`]. The spawned task is - /// owned by the backend, so it is cancelled when the backend is dropped. + /// Sign, broadcast and store an event without awaiting the result. fn send_fire_and_forget(&mut self, builder: EventBuilder, cx: &mut Context) { let task = self.send(builder, cx); - self.tasks.push(cx.spawn(async move |this, cx| { + self.push_task(cx.spawn(async move |this, cx| { if let Err(e) = task.await { this.update(cx, |_this, cx| { cx.emit(BackendEvent::error(e.to_string())); @@ -1379,10 +1306,7 @@ impl Backend { })); } - /// Publish a NIP-09 deletion event for `events` (best-effort), so a - /// publish that fails midway can retract the events that were already - /// broadcast to relays. Failures are logged, not surfaced: the caller's - /// error already told the user what happened. + /// Publish NIP-09 deletions for `events`, best-effort. fn retract_events(&mut self, events: &[Event], cx: &mut Context) { if events.is_empty() { return; @@ -1397,7 +1321,7 @@ impl Backend { let task = self.send(EventBuilder::new(Kind::EventDeletion, "").tags(tags), cx); - self.tasks.push(cx.spawn(async move |_this, _cx| { + self.push_task(cx.spawn(async move |_this, _cx| { if let Err(e) = task.await { log::warn!("failed to retract repository events: {e}"); } @@ -1406,8 +1330,28 @@ impl Backend { } } -/// Fingerprint of a relay + filter set, for fetch dedup. Relays and -/// filters are sorted first so the fingerprint is order-independent. +/// Broadcast an event and fail when no relay accepted it. +/// +/// The client stores accepted events locally, visible to database queries. +async fn broadcast_event(client: &Client, event: &Event) -> Result { + let output = client.send_event(event).await?; + + if output.success.is_empty() && !output.failed.is_empty() { + let reasons = output + .failed + .values() + .cloned() + .collect::>() + .join(", "); + return Err(anyhow!("event not accepted by any relay: {reasons}")); + } + + Ok(event.clone()) +} + +/// Fingerprint of a relay and filter set, for fetch dedup. +/// +/// Relays and filters are sorted first, so the fingerprint is order-independent. fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 { let mut relays: Vec<&str> = relays.to_vec(); relays.sort_unstable(); @@ -1420,11 +1364,7 @@ fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 { hasher.finish() } -/// Add the given relays, connect to them, and fetch the filters: a one-shot -/// subscription (auto-closing after EOSE) plus a negentropy sync per filter -/// as a second pass, so events that race with the subscription or relays -/// with flaky EOSE behavior can't be missed. Relays without NEG-XX support -/// just fail the sync step; the subscription already covered them. +/// Add the given relays, connect and fetch the filters. async fn connect_repo_relays_only( client: &Client, relays: Vec, @@ -1438,8 +1378,8 @@ async fn connect_repo_relays_only( for url in &relays { added |= client.add_relay(url).await?; } - // Connecting is only needed when the pool grew; connected relays no-op, - // but the call still iterates every relay in the pool. + + // Connect only when the pool grew. if added { client.connect().await; } @@ -1454,9 +1394,7 @@ async fn connect_repo_relays_only( .collect(); client.subscribe(target).close_on(opts).await?; - // Sync the filters concurrently: each reconciles against every relay - // either way, and a relay without NEG-XX support otherwise serializes - // its initial timeout behind every other filter. + // Sync the filters concurrently. let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5)); let syncs = filters.into_iter().map(|filter| { let client = &client; @@ -1473,14 +1411,13 @@ async fn connect_repo_relays_only( } } }); + futures::future::join_all(syncs).await; Ok(()) } -/// Subscribe only on the bootstrap relays, auto-closing after EOSE or a -/// short timeout. Use for one-shot data fetches (repo events, profiles) -/// instead of persistent gossip-routed subscriptions. +/// Subscribe only on the bootstrap relays. pub(crate) async fn subscribe_bootstrap_only( client: &Client, filters: Vec, @@ -1520,17 +1457,15 @@ fn with_master_key(uri: &str, keys: &Keys) -> String { format!("{uri}{separator}master={nsec}") } -/// 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`. +/// Base URL of a grasp server, `https://`. +/// +/// `ws://` grasp servers use `http://`, like ngit. pub(crate) 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). + // `domain()` drops the 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. + // `ws://` grasp servers, e.g. local dev relays, speak plain HTTP. let scheme = if relay.scheme().is_secure() { "https" } else { @@ -1539,18 +1474,66 @@ pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option { Some(format!("{scheme}://{host}{port}")) } -/// The GRASP clone URL of a repository on a grasp server, matching the -/// format ngit announces: `https:////.git`. +/// GRASP clone URL of a repository on a grasp server. 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() } -/// Push the repository at `path` to every grasp server: a server that -/// rejects the push is logged, but the push only fails when no server -/// accepted it. `push` performs the single-server push (e.g. -/// [`signed_git::push_main`] for the create flow, [`signed_git::push_all`] -/// for the init flow). +/// GRASP-06 contributor namespace URL of a pull request tip. +pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String { + format!("{base_url}/prs/{npub}/{repo_id}.git") +} + +/// Assemble the `clone` URLs of a pull request. +/// +/// The author's GRASP-06 `/prs/` URLs come first. +pub(crate) fn pr_clone_urls(prs_urls: Vec, base_clone_urls: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut urls = Vec::new(); + for url in prs_urls.into_iter().chain(base_clone_urls) { + if seen.insert(url.to_string()) { + urls.push(url); + } + } + urls +} + +/// The `g` tag servers of one kind-10317 grasp list event, in tag order. +fn grasp_list_servers(event: &Event) -> Vec { + event + .tags + .iter() + .filter(|tag| tag.kind() == "g") + .filter_map(|tag| tag.content()) + .filter_map(|url| RelayUrl::parse(url).ok()) + .collect() +} + +/// Grasp servers of the newest kind-10317 grasp list among `events`. +fn latest_grasp_list_servers(events: Vec) -> Vec { + events + .into_iter() + .max_by_key(|event| event.created_at) + .map(|event| grasp_list_servers(&event)) + .unwrap_or_default() +} + +/// Resolve the user's published grasp servers from the local database. +pub async fn user_grasp_list_servers( + client: Client, + user: PublicKey, +) -> Result, Error> { + let events: Vec = client + .database() + .query(filters::grasp_list(user)) + .await? + .into_iter() + .collect(); + Ok(latest_grasp_list_servers(events)) +} + +/// Push the repository at `path` to every grasp server. async fn push_to_grasp_servers( path: PathBuf, owner: String, @@ -1587,7 +1570,6 @@ async fn push_to_grasp_servers( } /// 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) { match credential.split_once("master=") { Some((base, nsec)) => { @@ -1628,4 +1610,90 @@ mod tests { "https://gitnostr.com/npub1test/my-repo.git" ); } + + #[test] + fn grasp06_prs_url_matches_ngit_format() { + assert_eq!( + grasp06_prs_url("https://relay.ngit.dev", "npub1author", "my-repo"), + "https://relay.ngit.dev/prs/npub1author/my-repo.git" + ); + // `ws://` grasp servers, local dev, keep their plain-HTTP base. + assert_eq!( + grasp06_prs_url("http://localhost:8080", "npub1author", "my-repo"), + "http://localhost:8080/prs/npub1author/my-repo.git" + ); + } + + #[test] + fn pr_clone_urls_orders_author_first_and_deduplicates() { + let prs = vec![ + Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"), + Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"), + ]; + let base = vec![ + Url::parse("https://a.example/npub1owner/repo.git").expect("url"), + Url::parse("https://b.example/npub1owner/repo.git").expect("url"), + Url::parse("https://b.example/npub1owner/repo.git").expect("url"), + ]; + + let urls = pr_clone_urls(prs, base); + assert_eq!( + urls.iter().map(ToString::to_string).collect::>(), + vec![ + "https://a.example/prs/npub1me/repo.git", + "https://a.example/npub1owner/repo.git", + "https://b.example/npub1owner/repo.git", + ] + ); + } + + fn grasp_list_event(servers: &[&str], created_at: u64) -> Event { + let keys = Keys::generate(); + let tags: Vec = servers + .iter() + .map(|url| Tag::parse(vec!["g", *url]).expect("valid tag")) + .collect(); + EventBuilder::new(Kind::GitUserGraspList, "") + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .finalize(&keys) + .expect("signed event") + } + + #[test] + fn grasp_list_servers_reads_g_tags_in_order() { + let event = grasp_list_event( + &["wss://first.example", "wss://second.example", "not a url"], + 1000, + ); + + let servers = grasp_list_servers(&event); + assert_eq!( + servers.iter().map(ToString::to_string).collect::>(), + vec!["wss://first.example", "wss://second.example"] + ); + } + + #[test] + fn latest_grasp_list_servers_takes_the_newest_list_and_falls_back_empty() { + let old = grasp_list_event(&["wss://old.example"], 1000); + let fresh = grasp_list_event(&["wss://fresh.example", "wss://also.example"], 2000); + + // The newest list wins, its `g` order preserved. + let servers = latest_grasp_list_servers(vec![old.clone(), fresh.clone()]); + assert_eq!( + servers.iter().map(ToString::to_string).collect::>(), + vec!["wss://fresh.example", "wss://also.example"] + ); + + // The order of the input events does not matter. + let servers = latest_grasp_list_servers(vec![fresh, old]); + assert_eq!( + servers.iter().map(ToString::to_string).collect::>(), + vec!["wss://fresh.example", "wss://also.example"] + ); + + // No list at all, empty, so the caller falls back to the defaults. + assert!(latest_grasp_list_servers(Vec::new()).is_empty()); + } } diff --git a/crates/signed_state/src/checkouts.rs b/crates/signed_state/src/checkouts.rs new file mode 100644 index 0000000..fb4abcb --- /dev/null +++ b/crates/signed_state/src/checkouts.rs @@ -0,0 +1,1168 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use anyhow::Error; +use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; +use nostr::prelude::*; +use settings::{CheckoutRecord, SettingsStore}; +use signed_core::{Announcement, RepoAddr}; + +use crate::backend::{Backend, BackendEvent}; +use crate::git_store::GitStore; +use crate::local_repos::LocalReposStore; +use crate::refresh::{RefreshGate, RefreshRequest}; +use crate::repo_list::RepoListStore; + +/// Delay between a refresh request and the actual re-computation. +const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); + +/// How often the statuses are recomputed against the local refs. +/// +/// A commit lands in a checkout long before the remote reconciliation cadence, +/// so this fast pass surfaces ready-to-push and ready-to-contribute checkouts +/// within a second or two. It reads the tracking refs only, no network. +const LOCAL_POLL: Duration = Duration::from_secs(2); + +/// How often a full pass refreshes the remotes while any repository panel is open. +const STATUS_POLL: Duration = Duration::from_secs(15); + +/// Remote refresh interval for the `ready to push` badges of the user's own repositories. +const PUSH_POLL: Duration = Duration::from_secs(60); + +/// Maximum checkouts considered per repository when computing statuses. +const MAX_STATUS_CHECKOUTS: usize = 8; + +struct GlobalCheckoutsStore(Entity); + +impl Global for GlobalCheckoutsStore {} + +/// One associated local checkout of a repository. +/// +/// Carries the git facts needed to suggest a pull request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CheckoutStatus { + /// The checkout folder. + pub path: PathBuf, + /// The branch checked out. A detached checkout is idle and yields no status. + pub branch: String, + /// Commit the branch points at, for tip-based PR dedupe. + pub head: String, + /// What the branch is compared against. + /// For ready-to-contribute statuses, the announced HEAD branch. + /// The fallbacks are `main`, then the first local branch. + /// For ready-to-push statuses, the remote-tracking ref. + /// Unpushed commits are counted against it. + /// + /// It is `refs/remotes/origin/`, else `origin/HEAD` for new branches. + pub base: String, + /// Commits in `base..branch`. + /// + /// Zero-ahead checkouts are dropped, so this is always above zero. + pub ahead: u32, +} + +/// A remembered record, with the address already parsed. +struct Remembered { + path: PathBuf, + addr: RepoAddr, + last_used: u64, +} + +/// Global store of local-checkout associations and per-checkout statuses. +/// +/// Readers (the sidebar rows, the repository panels) observe this store and +/// derive what they display from their own snapshots, so publishing needs no +/// fine-grained entities: the store notifies when a slice changed and each +/// reader re-derives only what it shows. +pub struct CheckoutsStore { + /// Checkout paths per announced repository. + by_repo: HashMap>, + /// Ready-to-contribute statuses of the requested repositories. + /// + /// Those are the repository detail panels currently open. + statuses: HashMap>, + /// Repositories whose statuses are recomputed on every input change. + /// + /// Those are the repository detail panels currently open. + status_requested: HashSet, + /// Repositories whose `ready to push` statuses are recomputed on the same cycle. + /// + /// The sidebar rows of the user's own repositories and their detail panels. + push_requested: HashSet, + /// The ready-to-push statuses of the requested own repositories. + push_statuses: HashMap>, + /// Last announced head branch per requested repository. + /// + /// A recompute defaults the base the same way. + requested_head: HashMap>, + /// Refresh coalescing, see [`RefreshGate`]. + refresh: RefreshGate, + /// A local status pass timer is pending. + local_pending: bool, + /// When the last full pass (with a remote refresh) completed. + /// + /// The local pass runs a full pass again once this is older than the + /// reconciliation cadence, so remote moves still land. + last_full_sync: Option, + tasks: Vec>>, + _subscriptions: Vec, +} + +impl CheckoutsStore { + /// Retrieve the global checkouts store. + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + pub(crate) fn set_global(entity: Entity, cx: &mut App) { + cx.set_global(GlobalCheckoutsStore(entity)); + } + + /// Create the store. + pub fn new(cx: &mut Context) -> Self { + let mut subscriptions = Vec::new(); + + if !cfg!(target_arch = "wasm32") { + let settings = SettingsStore::global(cx); + let local = LocalReposStore::global(cx); + let repos = RepoListStore::global(cx); + let backend = Backend::global(cx); + + subscriptions.push(cx.observe(&settings, |this, _settings, cx| { + this.refresh(cx); + })); + + subscriptions.push(cx.observe(&local, |this, _local, cx| { + this.refresh(cx); + })); + + subscriptions.push(cx.observe(&repos, |this, _repos, cx| { + this.refresh(cx); + })); + + // Another identity's repositories must not keep the old statuses alive. + subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| { + if matches!(event, BackendEvent::SignerChanged) { + this.status_requested.clear(); + this.push_requested.clear(); + this.requested_head.clear(); + this.statuses.clear(); + this.push_statuses.clear(); + cx.notify(); + this.refresh(cx); + } + })); + } + + let mut store = Self { + by_repo: HashMap::new(), + statuses: HashMap::new(), + status_requested: HashSet::new(), + push_requested: HashSet::new(), + push_statuses: HashMap::new(), + requested_head: HashMap::new(), + refresh: RefreshGate::default(), + local_pending: false, + last_full_sync: None, + tasks: Vec::new(), + _subscriptions: subscriptions, + }; + + if !cfg!(target_arch = "wasm32") { + store.refresh(cx); + } + + store + } + + /// Track a spawned task, pruning finished tasks first. + /// + /// Keeps the store's task list bounded by the number of in-flight tasks. + fn push_task(&mut self, task: Task>) { + self.tasks.retain(|task| !task.is_ready()); + self.tasks.push(task); + } + + /// Remember a successful local-checkout use. + pub fn record(&mut self, path: PathBuf, addr: RepoAddr, cx: &mut Context) { + if cfg!(target_arch = "wasm32") { + return; + } + let last_used = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let addr_str = addr.to_string(); + + let settings = SettingsStore::global(cx); + settings.update(cx, |settings, cx| { + settings.edit( + |s| { + s.checkouts + .records + .retain(|r| !(r.path == path && r.addr == addr_str)); + s.checkouts.records.push(CheckoutRecord { + path, + addr: addr_str, + last_used, + }); + }, + cx, + ); + }); + } + + /// The associated checkouts of `addr`, freshest first. + /// + /// Empty when none are known or the resolution has not run yet. + pub fn associations_of(&self, addr: &RepoAddr) -> Vec { + self.by_repo.get(addr).cloned().unwrap_or_default() + } + + /// Ask for the `ready to contribute` statuses of `addr` to stay current. + /// Called while the repository's detail panel is open. + /// + /// `announced_head` is the announced HEAD branch, used to default the base. + pub fn request_statuses( + &mut self, + addr: &RepoAddr, + announced_head: Option, + cx: &mut Context, + ) { + self.status_requested.insert(addr.clone()); + if announced_head != self.requested_head.get(addr).cloned().flatten() { + self.requested_head.insert(addr.clone(), announced_head); + } + self.refresh(cx); + } + + /// The ready-to-contribute statuses of `addr`. + /// + /// Empty while none are known or nothing is ahead. + pub fn ready_statuses_of(&self, addr: &RepoAddr) -> Vec { + self.statuses.get(addr).cloned().unwrap_or_default() + } + + /// Ask for the `ready to push` statuses of `addr` to stay current. + pub fn request_push_statuses(&mut self, addr: &RepoAddr, cx: &mut Context) { + self.push_requested.insert(addr.clone()); + self.refresh(cx); + } + + /// The checkout at `path` was just pushed to the remote. + /// + /// Its ready-to-push status is obsolete. Drop it from the cached statuses + /// and notify observers right away, so the sidebar badge and the push + /// banner update immediately instead of waiting for the next background + /// pass, which re-scans and re-fetches the remote. The debounced refresh + /// reconciles the remaining checkouts of the repository afterwards. + pub fn checkout_pushed(&mut self, addr: &RepoAddr, path: &Path, cx: &mut Context) { + let mut removed = false; + + if let Some(statuses) = self.push_statuses.get_mut(addr) { + let before = statuses.len(); + statuses.retain(|status| status.path.as_path() != path); + removed = statuses.len() != before; + + if removed && statuses.is_empty() { + self.push_statuses.remove(addr); + } + } + + if removed { + cx.notify(); + } + + // The other checkouts of this repository still need re-deriving + // against the remote, now that the pushed refs landed there. + self.request_push_statuses(addr, cx); + } + + /// The ready-to-push statuses of `addr`. + /// + /// Empty while none are known or nothing is unpushed. + pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec { + self.push_statuses.get(addr).cloned().unwrap_or_default() + } + + /// The number of unpushed commits for a repository. + pub fn unpushed(&self, addr: &RepoAddr) -> usize { + self.push_statuses + .get(addr) + .map(|list| list.iter().map(|status| status.ahead as usize).sum()) + .unwrap_or(0) + } + + /// Re-resolve the associations and the requested statuses. + /// + /// Requests arriving while a pass runs fold into a follow-up. + pub fn refresh(&mut self, cx: &mut Context) { + if self.refresh.request() != RefreshRequest::Schedule { + return; + } + + let task = cx.spawn(async move |this, cx| { + cx.background_executor().timer(REFRESH_DEBOUNCE).await; + this.update(cx, |this, cx| this.run_refresh(cx)) + }); + + self.push_task(task); + } + + /// One full resolve and apply cycle, the debounced entry point. + /// + /// Re-resolves the associations from the settings, the scan and the + /// announcements, then recomputes the requested statuses against freshly + /// fetched remotes. Full passes run on every input change and on the + /// remote reconciliation cadence ([`Self::local_tick`]); they also restart + /// the fast local pass. + fn run_refresh(&mut self, cx: &mut Context) { + self.refresh.begin(); + + // Inputs snapshot, all cheap shared reads. + let records = { + let settings = SettingsStore::global(cx); + settings.read(cx).settings().checkouts.records.clone() + }; + + let remembered: Vec = records + .into_iter() + .filter_map(|record| { + let addr = record.addr.parse::().ok()?; + Some(Remembered { + path: record.path, + addr, + last_used: record.last_used, + }) + }) + .collect(); + + let announcements = RepoListStore::global(cx).read(cx).announcements.clone(); + let scanned = LocalReposStore::global(cx).read(cx).repos.clone(); + let cache_root = GitStore::global(cx).cache().root().canonicalize().ok(); + + let requested: Vec<(RepoAddr, Option)> = self + .status_requested + .iter() + .map(|addr| { + ( + addr.clone(), + self.requested_head.get(addr).cloned().flatten(), + ) + }) + .collect(); + + let push_requested: Vec = self.push_requested.iter().cloned().collect(); + let poll = !self.status_requested.is_empty() || !self.push_requested.is_empty(); + + let work = cx.background_spawn(async move { + // Read the git facts of every scanned repository off the main thread. + // + // The facts are the origin URL and the root commit, both CLI reads. + let mut facts: Vec<(PathBuf, Option, Option)> = Vec::new(); + for path in scanned.iter() { + // The browser's mirror clones share the announce URLs and EUCs. They are not user checkouts. + if cache_root + .as_ref() + .is_some_and(|root| path.starts_with(root)) + { + continue; + } + let origin = signed_git::origin_url(path).ok().flatten(); + let root = signed_git::root_commit(path).ok().flatten(); + facts.push((path.clone(), origin, root)); + } + + let associations = resolve_associations(&remembered, &facts, announcements.iter()); + + // Missing directories are stale records, drop them. + let associations: HashMap> = associations + .into_iter() + .map(|(addr, paths)| (addr, paths.into_iter().filter(|p| p.is_dir()).collect())) + .collect(); + + let (statuses, push_statuses) = + compute_statuses(&associations, &requested, &push_requested, true); + + Ok::<_, Error>((associations, statuses, push_statuses)) + }); + + self.push_task(cx.spawn(async move |this, cx| { + let (associations, statuses, push_statuses) = match work.await { + Ok(results) => results, + Err(_) => { + // Git reads are best-effort, keep the last results. + return this.update(cx, |this, cx| { + this.refresh.abort(); + if poll { + this.schedule_local_pass(cx); + } + }); + } + }; + + let again = this.update(cx, |this, cx| { + let associations_changed = this.by_repo != associations; + let statuses_changed = this.statuses != statuses; + let push_statuses_changed = this.push_statuses != push_statuses; + + this.by_repo = associations; + this.statuses = statuses; + this.push_statuses = push_statuses; + + // Notify only when something actually changed, so observers + // skip the no-op heartbeats. + if associations_changed || statuses_changed || push_statuses_changed { + cx.notify(); + } + + this.last_full_sync = Some(Instant::now()); + this.refresh.finish() + })?; + + if again { + this.update(cx, |this, cx| this.refresh(cx))?; + } + + // Restart the fast local pass so the freshly resolved + // associations drive it. The pass itself decides when the next + // full pass runs. + this.update(cx, |this, cx| { + if poll { + this.schedule_local_pass(cx); + } + })?; + + Ok(()) + })); + } + + /// Schedule the fast local status pass, unless one is already pending. + /// + /// Every [`LOCAL_POLL`] the pass recomputes the requested statuses against + /// the local refs — no network — so a new commit in a checkout surfaces in + /// a second or two instead of at the next remote reconciliation. + fn schedule_local_pass(&mut self, cx: &mut Context) { + if self.local_pending { + return; + } + self.local_pending = true; + + let task = cx.spawn(async move |this, cx| { + cx.background_executor().timer(LOCAL_POLL).await; + this.update(cx, |this, cx| { + this.local_pending = false; + this.local_tick(cx); + }) + }); + + self.push_task(task); + } + + /// The fast local status pass. + /// + /// Recomputes the statuses against the local refs; when the remote + /// reconciliation cadence elapsed, it runs a full pass instead so pushes + /// made elsewhere do not linger as `to push`. + fn local_tick(&mut self, cx: &mut Context) { + // Nothing watched: the chain idles out until a new request restarts it. + if self.status_requested.is_empty() && self.push_requested.is_empty() { + return; + } + + // A full pass or a fresh request covers this tick, skip it. + if self.refresh.running() || self.refresh.debouncing() { + self.schedule_local_pass(cx); + return; + } + + // Open panels get the faster remote cadence. + let cadence = if self.status_requested.is_empty() { + PUSH_POLL + } else { + STATUS_POLL + }; + + let full_due = self + .last_full_sync + .is_none_or(|sync| sync.elapsed() >= cadence); + + if full_due { + self.last_full_sync = Some(Instant::now()); + self.refresh(cx); + } else { + self.run_local_statuses(cx); + } + + self.schedule_local_pass(cx); + } + + /// Recompute the requested statuses against the tracking refs only. + /// + /// The refs were last refreshed by a full pass. Comparing against them is + /// enough to pick up new local commits, and skipping the network keeps + /// this pass cheap enough to run every [`LOCAL_POLL`]. + fn run_local_statuses(&mut self, cx: &mut Context) { + let associations = self.by_repo.clone(); + + let requested: Vec<(RepoAddr, Option)> = self + .status_requested + .iter() + .map(|addr| { + ( + addr.clone(), + self.requested_head.get(addr).cloned().flatten(), + ) + }) + .collect(); + + let push_requested: Vec = self.push_requested.iter().cloned().collect(); + + let work = cx.background_spawn(async move { + let (statuses, push_statuses) = + compute_statuses(&associations, &requested, &push_requested, false); + Ok::<_, Error>((statuses, push_statuses)) + }); + + self.push_task(cx.spawn(async move |this, cx| { + let Ok((statuses, push_statuses)) = work.await else { + // Git reads are best-effort, keep the last results. + return Ok(()); + }; + + this.update(cx, |this, cx| { + // A full pass or a fresh request will apply fresher data + // (the tracking refs move only when a full pass fetches). + if this.refresh.running() || this.refresh.debouncing() { + return; + } + + let statuses_changed = this.statuses != statuses; + let push_statuses_changed = this.push_statuses != push_statuses; + + this.statuses = statuses; + this.push_statuses = push_statuses; + + if statuses_changed || push_statuses_changed { + cx.notify(); + } + })?; + + Ok(()) + })); + } +} + +/// Identity of a repository URL. +fn url_identity(url: &str) -> Option<(String, Option, String)> { + let parsed = Url::parse(url).ok()?; + let host = parsed.host_str()?.to_ascii_lowercase(); + let mut path = parsed.path().trim_matches('/').to_owned(); + if let Some(stripped) = path.strip_suffix(".git") { + path = stripped.to_owned(); + } + Some((host, parsed.port(), path)) +} + +/// Whether two repository URLs point at the same repository. +fn same_repo_url(a: &str, b: &str) -> bool { + match (url_identity(a), url_identity(b)) { + (Some(a), Some(b)) => a == b, + _ => a == b, + } +} + +/// Resolve the associations between local checkouts and announced repositories. +fn resolve_associations<'a>( + remembered: &[Remembered], + scanned: &[(PathBuf, Option, Option)], + announcements: impl IntoIterator, +) -> HashMap> { + let announcements: Vec<&Announcement> = announcements.into_iter().collect(); + let mut out: HashMap> = HashMap::new(); + + let mut sorted: Vec<&Remembered> = remembered.iter().collect(); + sorted.sort_by_key(|record| std::cmp::Reverse(record.last_used)); + for record in sorted { + let paths = out.entry(record.addr.clone()).or_default(); + if !paths.contains(&record.path) { + paths.push(record.path.clone()); + } + } + + for (path, origin, root) in scanned { + for announcement in &announcements { + let url_match = origin.as_deref().is_some_and(|origin| { + announcement + .clone + .iter() + .any(|url| same_repo_url(origin, url.as_str())) + }); + + let euc_match = root + .as_deref() + .is_some_and(|root| announcement.euc.as_deref() == Some(root)); + + if url_match || euc_match { + let paths = out.entry(announcement.addr()).or_default(); + if !paths.contains(path) { + paths.push(path.clone()); + } + } + } + } + + out +} + +/// Whether the worktree of `path` has uncommitted changes. +fn worktree_dirty(path: &Path) -> bool { + let output = Command::new("git") + .arg("-C") + .arg(path) + .args(["status", "--porcelain"]) + .env("GIT_TERMINAL_PROMPT", "0") + .output(); + match output { + Ok(output) => !String::from_utf8_lossy(&output.stdout).trim().is_empty(), + Err(_) => false, + } +} + +/// Commits in `base..branch` of the checkout at `path`. +fn commits_ahead(path: &Path, base: &str, branch: &str) -> u32 { + let output = Command::new("git") + .arg("-C") + .arg(path) + .args(["rev-list", "--count", &format!("{base}..{branch}")]) + .env("GIT_TERMINAL_PROMPT", "0") + .output(); + match output { + Ok(output) => String::from_utf8_lossy(&output.stdout) + .trim() + .parse() + .unwrap_or(0), + Err(_) => 0, + } +} + +/// The branch checked out at `path`, read via `git branch --show-current`. +fn current_branch_of(path: &Path) -> Option { + let output = Command::new("git") + .arg("-C") + .arg(path) + .args(["branch", "--show-current"]) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .ok()?; + let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + (!branch.is_empty()).then_some(branch) +} + +/// The ready-to-contribute status of one checkout. +fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option { + let branches = signed_git::worktree_branches(path).ok()?; + if branches.is_empty() || worktree_dirty(path) { + return None; + } + let branch = current_branch_of(path)?; + let head = signed_git::head_commit_id(path).ok().flatten()?; + let base = announced_head + .filter(|name| branches.iter().any(|b| b == name)) + .map(str::to_owned) + .or_else(|| branches.iter().find(|b| *b == "main").cloned()) + .or_else(|| branches.first().cloned())?; + if base == branch { + return None; + } + let ahead = commits_ahead(path, &base, &branch); + (ahead > 0).then_some(CheckoutStatus { + path: path.to_path_buf(), + branch, + head, + base, + ahead, + }) +} + +/// Whether the reference `name` exists in the checkout at `path`. +/// +/// Example, `refs/remotes/origin/main`. +fn ref_exists(path: &Path, name: &str) -> bool { + let output = Command::new("git") + .arg("-C") + .arg(path) + .args(["rev-parse", "--verify", "--quiet", name]) + .env("GIT_TERMINAL_PROMPT", "0") + .output(); + matches!(output, Ok(output) if output.status.success()) +} + +/// The `ready to push` status of one checkout of the user's own repository. +/// +/// `fetch` refreshes the remote heads first, so a full pass sees pushes made +/// elsewhere; the fast local pass skips it and compares against the tracking +/// refs left by the last full pass, which is enough to detect local commits. +fn checkout_push_status(path: &Path, fetch: bool) -> Option { + if worktree_dirty(path) { + return None; + } + + let branch = current_branch_of(path)?; + let head = signed_git::head_commit_id(path).ok().flatten()?; + let origin = signed_git::origin_url(path).ok().flatten()?; + + if fetch { + // Refresh the remote heads first. + // Commits made elsewhere or pushed from another machine must not linger as `to push`. + signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok(); + } + + let remote = format!("refs/remotes/origin/{branch}"); + + // A branch never fetched or pushed yet compares against the remote HEAD. + // The remote HEAD is the fork point in practice. + let base = if ref_exists(path, &remote) { + remote + } else if ref_exists(path, "refs/remotes/origin/HEAD") { + "refs/remotes/origin/HEAD".to_owned() + } else { + return None; + }; + + let ahead = commits_ahead(path, &base, &branch); + + (ahead > 0).then_some(CheckoutStatus { + path: path.to_path_buf(), + branch, + head, + base, + ahead, + }) +} + +/// Compute the requested statuses against the checkout paths of `associations`. +/// +/// Shared by the full and the local pass. `fetch` refreshes the checkouts' +/// remote heads first, so the full pass sees remote moves; the fast local +/// pass reads the tracking refs only, which is enough to detect local commits. +fn compute_statuses( + associations: &HashMap>, + requested: &[(RepoAddr, Option)], + push_requested: &[RepoAddr], + fetch: bool, +) -> ( + HashMap>, + HashMap>, +) { + let mut statuses: HashMap> = HashMap::new(); + for (addr, announced_head) in requested { + let Some(paths) = associations.get(addr) else { + continue; + }; + + let list: Vec = paths + .iter() + .take(MAX_STATUS_CHECKOUTS) + .filter_map(|path| checkout_status(path, announced_head.as_deref())) + .collect(); + + if !list.is_empty() { + statuses.insert(addr.clone(), list); + } + } + + let mut push_statuses: HashMap> = HashMap::new(); + for addr in push_requested { + let Some(paths) = associations.get(addr) else { + continue; + }; + + let list: Vec = paths + .iter() + .take(MAX_STATUS_CHECKOUTS) + .filter_map(|path| checkout_push_status(path, fetch)) + .collect(); + + if !list.is_empty() { + push_statuses.insert(addr.clone(), list); + } + } + + (statuses, push_statuses) +} + +/// Whether the pull request `pr` already proposes the same change as `checkout`. +pub fn pr_proposes_checkout( + pr: &Event, + open: bool, + user: PublicKey, + checkout: &CheckoutStatus, +) -> bool { + if pr.kind != Kind::GitPullRequest || !open || pr.pubkey != user { + return false; + } + let branch_matches = pr + .tags + .iter() + .find(|t| t.kind() == "branch-name") + .and_then(|t| t.content()) + .is_some_and(|name| name == checkout.branch); + // A renamed branch falls back to the proposed tip commit. + let tip_matches = pr + .tags + .iter() + .find(|t| t.kind() == "c") + .and_then(|t| t.content()) + .is_some_and(|tip| tip == checkout.head); + branch_matches || tip_matches +} + +#[cfg(test)] +mod tests { + use signed_core::{RepoAddr, repo_addr}; + + use super::*; + + #[test] + fn same_repo_url_ignores_the_transport_scheme() { + // grasp announce vs https origin, with and without `.git`. + assert!(same_repo_url( + "grasp://relay.ngit.dev/npub1test/repo", + "https://relay.ngit.dev/npub1test/repo.git" + )); + assert!(same_repo_url( + "ws://localhost:8080/npub1test/repo", + "http://localhost:8080/npub1test/repo" + )); + // The port and the path matter. + assert!(!same_repo_url( + "wss://localhost:8081/npub1test/repo", + "wss://localhost:8080/npub1test/repo" + )); + assert!(!same_repo_url( + "wss://host/npub1test/repo", + "wss://host/npub1other/repo" + )); + // Unparseable URLs compare literally. + assert!(same_repo_url("/local/path", "/local/path")); + assert!(!same_repo_url("/local/path", "/local/other")); + } + + fn remembered(path: &str, id: &str, last_used: u64) -> Remembered { + Remembered { + path: PathBuf::from(path), + addr: addr(id), + last_used, + } + } + + fn scanned( + path: &str, + origin: Option<&str>, + root: Option<&str>, + ) -> (PathBuf, Option, Option) { + ( + PathBuf::from(path), + origin.map(str::to_owned), + root.map(str::to_owned), + ) + } + + const KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + + fn owner() -> PublicKey { + Keys::new(SecretKey::from_hex(KEY).expect("secret")).public_key() + } + + fn addr(id: &str) -> RepoAddr { + repo_addr(owner(), id) + } + + /// Build one announcement by the fixed test owner. + /// Takes `clone` URLs and an EUC. + fn announcement(id: &str, clones: &[&str], euc: Option<&str>) -> Announcement { + let keys = Keys::new(SecretKey::from_hex(KEY).expect("secret")); + let mut tags = vec![Tag::parse(vec!["d", id]).expect("tag")]; + for url in clones { + tags.push(Tag::parse(vec!["clone", *url]).expect("tag")); + } + if let Some(euc) = euc { + tags.push(Tag::parse(vec!["r", euc, "euc"]).expect("tag")); + } + let event = EventBuilder::new(Kind::GitRepoAnnouncement, "") + .tags(tags) + .finalize(&keys) + .expect("signed"); + Announcement::from_event(&event).expect("parsed") + } + + #[test] + fn resolve_orders_remembered_freshest_first() { + let announcements = vec![announcement("repo", &[], None)]; + let base = addr("repo"); + + let resolved = resolve_associations( + &[ + remembered("/old", "repo", 100), + remembered("/fresh", "repo", 200), + remembered("/other", "unrelated", 300), + ], + &[], + &announcements, + ); + + let paths = resolved.get(&base).expect("associations"); + assert_eq!(paths, &vec![PathBuf::from("/fresh"), PathBuf::from("/old")]); + // Records for repositories without announcements stay inert. + assert_eq!(resolved.len(), 2); + } + + #[test] + fn resolve_matches_scanned_repos_by_origin_and_euc() { + let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; + let announcements = vec![ + announcement("repo", &["grasp://host/npub1x/repo"], None), + announcement("family", &[], Some(euc)), + ]; + let repo = addr("repo"); + let family = addr("family"); + + let resolved = resolve_associations( + &[], + &[ + // Origin matches modulo scheme and the `.git` suffix. + scanned("/clone", Some("https://host/npub1x/repo.git"), None), + // Root commit matches the family EUC. + scanned("/family-checkout", None, Some(euc)), + // Neither matches anything. + scanned("/unrelated", Some("https://elsewhere/x.git"), None), + ], + &announcements, + ); + + assert_eq!( + resolved.get(&repo).expect("repo matches"), + &vec![PathBuf::from("/clone")] + ); + assert_eq!( + resolved.get(&family).expect("family matches"), + &vec![PathBuf::from("/family-checkout")] + ); + assert_eq!(resolved.len(), 2); + } + + #[test] + fn resolve_deduplicates_paths_remembering_first() { + let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; + let announcements = vec![announcement( + "repo", + &["https://host/npub1x/repo.git"], + Some(euc), + )]; + let base = addr("repo"); + + // The same path is both remembered and scanned, its origin matches. + // The remembered occurrence wins and the path is listed once. + let resolved = resolve_associations( + &[remembered("/shared", "repo", 100)], + &[ + scanned("/shared", Some("https://host/npub1x/repo"), None), + scanned("/scanned-only", Some("https://host/npub1x/repo.git"), None), + ], + &announcements, + ); + + let paths = resolved.get(&base).expect("associations"); + assert_eq!( + paths, + &vec![PathBuf::from("/shared"), PathBuf::from("/scanned-only")] + ); + } + + #[test] + fn checkout_status_reports_ahead_branches_only() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("repo"); + let _initial = signed_git::init_repository(&path, "My Repo", "").expect("init"); + let run = |args: &[&str]| { + let status = Command::new("git") + .current_dir(&path) + .env("GIT_AUTHOR_NAME", "Test Author") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test Author") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .env("GIT_EDITOR", "true") + .args(args) + .status() + .expect("git"); + assert!(status.success(), "git {args:?} failed"); + }; + let commit = |message: &str| { + run(&["add", "-A"]); + run(&["commit", "-m", message]); + }; + + // A feature branch ahead of main, ready to contribute. + run(&["checkout", "-b", "feature"]); + std::fs::write(path.join("feature.txt"), "x\n").expect("write"); + commit("feature work"); + let status = checkout_status(&path, Some("main")).expect("status"); + assert_eq!(status.branch, "feature"); + assert_eq!(status.base, "main"); + assert_eq!(status.ahead, 1); + assert_eq!(status.head.len(), 40); + + // Dirty worktrees are never suggested. + std::fs::write(path.join("uncommitted.txt"), "y\n").expect("write"); + assert!(checkout_status(&path, Some("main")).is_none()); + run(&["checkout", "--", "."]); + + // Even on main, nothing to propose. + run(&["checkout", "main"]); + assert_eq!(checkout_status(&path, Some("main")), None); + } + + #[test] + fn checkout_push_status_counts_unpushed_commits_only() { + // The `grasp remote` is a plain repository the checkout clones from. + // Its origin URL is a local path, so the whole cycle runs offline. + // Git refuses pushes to a checked-out branch by default. + // Act like a grasp server and allow them. + let dir = tempfile::tempdir().expect("tempdir"); + let remote = dir.path().join("remote"); + signed_git::init_repository(&remote, "My Repo", "").expect("init"); + let config = Command::new("git") + .args(["config", "receive.denyCurrentBranch", "ignore"]) + .current_dir(&remote) + .status() + .expect("git config"); + assert!(config.success(), "git config failed"); + + let checkout = dir.path().join("checkout"); + let status = Command::new("git") + .args([ + "clone", + "-q", + remote.to_str().unwrap(), + checkout.to_str().unwrap(), + ]) + .status() + .expect("git clone"); + assert!(status.success(), "git clone failed"); + + let run = |args: &[&str]| { + let status = Command::new("git") + .current_dir(&checkout) + .env("GIT_AUTHOR_NAME", "Test Author") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test Author") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .env("GIT_EDITOR", "true") + .args(args) + .status() + .expect("git"); + assert!(status.success(), "git {args:?} failed"); + }; + + // A fresh clone has nothing to push. + assert_eq!(checkout_push_status(&checkout, true), None); + + // One local commit, ready to push, counted against the remote. + std::fs::write(checkout.join("work.txt"), "x\n").expect("write"); + run(&["add", "-A"]); + run(&["commit", "-m", "local work"]); + let status = checkout_push_status(&checkout, true).expect("status"); + assert_eq!(status.branch, "main"); + assert_eq!(status.base, "refs/remotes/origin/main"); + assert_eq!(status.ahead, 1); + assert_eq!(status.head.len(), 40); + + // The local-only pass reads the tracking refs, no fetch needed: + // a commit lands locally long before the remote is reconciled. + let local = checkout_push_status(&checkout, false).expect("local status"); + assert_eq!(local.ahead, 1); + + // After the push the same commit is on the remote, idle again. + run(&["push", "origin", "main"]); + assert_eq!(checkout_push_status(&checkout, true), None); + + // A commit made by someone else on the remote must not count as local work. + // It is behind, not ahead. + let remote_run = |args: &[&str]| { + let status = Command::new("git") + .current_dir(&remote) + .env("GIT_AUTHOR_NAME", "Other Author") + .env("GIT_AUTHOR_EMAIL", "other@example.com") + .env("GIT_COMMITTER_NAME", "Other Author") + .env("GIT_COMMITTER_EMAIL", "other@example.com") + .args(args) + .status() + .expect("git"); + assert!(status.success(), "git {args:?} failed"); + }; + std::fs::write(remote.join("other.txt"), "y\n").expect("write"); + remote_run(&["add", "-A"]); + remote_run(&["commit", "-m", "remote work"]); + assert_eq!(checkout_push_status(&checkout, true), None); + } + + fn pr_event(author: &str, tags: &[&[&str]]) -> Event { + let keys = Keys::new(SecretKey::from_hex(author).expect("secret")); + let tags: Vec = tags + .iter() + .map(|t| Tag::parse(t.to_vec()).expect("valid tag")) + .collect(); + EventBuilder::new(Kind::GitPullRequest, "") + .tags(tags) + .finalize(&keys) + .expect("signed event") + } + + fn status(branch: &str, head: &str) -> CheckoutStatus { + CheckoutStatus { + path: PathBuf::from("/checkout"), + branch: branch.to_owned(), + head: head.to_owned(), + base: "main".to_owned(), + ahead: 1, + } + } + + #[test] + fn pr_proposes_checkout_matches_branch_or_tip() { + let author = "0000000000000000000000000000000000000000000000000000000000000002"; + let tip = "aa231c4c6a5777dc89b42207b499891a344add5c"; + + // A matching `branch-name` covers the proposal. + let pr = pr_event(author, &[&["branch-name", "feature"], &["c", tip]]); + let status = status("feature", "bb231c4c6a5777dc89b42207b499891a344add5c"); + assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status)); + + // Without a branch-name tag, the `c` tip still matches for a renamed branch. + let pr = pr_event( + author, + &[&["c", "bb231c4c6a5777dc89b42207b499891a344add5c"]], + ); + assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status)); + + // Someone else's PR, a closed PR, a different branch and a missing tip. + // They all leave the checkout uncovered. + let pr = pr_event(author, &[&["branch-name", "feature"]]); + assert!(!pr_proposes_checkout(&pr, false, pr.pubkey, &status)); + let other = pr_event( + "0000000000000000000000000000000000000000000000000000000000000003", + &[&["branch-name", "feature"]], + ); + assert!(!pr_proposes_checkout(&pr, true, other.pubkey, &status)); + let other_branch = pr_event(author, &[&["branch-name", "other"]]); + assert!(!pr_proposes_checkout( + &other_branch, + true, + other_branch.pubkey, + &status + )); + } +} diff --git a/crates/signed_state/src/git_store.rs b/crates/signed_state/src/git_store.rs index ab7b5df..2d49a4c 100644 --- a/crates/signed_state/src/git_store.rs +++ b/crates/signed_state/src/git_store.rs @@ -7,17 +7,12 @@ struct GlobalGitStore(GitCache); impl Global for GlobalGitStore {} -/// Global access to the on-disk git clone cache (grasp mirrors). -/// -/// Installed at startup via [`GitStore::set_global`]; see also -/// [`signed_state::init`]. +/// Global access to the on-disk git clone cache, the grasp mirrors. #[derive(Debug, Clone)] pub struct GitStore(GitCache); impl GitStore { /// Register the clone cache rooted at `root` as an app-wide global. - /// Replaces any previously installed store (see [`signed_state::init`], which - /// installs an empty one). pub fn set_global(root: impl Into, cx: &mut App) -> Self { let store = Self::new(root); cx.set_global(GlobalGitStore(store.0.clone())); @@ -25,10 +20,6 @@ impl GitStore { } /// The app-wide clone cache. - /// - /// # Panics - /// - /// Panics if [`GitStore::set_global`] was never called. pub fn global(cx: &App) -> Self { Self(cx.global::().0.clone()) } diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 25a0968..9d90403 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -1,13 +1,16 @@ mod backend; +mod checkouts; mod git_store; mod local_repos; mod profile; +mod refresh; mod repo; mod repo_list; use std::path::{Path, PathBuf}; -pub use backend::{Backend, BackendEvent}; +pub use backend::{Backend, BackendEvent, user_grasp_list_servers}; +pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout}; pub use git_store::GitStore; use gpui::{App, AppContext, Entity}; pub use local_repos::LocalReposStore; @@ -16,16 +19,18 @@ pub use profile::{Profile, ProfileStore}; pub use repo::RepoStore; pub use repo_list::{RepoActivityCounts, RepoListStore}; use signed_nostr::new_backend; -pub use utils::shorten_pubkey; /// Initialize the backend and stores, and install them as globals. /// Call once at startup, before opening any window that uses the stores. #[cfg(not(target_arch = "wasm32"))] -pub fn init(db_path: impl AsRef, scan_paths: Vec, cx: &mut App) -> Entity { - // rustls uses the `aws_lc_rs` provider by default; ignore if already installed. - rustls::crypto::aws_lc_rs::default_provider() - .install_default() - .ok(); +pub fn init( + db_path: impl AsRef, + repos_root: impl Into, + scan_paths: Vec, + cx: &mut App, +) -> Entity { + // rustls uses the `aws_lc_rs` provider by default. + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let (client, signer) = cx.foreground_executor().block_on(async move { let path = db_path.as_ref().to_path_buf(); @@ -37,16 +42,10 @@ pub fn init(db_path: impl AsRef, scan_paths: Vec, cx: &mut App) - let entity = cx.new(|cx| Backend::new(client, signer, cx)); Backend::set_global(entity.clone(), cx); ProfileStore::set_global(cx.new(ProfileStore::new), cx); - - // Seed the explore list from the local database; relay syncs continue - // in the background. - RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx); - - // The clone cache is native-only; wasm registers an empty store so - // `GitStore::global` still works. - GitStore::set_global(PathBuf::new(), cx); - + RepoListStore::set_global(cx.new(RepoListStore::new), cx); + GitStore::set_global(repos_root, cx); LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx); + CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx); entity } @@ -55,13 +54,12 @@ pub fn init(db_path: impl AsRef, scan_paths: Vec, cx: &mut App) - #[cfg(target_arch = "wasm32")] pub fn init(cx: &mut App) -> Entity { let (client, signer) = new_backend().expect("failed to initialize nostr backend"); - let entity = cx.new(|cx| Backend::new(client, signer, cx)); Backend::set_global(entity.clone(), cx); ProfileStore::set_global(cx.new(ProfileStore::new), cx); - RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx); + RepoListStore::set_global(cx.new(RepoListStore::new), cx); GitStore::set_global(PathBuf::new(), cx); LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx); - + CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx); entity } diff --git a/crates/signed_state/src/local_repos.rs b/crates/signed_state/src/local_repos.rs index a1335ae..776943d 100644 --- a/crates/signed_state/src/local_repos.rs +++ b/crates/signed_state/src/local_repos.rs @@ -45,9 +45,7 @@ impl LocalReposStore { store } - /// Forget a repository that has just been published to NIP-34, - /// so it leaves the local list immediately. A later rescan re-discovers it from disk, - /// the sidebar additionally hides published repositories by identifier. + /// Forget a repository that has just been published to NIP-34. pub fn remove(&mut self, path: &Path, cx: &mut Context) { self.repos = Arc::new( self.repos @@ -95,8 +93,7 @@ impl LocalReposStore { dirty })?; - // Scans requested while this one was running are coalesced into - // a single follow-up scan. + // Scans requested while this one ran are coalesced into one follow-up scan. if again { this.update(cx, |this, cx| this.rescan(cx))?; } diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs index 22624f9..c2d74ab 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/signed_state/src/profile.rs @@ -3,14 +3,17 @@ use std::collections::{HashMap, HashSet}; use std::time::{Duration, Instant}; use anyhow::Error; -use flume::{Receiver, RecvTimeoutError, Sender}; -use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Task}; +use flume::{Receiver, Sender}; +use gpui::{ + App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, Task, + WeakEntity, +}; use nostr_sdk::prelude::*; use utils::shorten_pubkey; use crate::backend::{Backend, BackendEvent, sync_bootstrap_only}; -/// A user profile (kind `0` metadata), as plain data for the UI. +/// A user profile as plain data for the UI, from the kind-0 metadata. #[derive(Debug, Clone)] pub struct Profile { public_key: PublicKey, @@ -60,20 +63,15 @@ impl Profile { } } -/// Message from the fetch task to the main thread. -enum Dispatch { - /// A batched sync finished; re-read seen profiles from the database. - Synced, -} - /// How long to wait for more requests before firing a batched sync. const BATCH_TIMEOUT: Duration = Duration::from_millis(500); -/// Global profile cache. Profiles are fetched in batches and kept as plain -/// data; the whole store notifies on change. +/// Global profile cache. +/// +/// Profiles are fetched in batches and kept as plain data. pub struct ProfileStore { profiles: HashMap, - /// Public keys we've already requested this session (main thread only). + /// Public keys requested this session, main thread only. seen: RefCell>, /// Sender for queuing fetch requests, batched by a background task. sender: Sender, @@ -111,24 +109,15 @@ impl ProfileStore { _ => {} }); - // Fetch requests are queued on a channel and synced in batches by a - // background task. + // Fetch requests are queued on a channel, batched into one sync per debounce window. let client = backend.read(cx).client(); let (sender, receiver) = flume::unbounded::(); - let (dispatch_tx, dispatch_rx) = flume::unbounded::(); + let entity = cx.entity().downgrade(); let mut tasks = Vec::new(); - tasks.push(cx.background_spawn(async move { - Self::handle_requests(&client, &dispatch_tx, &receiver).await - })); - - // Re-read seen profiles from the database after each batch sync. - tasks.push(cx.spawn(async move |this, cx| { - while let Ok(Dispatch::Synced) = dispatch_rx.recv_async().await { - this.update(cx, |this, cx| this.apply_seen(cx)).ok(); - } - Ok(()) + tasks.push(cx.spawn(async move |_this, cx| { + Self::handle_requests(entity, &client, &receiver, cx).await })); let mut store = Self { @@ -143,8 +132,17 @@ impl ProfileStore { store } - /// Get a profile. Returns a placeholder (default metadata) and queues a - /// fetch if the profile isn't cached yet. + /// Track a spawned task, pruning finished tasks first. + /// + /// Keeps the store's task list bounded by the number of in-flight tasks. + fn push_task(&mut self, task: Task>) { + self.tasks.retain(|task| !task.is_ready()); + self.tasks.push(task); + } + + /// Get a profile. + /// + /// Returns a placeholder with default metadata. Queues a fetch when the profile is not cached yet. pub fn get(&self, public_key: &PublicKey) -> Profile { if let Some(profile) = self.profiles.get(public_key) { return profile.clone(); @@ -170,7 +168,8 @@ impl ProfileStore { let filter = Filter::new().kind(Kind::Metadata).limit(200); let events = client.database().query(filter).await?; - // Parse off the main thread; only plain profiles cross back. + // Parse off the main thread. + // Only plain profiles cross back. let profiles: Vec = events .into_iter() .map(|event| { @@ -182,7 +181,7 @@ impl ProfileStore { Ok::<_, Error>(profiles) }); - self.tasks.push(cx.spawn(async move |this, cx| { + self.push_task(cx.spawn(async move |this, cx| { let profiles = work.await?; this.update(cx, |this, cx| { @@ -205,7 +204,8 @@ impl ProfileStore { let filter = Filter::new().kind(Kind::Metadata).author(public_key); let events = client.database().query(filter).await?; - // Parse off the main thread; only the profile crosses back. + // Parse off the main thread. + // Only the profile crosses back. let profile = events .into_iter() .max_by_key(|e| e.created_at) @@ -217,7 +217,7 @@ impl ProfileStore { Ok::<_, Error>(profile) }); - self.tasks.push(cx.spawn(async move |this, cx| { + self.push_task(cx.spawn(async move |this, cx| { let profile = work.await?; this.update(cx, |this, cx| { @@ -231,8 +231,9 @@ impl ProfileStore { })); } - /// Re-read the latest metadata of every requested author from the local - /// database (used after a sync, which produces no NostrUpdate events). + /// Re-read the latest metadata of every requested author from the local database. + /// + /// Used after a sync, which produces no NostrUpdate events. fn apply_seen(&mut self, cx: &mut Context) { let authors: Vec = self.seen.borrow().iter().copied().collect(); @@ -272,7 +273,7 @@ impl ProfileStore { Ok::<_, Error>(profiles) }); - self.tasks.push(cx.spawn(async move |this, cx| { + self.push_task(cx.spawn(async move |this, cx| { let profiles = work.await?; this.update(cx, |this, cx| { @@ -286,44 +287,57 @@ impl ProfileStore { })); } - /// Sync metadata for requested authors in batches, debounced to collect - /// requests. Runs on a background thread; results are dispatched to the - /// main thread, which re-reads the database. + /// Sync metadata for requested authors in batches, debounced to collect requests. + /// + /// After each batch, the seen profiles are re-read from the database on the main thread. async fn handle_requests( + this: WeakEntity, client: &Client, - dispatch: &Sender, receiver: &Receiver, + cx: &mut AsyncApp, ) -> Result<(), Error> { let mut batch: HashSet = HashSet::new(); loop { // Wait for the first request of a batch. - match receiver.recv_timeout(BATCH_TIMEOUT) { + match receiver.recv_async().await { Ok(public_key) => { batch.insert(public_key); } - Err(RecvTimeoutError::Disconnected) => return Ok(()), - Err(RecvTimeoutError::Timeout) => continue, - }; + Err(_) => return Ok(()), + } // Collect everything that arrives within the debounce window. + // The channel has no async timeout, race the receive against a timer. let deadline = Instant::now() + BATCH_TIMEOUT; - while let Ok(public_key) = receiver.recv_deadline(deadline) { - batch.insert(public_key); + loop { + let now = Instant::now(); + if now >= deadline { + break; + } + let timer = cx.background_executor().timer(deadline - now); + futures::pin_mut!(timer); + let recv = receiver.recv_async(); + futures::pin_mut!(recv); + match futures::future::select(recv, timer).await { + futures::future::Either::Left((Ok(public_key), _)) => { + batch.insert(public_key); + } + futures::future::Either::Left((Err(_), _)) => return Ok(()), + futures::future::Either::Right(_) => break, + } } let filter = Filter::new() .kind(Kind::Metadata) .authors(batch.drain().collect::>()); - // Negentropy-sync with the bootstrap relays. Synced events are - // written to the database directly (no NostrUpdate), so re-apply - // from the database afterwards. + // Negentropy-sync with the bootstrap relays. + // Synced events are written to the database directly, no NostrUpdate. + // Re-apply from the database afterwards. match sync_bootstrap_only(client, filter, SyncOptions::default()).await { Ok(_) => { - if dispatch.send(Dispatch::Synced).is_err() { - log::warn!("profile dispatch channel closed, dropping sync result"); - } + let _ = this.update(cx, |this, cx| this.apply_seen(cx)); } Err(e) => log::warn!("profile sync failed: {e}"), } diff --git a/crates/signed_state/src/refresh.rs b/crates/signed_state/src/refresh.rs new file mode 100644 index 0000000..e0aa024 --- /dev/null +++ b/crates/signed_state/src/refresh.rs @@ -0,0 +1,69 @@ +/// Refresh coalescing shared by the event stores. +/// +/// [`crate::RepoStore`], [`crate::RepoListStore`] and [`crate::CheckoutsStore`] +/// re-query their inputs on a debounce timer with the same policy: +/// a request arriving while a run is in flight is folded into a follow-up run, +/// a request arriving while the debounce timer is pending is dropped by it. +#[derive(Debug, Default)] +pub struct RefreshGate { + /// A run is in flight. + running: bool, + /// A request arrived while a run was in flight. + dirty: bool, + /// The debounce timer is pending. + debouncing: bool, +} + +/// What a refresh request decided. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefreshRequest { + /// No run or timer covers the request, start the debounce timer. + Schedule, + /// A run or pending timer already covers the request. + Fold, +} + +impl RefreshGate { + /// Whether a run is in flight. + pub fn running(&self) -> bool { + self.running + } + + /// Whether the debounce timer is pending. + pub fn debouncing(&self) -> bool { + self.debouncing + } + + /// A new refresh request arrived. + /// + /// Folded into a follow-up run while one is in flight, dropped while the + /// debounce timer is pending, otherwise starts the timer. + pub fn request(&mut self) -> RefreshRequest { + if self.running { + self.dirty = true; + RefreshRequest::Fold + } else if self.debouncing { + RefreshRequest::Fold + } else { + self.debouncing = true; + RefreshRequest::Schedule + } + } + + /// The debounce timer fired and the run starts now. + pub fn begin(&mut self) { + self.debouncing = false; + self.running = true; + } + + /// The run ended. Whether a request arrived while it ran. + pub fn finish(&mut self) -> bool { + self.running = false; + std::mem::take(&mut self.dirty) + } + + /// The run was abandoned, e.g. on error. Pending follow-up requests survive. + pub fn abort(&mut self) { + self.running = false; + } +} diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 3f482e3..eb12437 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -5,34 +5,37 @@ use std::time::Duration; use anyhow::Error; use bitcoin_hashes::sha1::Hash as Sha1Hash; -use gpui::{AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity}; +use gpui::{App, AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity}; use nostr::event::IntoEventBuilder; use nostr_sdk::prelude::*; use signed_core::{ - Announcement, COVER_NOTE_KIND, Deletions, RepoAddr, RepoStatus, build_state, cover_note, - filters, labels_and_subject, parse_state, pull_request_patch, pull_request_patches, - subject_override, + Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch, + pull_request_patches, }; -use crate::backend::{Backend, BackendEvent, grasp_base_url}; +use crate::backend::{ + Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, user_grasp_list_servers, +}; +use crate::checkouts::CheckoutsStore; use crate::git_store::GitStore; +use crate::refresh::{RefreshGate, RefreshRequest}; +use crate::repo_list::RepoListStore; -/// Delay between a refresh request and the actual re-query, so bursts of -/// events (e.g. per-event `NostrUpdate`s) collapse into one query. +/// Delay between a refresh request and the actual re-query. const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); -/// Maximum size of one patch event, following NIP-34's guidance that -/// patches should be used when each event is under 60kb. +/// Maximum size of one patch event. +/// +/// NIP-34 suggests patches when each event is under 60kb. const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024; -/// Per-repository store: announcement, state, issues, patches, PRs, -/// comments and their resolved statuses. Always derived from the local -/// database. +/// Per-repository store. +/// +/// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses. +/// Always derived from the local database. pub struct RepoStore { addr: RepoAddr, pub announcement: Option, - /// `(refname, commit-id)` pairs from the latest state announcement. - pub refs: Vec<(String, String)>, /// Branch pointed to by `HEAD` in the latest state announcement. pub head: Option, pub issues: Vec, @@ -40,39 +43,40 @@ pub struct RepoStore { pub pull_requests: Vec, /// Comments on issues / PRs, oldest first. pub comments: Vec, - /// Resolved status per root event (issue / patch / PR), recomputed on - /// every refresh so render paths are HashMap lookups instead of - /// scanning all status events per root. + /// Resolved status per root event, issue, patch or PR. status_by_root: HashMap, - /// Open issue / root PR counts, computed with [`Self::status_by_root`] - /// on every refresh. + /// Open issue and root PR counts. + /// Computed with [`Self::status_by_root`] on every refresh. open_issue_count: usize, open_pr_count: usize, - /// Kind-1624 cover notes and kind-1985 label events referencing this - /// repository's roots (ngit / GitWorkshop extensions). - cover_notes: Vec, - labels: Vec, - /// Incremented on every applied refresh; views key their derived-data - /// caches to it instead of recomputing on every render. + /// Incremented on every applied refresh. + /// + /// Views key their derived-data caches to it instead of recomputing on every render. version: u64, /// Error of the last action initiated from this store, if any. pub last_error: Option, - /// Non-fatal warning of the last action (e.g. a PR published without - /// its commit reaching a grasp server), if any. + /// Non-fatal warning of the last action, if any. + /// + /// Example, a PR published without its commit reaching a grasp server. pub last_warning: Option, - /// Relays announced by this repository (NIP-34 `relays` tag) that we - /// have already been asked to connect to and fetch from, to avoid - /// re-subscribing on every refresh. + /// A republish or a checkout push is in flight. + /// + /// Views show a spinner and disable their push triggers while it is set. + pub pushing: bool, + /// A clone-into-a-folder operation is in flight. + /// + /// Views show a spinner and disable the clone trigger while it is set. + pub cloning: bool, + /// Relays already asked to connect to, from this repository's NIP-34 `relays` tag. + /// + /// Avoids re-subscribing and re-fetching on every refresh. repo_relays: HashSet, - /// Root events (issues, patches, PRs) for which the per-root fetches - /// (NIP-22 comments, statuses without an `a` tag, cover notes and - /// labels) have already been requested, to avoid re-fetching on every - /// refresh. + /// Root events, issues, patches and PRs, already fetched per root. + /// + /// The per-root fetches cover NIP-22 comments and statuses without an `a` tag. root_fetches: HashSet, - refreshing: bool, - refresh_dirty: bool, - /// A refresh is waiting out [`REFRESH_DEBOUNCE`]. - debouncing: bool, + /// Refresh coalescing, see [`RefreshGate`]. + refresh: RefreshGate, tasks: Vec>>, _subscription: Subscription, } @@ -90,25 +94,22 @@ impl RepoStore { let coordinate = update.coordinate.as_ref() == Some(&this.addr); let author = update.author == this.addr.public_key; let kind = update.kind == Kind::GitRepoAnnouncement; - // NIP-22 comments carry no `a` tag, so they can't be - // matched by coordinate; any comment may reference this - // repository's roots. + // NIP-22 comments carry no `a` tag. + // Coordinate matching fails for them. + // Any comment may reference this repository's roots. let comment = update.kind == Kind::Comment; - // Status events may omit their `a` tag (NIP-34), so any - // status event may reference a root of this repository. + // Status events may omit their `a` tag, NIP-34. + // Any status event may reference a root of this repository. let status = RepoStatus::from_kind(update.kind).is_some(); - // Cover notes and labels carry no `a` tag either. - let annotation = update.kind == COVER_NOTE_KIND || update.kind == Kind::Label; - deletion || coordinate || (author && kind) || comment || status || annotation + deletion || coordinate || (author && kind) || comment || status } BackendEvent::Published(event) => { let kind = event.kind == Kind::GitRepoAnnouncement; let author = event.pubkey == this.addr.public_key; let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr); - // Locally published deletions may target any event of - // this repository; refresh so they take effect - // immediately, like relay deletions. + // Locally published deletions may target any event of this repository. + // Refresh so they take effect immediately, like relay deletions. let deletion = event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; @@ -125,7 +126,6 @@ impl RepoStore { let mut store = Self { addr, announcement: None, - refs: Vec::new(), head: None, issues: Vec::new(), patches: Vec::new(), @@ -134,24 +134,22 @@ impl RepoStore { status_by_root: HashMap::new(), open_issue_count: 0, open_pr_count: 0, - cover_notes: Vec::new(), - labels: Vec::new(), version: 0, last_error: None, last_warning: None, + pushing: false, + cloning: false, repo_relays: HashSet::new(), root_fetches: HashSet::new(), - refreshing: false, - refresh_dirty: false, - debouncing: false, + refresh: RefreshGate::default(), _subscription: subscription, tasks: Vec::new(), }; store.subscribe_remote(cx); - // The announcement we opened the repo from may already list its - // relays; connect to them right away instead of waiting for the - // bootstrap fetch to return the same event. + // The announcement we opened the repo from may already list its relays. + // Connect to them right away. + // Do not wait for the bootstrap fetch to return the same event. store.connect_announced_relays(&announced_relays, cx); store.refresh(cx); store @@ -162,44 +160,41 @@ impl RepoStore { &self.addr } - /// Returns the repository's name, or "Unknown" if not known. + /// Returns the repository's name, or `Unknown` when not known. pub fn name(&self) -> SharedString { self.announcement .as_ref() .map_or(SharedString::default(), |a| { - a.name.clone().unwrap_or(SharedString::from("Unknown")) + SharedString::from(a.name.as_deref().unwrap_or("Unknown")) }) } - /// Filters that make up a repository: announcement, state, - /// activity and deletions targeting it. + /// Filters that make up a repository. + /// + /// Announcement, state, activity and deletions targeting it. fn repo_filters(addr: &RepoAddr) -> Vec { let mut filters = vec![ - // Announcement and state share author and identifier, so they - // combine into one filter: one fewer negentropy reconciliation - // per relay when fetching from the repo's announced relays. + // Announcement and state share author and identifier. + // They combine into one filter, one fewer negentropy reconciliation per relay. Filter::new() .kinds([Kind::GitRepoAnnouncement, Kind::RepoState]) .author(addr.public_key) .identifier(addr.identifier.clone()), filters::activity(addr), ]; - // Deletion requests (NIP-09/62) must be known before any event of - // this repository can be shown. + // Deletion requests, NIP-09/62, must be known before any event is shown. filters.extend(filters::deletions_for_repo(addr)); filters } - /// Fetch this repository's events from the relays announced in its - /// NIP-34 `relays` tag. Deduplicated: each relay is only contacted once - /// per store, so refreshes after the first are no-ops unless the - /// announcement lists new relays. + /// Fetch this repository's events from the relays in its NIP-34 `relays` tag. fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context) { let new: Vec = relays .iter() .filter(|url| !self.repo_relays.contains(*url)) .cloned() .collect(); + if new.is_empty() { return; } @@ -207,12 +202,13 @@ impl RepoStore { let backend = Backend::global(cx); let addr = self.addr.clone(); + backend.update(cx, |backend, cx| { backend.connect_repo_relays(new, Self::repo_filters(&addr), cx); }); } - /// Fetch this repository's events from the bootstrap relays + /// Fetch this repository's events from the bootstrap relays. fn subscribe_remote(&mut self, cx: &mut Context) { let backend = Backend::global(cx); let addr = self.addr.clone(); @@ -223,26 +219,15 @@ impl RepoStore { } /// Re-query the local database and update all fields. - /// - /// The query and processing run on a background thread, - /// only the results are applied on the main thread. pub fn refresh(&mut self, cx: &mut Context) { - if self.refreshing { - self.refresh_dirty = true; + if self.refresh.request() != RefreshRequest::Schedule { return; } - if self.debouncing { - return; - } - self.debouncing = true; let task = cx.spawn(async move |this, cx| { cx.background_executor().timer(REFRESH_DEBOUNCE).await; - this.update(cx, |this, cx| { - this.debouncing = false; - this.run_refresh(cx); - }) + this.update(cx, |this, cx| this.run_refresh(cx)) }); self.tasks.retain(|task| !task.is_ready()); @@ -250,7 +235,7 @@ impl RepoStore { } fn run_refresh(&mut self, cx: &mut Context) { - self.refreshing = true; + self.refresh.begin(); let backend = Backend::global(cx); let client = backend.read(cx).client(); @@ -270,8 +255,8 @@ impl RepoStore { let deletions = Deletions::from_events(deletion_events); - // Parse and sort off the main thread; only plain data - // crosses back into the entity. + // Parse and sort off the main thread. + // Only plain data crosses back into the entity. let all_announcements = announcements .into_iter() .filter(|e| !deletions.is_deleted(e)); @@ -284,7 +269,6 @@ impl RepoStore { let (mut issues, mut patches, mut pull_requests, mut statuses, mut comments) = (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()); - let (mut cover_notes, mut labels): (Vec, Vec) = (Vec::new(), Vec::new()); for event in activity { if deletions.is_deleted(&event) { @@ -300,9 +284,8 @@ impl RepoStore { } } - // NIP-22 comments reference their root via an `E`/`e` tag rather - // than the repository's `a` tag, so query them by the root events - // of this repository. + // NIP-22 comments reference their root via an `E` or `e` tag. + // Not the repository's `a` tag, so query them by the root events. let mut seen_comments: HashSet = comments.iter().map(|e| e.id).collect(); let db = client.database(); @@ -320,8 +303,8 @@ impl RepoStore { } } - // Status events may omit their `a` tag, - // so also query them by the root events they reference. + // Status events may omit their `a` tag. + // Query them by the root events they reference too. let mut seen_statuses: HashSet = statuses.iter().map(|e| e.id).collect(); let db = client.database(); @@ -339,41 +322,17 @@ impl RepoStore { } } - // Cover notes (1624) and label events (1985) reference - // so query them per root like comments and statuses. - let mut seen_cover_notes: HashSet = cover_notes.iter().map(|e| e.id).collect(); - let mut seen_labels: HashSet = labels.iter().map(|e| e.id).collect(); - let db = client.database(); - - let roots = issues - .iter() - .chain(&patches) - .chain(&pull_requests) - .map(|e| e.id); - - for root in roots { - for event in db.query(filters::annotations_for([root])).await? { - if deletions.is_deleted(&event) { - continue; - } - if event.kind == COVER_NOTE_KIND && seen_cover_notes.insert(event.id) { - cover_notes.push(event); - } else if event.kind == Kind::Label && seen_labels.insert(event.id) { - labels.push(event); - } - } - } - + // Cover notes, 1624, and label events, 1985, carry no `a` tag. + // Query them per root like comments and statuses. + // The events are only stored for interop and nothing displays them. sort_newest_first(&mut issues); sort_newest_first(&mut patches); sort_newest_first(&mut pull_requests); sort_oldest_first(&mut comments); - sort_newest_first(&mut cover_notes); - sort_newest_first(&mut labels); - // Resolve every root's status once here; render paths do - // HashMap lookups instead of scanning all status events per - // root (quadratic, with an allocation per pair). + // Resolve every root's status once here. + // Render paths do HashMap lookups instead of per-root status scans. + // Those scans are quadratic, with an allocation per pair. let maintainers = announcement .as_ref() .map(Announcement::effective_maintainers) @@ -405,8 +364,6 @@ impl RepoStore { open_issue_count, open_pr_count, comments, - cover_notes, - labels, )) }); @@ -423,13 +380,11 @@ impl RepoStore { open_issue_count, open_pr_count, comments, - cover_notes, - labels, ) = match work.await { Ok(data) => data, Err(e) => { return this.update(cx, |this, cx| { - this.refreshing = false; + this.refresh.abort(); this.last_error = Some(e.to_string()); cx.notify(); }); @@ -439,8 +394,8 @@ impl RepoStore { let again = this.update(cx, |this, cx| { this.announcement = announcement; - // The announcement may list relays for this repository's activity, - // connect to any we haven't fetched from yet. + // The announcement may list relays for this repository's activity. + // Connect to any we have not fetched from yet. let relays = this .announcement .as_ref() @@ -448,8 +403,7 @@ impl RepoStore { .unwrap_or_default(); this.connect_announced_relays(&relays, cx); - if let Some((refs, head)) = state { - this.refs = refs; + if let Some((_, head)) = state { this.head = head; } @@ -460,14 +414,12 @@ impl RepoStore { this.status_by_root = status_by_root; this.open_issue_count = open_issue_count; this.open_pr_count = open_pr_count; - this.cover_notes = cover_notes; - this.labels = labels; this.version = this.version.wrapping_add(1); - // Comments, statuses without an `a` tag, cover notes and - // labels are not addressed to the repository, so fetch them - // by the root events they reference, on the bootstrap relays - // and on the relays this repository announced. + // Comments and statuses without an `a` tag. + // None are addressed to the repository. + // Fetch them by the root events they reference. + // Use the bootstrap relays and the relays this repository announced. let roots = this .issues .iter() @@ -484,13 +436,10 @@ impl RepoStore { if !new_roots.is_empty() { this.root_fetches.extend(new_roots.iter().copied()); - // Batch the per-root filters: one statuses filter and one - // annotations filter covering all new roots, instead of - // one filter per root (each filter is a separate - // negentropy reconciliation per relay). + // Batch the per-root filters. + // One filter per root costs a negentropy reconciliation per relay. let mut root_filters = filters::comments_for(new_roots.clone()); root_filters.push(filters::statuses_for(new_roots.iter().copied())); - root_filters.push(filters::annotations_for(new_roots)); let announced: Vec = this.repo_relays.iter().cloned().collect(); let backend = Backend::global(cx); @@ -502,16 +451,11 @@ impl RepoStore { cx.notify(); - this.refreshing = false; - if this.refresh_dirty { - this.refresh_dirty = false; - true - } else { - false - } + this.refresh.finish() })?; - // Requests that arrived while the refresh was running are coalesced into one follow-up refresh. + // Requests that arrived while the refresh was running. + // They are coalesced into one follow-up refresh. if again { this.update(cx, |this, cx| this.refresh(cx))?; } @@ -520,7 +464,7 @@ impl RepoStore { })); } - /// Resolve the status of a root event (issue / patch / PR) per NIP-34 + /// Resolve the status of a root event, an issue, patch or PR, per NIP-34. pub fn status_of(&self, root: &Event) -> RepoStatus { status_of(&self.status_by_root, root) } @@ -531,59 +475,26 @@ impl RepoStore { self.version } - /// The effective cover note of `root` (kind 1624), if any: - /// the latest note authored by the root author or a maintainer. - pub fn cover_note_of(&self, root: &Event) -> Option<&Event> { - let maintainers = self - .announcement - .as_ref() - .map(Announcement::effective_maintainers) - .unwrap_or_default(); - - cover_note(root, &self.cover_notes, &maintainers) - } - - /// The effective hashtag labels of `root`: its own `t` tags plus labels - /// from authorized NIP-32 kind-1985 events (`#t` namespace). - pub fn labels_of(&self, root: &Event) -> Vec { - let maintainers = self - .announcement - .as_ref() - .map(Announcement::effective_maintainers) - .unwrap_or_default(); - - let (labels, _) = labels_and_subject(root, &self.labels, &maintainers); - labels - } - - /// The effective subject/title override of `root` from authorized - /// kind-1985 events (`#subject` namespace), if any. - pub fn subject_of(&self, root: &Event) -> Option { - let maintainers = self - .announcement - .as_ref() - .map(Announcement::effective_maintainers) - .unwrap_or_default(); - - subject_override(root, &self.labels, &maintainers) - } - - /// Number of open issues: issues whose resolved status is - /// [`RepoStatus::Open`] (issues without status events default to open). + /// Number of open issues. + /// + /// Issues whose resolved status is [`RepoStatus::Open`]. + /// Issues without status events default to open. pub fn issue_count(&self) -> usize { self.open_issue_count } - /// Number of open pull requests: root PR events (not PR updates, whose - /// status is carried by the root) with a resolved status of - /// [`RepoStatus::Open`]. + /// Number of open pull requests. + /// + /// Only root PR events count, PR updates do not. + /// They must resolve to [`RepoStatus::Open`]. pub fn pull_request_count(&self) -> usize { self.open_pr_count } - /// Whether `user` is the author (owner) of this repository: the public - /// key of the repository address. Only the author may manage the - /// repository's pull requests (close / reopen / merge). + /// Whether `user` is the author or owner of this repository. + /// + /// The author is the public key of the repository address. + /// Only the author may manage pull requests, close, reopen or merge. pub fn is_author(&self, user: &PublicKey) -> bool { &self.addr.public_key == user } @@ -601,19 +512,20 @@ impl RepoStore { self.send(builder, cx); } - /// Comments on a root event (issue / PR), oldest first. + /// Comments on a root event, an issue or PR, oldest first. pub fn comments_of(&self, root: &EventId) -> impl Iterator { self.comments .iter() .filter(move |e| signed_core::references_root(e, root)) } - /// Comment on a root event (issue / PR) per NIP-34 (kind 1111) + /// Comment on a root event, an issue or PR, per NIP-34, kind 1111. pub fn comment(&mut self, root: &Event, content: String, cx: &mut Context) { self.reply(root, None, content, cx); } - /// Reply to `parent` (a comment on `root`) with a NIP-22 threaded comment, + /// Reply to `parent`, a comment on `root`, with a NIP-22 threaded comment. + /// /// `None` publishes a top-level comment on the root itself. pub fn reply( &mut self, @@ -634,27 +546,7 @@ impl RepoStore { ); } - /// Open a pull request on this repository: a root PR event (kind 1618) - /// whose content is the markdown description, plus a root patch event - /// (kind 1617) carrying the `git format-patch` output, which the PR - /// references via an `e` tag (NIP-34). - /// - /// The patch series is published first (one kind-1617 event per commit, - /// chained with NIP-10 `e` replies, each under [`MAX_PATCH_EVENT_BYTES`]) - /// so the PR can reference the root patch's id. The proposed commit is - /// parsed from the series' last `From ` header (the tip); without - /// one publishing is refused, because the PR's `c` tag must carry a real - /// commit id for other NIP-34 clients to verify and apply the proposal. - /// The `clone` tag carries the announced mirror URLs, and when - /// `push_from` is set the tip is pushed to those servers under - /// `refs/nostr/` (best-effort) before the PR is published, so - /// the commit is actually downloadable there; the linked patch stays the - /// source of truth either way. - /// - /// `branch_name` lands in the PR's `branch-name` tag (NIP-34); `draft` - /// publishes a kind-1633 status right after the PR event. `merge_base` - /// is the hex commit the proposed branch forked from, computed from a - /// local checkout when the patch was generated there. + /// Open a pull request on this repository. #[allow(clippy::too_many_arguments)] pub fn open_pull_request( &mut self, @@ -688,7 +580,8 @@ impl RepoStore { return; } - // The tip of the series is its last commit; `git format-patch` orders patches oldest first. + // The tip of the series is its last commit. + // `git format-patch` orders patches oldest first. let Some(current_commit) = series .last() .and_then(|part| patch_current_commit(part)) @@ -704,28 +597,45 @@ impl RepoStore { let backend = Backend::global(cx); let signer = backend.read(cx).signer(); - if backend.read(cx).current_user().is_none() { + let Some(user) = backend.read(cx).current_user() else { self.last_error = Some("Sign in to open a pull request".into()); cx.notify(); return; - } + }; + // The author's npub names their GRASP-06 namespace, `/prs/...`. + let author_npub = user.to_bech32().unwrap(); let addr = self.addr.clone(); let owner = self.addr.public_key; let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); - - let (push_owner, push_repo_id, push_relays) = self + let repo_id = addr.identifier.clone(); + let base_npub = owner.to_bech32().unwrap(); + let push_relays = self .announcement .as_ref() - .map(|a| { - let owner = a.owner.to_bech32().unwrap_or_else(|_| a.owner.to_hex()); - (owner, a.id.clone(), a.relays.clone()) - }) + .map(|a| a.relays.clone()) .unwrap_or_default(); + // GRASP-06 hosting falls back to the settings defaults. + // That happens when the author has no published grasp list. + let defaults: Vec = { + let settings = settings::SettingsStore::global(cx).read(cx).settings(); + let urls: Vec = if settings.grasp_servers.default_servers.is_empty() { + settings::DEFAULT_GRASP_SERVERS + .iter() + .map(|url| (*url).to_owned()) + .collect() + } else { + settings.grasp_servers.default_servers.clone() + }; + urls.iter() + .filter_map(|url| RelayUrl::parse(url).ok()) + .collect() + }; + self.tasks.push(cx.spawn(async move |this, cx| { - // The PR references the root patch event so viewers can find - // the patch without carrying it inline. + // The PR references the root patch event. + // Viewers can then find the patch without carrying it inline. let root_patch = match publish_patch_series( &this, cx, @@ -747,20 +657,77 @@ impl RepoStore { } }; + // GRASP-06 pushes the tip to the author's own grasp servers. + // The path is `/prs//.git`. + // Contributing to another project never depends on that project's servers. + // Resolve the servers from the author's latest kind-10317 grasp list. + // The settings defaults stand in when no list is published or the query fails. + let author_servers = { + let query = this.update(cx, |_this, cx| { + let client = Backend::global(cx).read(cx).client(); + user_grasp_list_servers(client, user) + })?; + match cx.background_spawn(query).await { + Ok(published) if !published.is_empty() => published, + _ => defaults, + } + }; + + let author_targets: Vec<(String, String)> = { + let mut targets = Vec::new(); + for server in &author_servers { + let Some(base) = grasp_base_url(server) else { + continue; + }; + let url = grasp06_prs_url(&base, &author_npub, &repo_id); + if !targets.iter().any(|(existing, _)| existing == &url) { + targets.push((url, server.to_string())); + } + } + targets + }; + let base_targets: Vec<(String, String)> = { + let mut targets = Vec::new(); + for relay in &push_relays { + let Some(base) = grasp_base_url(relay) else { + continue; + }; + let url = format!("{base}/{base_npub}/{repo_id}.git"); + if !targets.iter().any(|(existing, _)| existing == &url) { + targets.push((url, relay.to_string())); + } + } + targets + }; + let builder = this.update(cx, |this, _cx| { + // NIP-34 PRs carry at least one clone URL. + // The tip commit is downloadable from it. + // The author's `/prs/` URLs come first. + // They are author-controlled and most likely alive. + // The announced mirrors follow. + // The list is fixed before signing. + // The pushed ref name embeds the event id. + // Every candidate URL is listed up front. + // Dead URLs are inert, the linked patch stays the source of truth. + let prs_urls: Vec = author_targets + .iter() + .filter_map(|(url, _)| Url::parse(url).ok()) + .collect(); + let base_clone = this + .announcement + .as_ref() + .map(|a| a.clone.clone()) + .unwrap_or_default(); + let clone = pr_clone_urls(prs_urls, base_clone); + let builder = GitPullRequest { repository: this.addr.clone(), content: description, subject, labels: Vec::new(), branch_name, - // NIP-34: PRs carry at least one clone URL where the tip commit can be downloaded, - // the announced mirrors are also the servers the tip is pushed to below. - clone: this - .announcement - .as_ref() - .map(|a| a.clone.clone()) - .unwrap_or_default(), + clone, current_commit, root_patch_event: Some(root_patch.id), merge_base: merge_base @@ -768,16 +735,16 @@ impl RepoStore { } .into_event_builder(); - // NIP-34: the `r` EUC tag lets clients subscribe to all PRs of this repository + // The `r` EUC tag lets clients subscribe to all PRs of the repository. match this.announcement.as_ref().and_then(|a| a.euc.clone()) { Some(euc) => builder.tag(Tag::parse(["r", &euc]).expect("valid r tag")), None => builder, } })?; - // Sign before publishing so the tip can be pushed to the grasp - // servers under `refs/nostr/` (nak's convention): - // readers fetch that ref to get the commit behind the `c` tag. + // Sign before publishing. + // The tip is pushed to the grasp servers under `refs/nostr/`. + // Nak's convention, readers fetch that ref for the commit behind the `c` tag. let event = cx .background_spawn({ let signer = signer.clone(); @@ -793,20 +760,21 @@ impl RepoStore { let path = path.clone(); let tip = tip.clone(); let reference = reference.clone(); - let owner = push_owner.clone(); - let repo_id = push_repo_id.clone(); - let relays = push_relays.clone(); + // Author servers first, then the announced base grasp servers. + // The extra targets are best-effort redundancy. + let targets: Vec<(String, String)> = author_targets + .into_iter() + .chain(base_targets) + .collect(); async move { let mut failures = Vec::new(); let mut pushed = 0; - for relay in &relays { - let Some(base) = grasp_base_url(relay) else { - continue; - }; - let url = format!("{base}/{owner}/{repo_id}.git"); - match signed_git::push_commit_ref(&path, &url, &tip, &reference) { + for (url, label) in &targets { + match signed_git::push_commit_ref( + &path, url, &tip, &reference, + ) { Ok(()) => pushed += 1, - Err(e) => failures.push(format!("{relay}: {e}")), + Err(e) => failures.push(format!("{label}: {e}")), } } (pushed, failures) @@ -840,8 +808,8 @@ impl RepoStore { } }; - // NIP-34: a draft PR carries a kind-1633 status event, - // publish it right after the PR event so viewers never show it open. + // A draft PR carries a kind-1633 status event, NIP-34. + // Publish it right after the PR event so viewers never show it open. if draft { this.update(cx, |this, cx| { this.set_status(&pr_event, RepoStatus::Draft, cx); @@ -852,11 +820,9 @@ impl RepoStore { })); } - /// Update a pull request: publish revision patch events chained to the - /// original root patch (`t root-revision` and a NIP-10 `e` reply on the - /// first, per NIP-34), then a kind-1619 PR update event carrying the new tip. + /// Update a pull request. /// - /// Only the PR author may update it; other authors must open a new PR. + /// Other authors must open a new PR. pub fn update_pull_request(&mut self, root: &Event, patch: String, cx: &mut Context) { self.last_error = None; self.last_warning = None; @@ -905,9 +871,8 @@ impl RepoStore { return; }; - // NIP-34: the first patch of a revision replies to the original - // root patch (the PR's `e` tag; fall back to the oldest patch of - // the linked set for PRs without one). + // The first revision patch replies to the original root patch, NIP-34. + // Use the PR's `e` tag, or the oldest patch of the linked set if the PR has none. let root_patch_id = root.tags.event_ids().next().or_else(|| { pull_request_patches(root, self.patches.iter()) .first() @@ -954,8 +919,8 @@ impl RepoStore { } .into_event_builder(); - // NIP-34: the `r` EUC tag lets clients subscribe to all PR - // updates of this repository; the SDK builder omits it. + // The `r` EUC tag lets clients subscribe to all PR updates. + // The SDK builder omits it. let builder = match euc.as_deref() { Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")), None => builder, @@ -976,9 +941,9 @@ impl RepoStore { })); } - /// Set the status of a root event. Per NIP-34 only the root author or a - /// repository maintainer may set the status; status events from anyone - /// else are ignored by clients, so refuse them up front. + /// Set the status of a root event. + /// + /// Only the root author or a maintainer may set it, per NIP-34. pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context) { self.last_error = None; @@ -1015,69 +980,7 @@ impl RepoStore { self.send(builder, cx); } - /// Publish a repository state announcement (kind 30618) with the refs of - /// the local clone: branches, tags and HEAD. Only the repository owner - /// may publish state, and a local clone must exist to read the refs from. - pub fn publish_state(&mut self, cx: &mut Context) { - self.last_error = None; - - let backend = Backend::global(cx); - let Some(user) = backend.read(cx).current_user() else { - self.last_error = Some("Sign in to publish repository state".into()); - cx.notify(); - return; - }; - if !self.is_author(&user) { - self.last_error = Some("Only the repository owner can publish state".into()); - cx.notify(); - return; - } - - let cache = GitStore::global(cx).cache().clone(); - let addr = self.addr.clone(); - let clone_urls: Vec = self - .announcement - .as_ref() - .map(|a| a.clone.iter().map(ToString::to_string).collect()) - .unwrap_or_default(); - - let work = cx.background_spawn(async move { - let repo = cache.ensure_clone(&addr, &clone_urls)?; - signed_git::repo_ref_state(&repo) - }); - - self.tasks.push(cx.spawn(async move |this, cx| { - let state = match work.await { - Ok(state) => state, - Err(e) => { - return this.update(cx, |this, cx| { - this.last_error = Some(e.to_string()); - cx.notify(); - }); - } - }; - - this.update(cx, |this, cx| { - let builder = - build_state(&this.addr.identifier, &state.refs, state.head.as_deref()); - this.send(builder, cx); - })?; - - Ok(()) - })); - } - - /// Merge a pull request: apply its patch (the content of the linked - /// root patch event) to the local clone of this repository, then publish - /// a kind-1631 (Applied) status event with merge provenance: the commits - /// `git am` created (`applied-as-commits` + `r` tags) and the applied - /// patch events (`q` tags, plus `e` reply tags for every patch beyond - /// the root, per NIP-34). - /// - /// Only the repository author may merge. The clone is created on demand - /// from the announcement's clone URLs when needed. Patch application - /// (`git am`) runs on a background thread; failures (e.g. a patch that - /// no longer applies) surface in [`Self::last_error`]. + /// Merge a pull request. pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context) { self.last_error = None; self.last_warning = None; @@ -1093,23 +996,28 @@ impl RepoStore { let cache = GitStore::global(cx).cache().clone(); let addr = self.addr.clone(); + let clone_urls: Vec = self .announcement .as_ref() .map(|a| a.clone.iter().map(ToString::to_string).collect()) .unwrap_or_default(); + let patch = pull_request_patch(root, self.patches.iter()); + // The applied patch events, for the status tags below. let patches: Vec = pull_request_patches(root, self.patches.iter()) .into_iter() .cloned() .collect(); + let relay_hint = self .announcement .as_ref() .and_then(|a| a.relays.first()) .map(ToString::to_string) .unwrap_or_default(); + let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); let root = root.clone(); @@ -1119,8 +1027,8 @@ impl RepoStore { .workdir() .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? .to_path_buf(); - // The commits created by the apply: everything between the - // previous HEAD and the new one, oldest first. + // The commits the apply created. + // Everything between the previous HEAD and the new one, oldest first. let previous = signed_git::head_commit_id(&workdir)?; signed_git::apply_patch(&workdir, &patch)?; let applied = signed_git::commits_since(&workdir, previous.as_deref())?; @@ -1152,10 +1060,201 @@ impl RepoStore { })); } - /// Publish a kind-1631 (Applied) status event for `root` after a merge: - /// `applied-as-commits` + `r` tags for the commits `git am` created, - /// `q` tags for the applied patch events, and `e` reply tags for every - /// patch of the series beyond the root (NIP-34). + /// The latest announcement of this repository, + /// for operations that need its clone URLs and relays. + fn action_announcement(&self, cx: &App) -> Option { + self.announcement.clone().or_else(|| { + RepoListStore::global(cx) + .read(cx) + .announcements + .iter() + .find(|announcement| announcement.addr() == self.addr) + .cloned() + }) + } + + /// Re-push the repository's refs to its announced grasp servers, republish. + pub fn push_repository(&mut self, cx: &mut Context) -> Task> { + if self.pushing { + return Task::ready(Err(anyhow::anyhow!( + "A push to this repository is already in progress" + ))); + } + let Some(announcement) = self.action_announcement(cx) else { + return self.action_error("Repository announcement is not loaded yet", cx); + }; + + self.pushing = true; + self.last_error = None; + cx.notify(); + + let backend = Backend::global(cx); + let push = backend.update(cx, |backend, cx| backend.push_repository(announcement, cx)); + + cx.spawn(async move |this, cx| { + let result = push.await; + + this.update(cx, |this, cx| { + this.pushing = false; + + if let Err(e) = &result { + this.last_error = Some(format!("Push failed: {e}")); + } + + cx.notify(); + })?; + + result + }) + } + + /// Push the unpushed commits of the local checkout at `path`. + pub fn push_checkout( + &mut self, + path: PathBuf, + cx: &mut Context, + ) -> Task> { + if self.pushing { + return Task::ready(Err(anyhow::anyhow!( + "A push to this repository is already in progress" + ))); + } + + let Some(announcement) = self.action_announcement(cx) else { + return self.action_error("Repository announcement is not loaded yet", cx); + }; + + // The state event's `HEAD` stays the announced default branch. + // The checkout may be on a side branch. + let head = self.head.clone(); + let addr = self.addr.clone(); + + self.pushing = true; + self.last_error = None; + cx.notify(); + + let checkouts = CheckoutsStore::global(cx); + let backend = Backend::global(cx); + let push = backend.update(cx, |backend, cx| { + backend.push_checkout(announcement, path.clone(), head, cx) + }); + + cx.spawn(async move |this, cx| { + let result = push.await; + + this.update(cx, |this, cx| { + this.pushing = false; + + match &result { + Ok(()) => { + // The remote moved, so recompute the ready-to-push statuses. + checkouts.update(cx, |store, cx| { + store.checkout_pushed(&addr, &path, cx); + }); + } + Err(e) => { + this.last_error = Some(format!("Push failed: {e}")); + } + } + + cx.notify(); + })?; + + result + }) + } + + /// Delete the repository from nostr, announcement, state and activity. + /// + /// Only the repository owner may delete it. The lists update when the + /// deletion events arrive. + pub fn delete_repository(&mut self, cx: &mut Context) -> Task> { + let addr = self.addr.clone(); + self.last_error = None; + + let backend = Backend::global(cx); + let delete = backend.update(cx, |backend, cx| backend.delete_repository(addr, cx)); + + cx.spawn(async move |this, cx| { + let result = delete.await; + this.update(cx, |this, cx| { + if let Err(e) = &result { + this.last_error = Some(format!("Delete failed: {e}")); + } + cx.notify(); + })?; + result + }) + } + + /// Clone the repository into `destination`, a user-chosen folder outside + /// the cache, and remember the clone as a checkout of this repository. + pub fn clone_to_folder( + &mut self, + destination: PathBuf, + cx: &mut Context, + ) -> Task> { + if self.cloning { + return Task::ready(Err(anyhow::anyhow!( + "A clone of this repository is already in progress" + ))); + } + let Some(announcement) = self.action_announcement(cx) else { + return self.action_error("Repository announcement is not loaded yet", cx); + }; + + let clone_urls: Vec = announcement.clone.iter().map(ToString::to_string).collect(); + let addr = self.addr.clone(); + + self.cloning = true; + self.last_error = None; + cx.notify(); + + let clone = { + let destination = destination.clone(); + cx.background_spawn(async move { signed_git::clone_repo(&clone_urls, &destination) }) + }; + + cx.spawn(async move |this, cx| { + let result = clone.await; + + this.update(cx, |this, cx| { + this.cloning = false; + + match &result { + Ok(()) => { + // Remember the clone as a checkout of this repository. + let checkouts = CheckoutsStore::global(cx); + checkouts.update(cx, |store, cx| { + store.record(destination.clone(), addr.clone(), cx); + }); + } + Err(e) => { + this.last_error = Some(format!("Failed to clone: {e}")); + } + } + + cx.notify(); + })?; + + result + }) + } + + /// Fail an operation whose announcement is not loaded yet. + fn action_error( + &mut self, + message: impl Into, + cx: &mut Context, + ) -> Task> { + let message = message.into(); + self.last_error = Some(message.clone()); + cx.notify(); + + Task::ready(Err(anyhow::anyhow!("{message}"))) + } + + /// Publish a kind-1631 Applied status event for `root` after a merge. fn publish_applied_status( &mut self, root: &Event, @@ -1171,14 +1270,16 @@ impl RepoStore { Tag::public_key(root.pubkey), Tag::coordinate(self.addr.clone(), None), ]; + if let Some(euc) = euc && let Ok(tag) = Tag::parse(["r", euc]) { tags.push(tag); } - // The applied patch events: a `q` tag per event, plus an `e` reply - // for every event beyond the root (chain parts and revisions), so - // their statuses resolve to Applied too. + + // Tag each applied patch event. + // `q` per event, `e` reply for events beyond the root, chain parts and revisions. + // Their statuses then resolve to Applied too. for (ix, patch) in patches.iter().enumerate() { if let Ok(tag) = Tag::parse(["q", &patch.id.to_hex(), relay_hint, &patch.pubkey.to_hex()]) @@ -1191,6 +1292,7 @@ impl RepoStore { tags.push(tag); } } + // The commits `git am` created on top of the previous HEAD. if !applied.is_empty() { let mut applied_tag = vec!["applied-as-commits".to_string()]; @@ -1233,8 +1335,7 @@ where events.into_iter().max_by_key(|e| e.created_at) } -/// Status of `root` from the precomputed map; roots without status events -/// default to [`RepoStatus::Open`], like [`signed_core::resolve_status`]. +/// Status of `root` from the precomputed map. fn status_of(status_by_root: &HashMap, root: &Event) -> RepoStatus { status_by_root .get(&root.id) @@ -1242,10 +1343,7 @@ fn status_of(status_by_root: &HashMap, root: &Event) -> Rep .unwrap_or(RepoStatus::Open) } -/// Resolve the status of every root event in one pass: status events are -/// indexed by the root they reference (`e`/`E` tag), then each root -/// resolves against its own slice. O(roots + statuses) instead of the -/// O(roots × statuses) of resolving per root on demand. +/// Resolve every root event's status in one pass. fn resolve_statuses( issues: &[Event], patches: &[Event], @@ -1285,20 +1383,17 @@ fn sort_oldest_first(events: &mut [Event]) { events.sort_by_key(|e| e.created_at); } -/// The proposed commit of a `git format-patch` output: the `From ` -/// header on its first line. +/// The proposed commit of a `git format-patch` output. +/// It is the `From ` header on the first line. fn patch_current_commit(patch: &str) -> Option<&str> { let line = patch.lines().next()?; let hex = line.strip_prefix("From ")?; hex.split_whitespace().next().filter(|hex| hex.len() == 40) } -/// Publish a `git format-patch` series as chained kind-1617 events and -/// return the root event (the one a PR references). The first part carries -/// `first_marker` (`t root`, or `t root-revision` with an `e` reply to -/// `reply_to` for revisions); every later part replies to the previous one -/// (NIP-34). Every part gets the repository coordinate, the owner, its own -/// `commit`/`r` tags, and the repository EUC when known. +/// Publish a `git format-patch` series as chained kind-1617 events. +/// +/// Returns the root event, the one a PR references. #[allow(clippy::too_many_arguments)] async fn publish_patch_series( this: &WeakEntity, @@ -1365,10 +1460,7 @@ async fn publish_patch_series( root.ok_or_else(|| anyhow::anyhow!("patch series is empty")) } -/// Build a NIP-22 kind-1111 comment: uppercase `E`/`K`/`P` tags scope the -/// thread root, lowercase `e`/`k`/`p` the direct parent (or the root for a -/// top-level comment). An `a` tag with the repository coordinate (not part -/// of NIP-22) is added so Signed's own activity subscriptions also match. +/// Build a NIP-22 kind-1111 comment. fn comment_builder( root: &Event, parent: Option<&Event>, @@ -1384,6 +1476,7 @@ fn comment_builder( relay_hint.cloned().map(Cow::Owned), ) }; + let root_target = target(root); let parent_target = parent.map(target).unwrap_or_else(|| root_target.clone()); @@ -1435,15 +1528,15 @@ mod tests { assert!(kinds.contains(&expected), "missing {expected} tag"); } - // The uppercase `E` tag scopes the root: id, relay hint and author. + // The uppercase `E` tag scopes the root, with its id, relay hint and author. let e = event.tags.iter().find(|t| t.kind() == "E").expect("E tag"); let slice = e.as_slice(); assert_eq!(slice[1], root.id.to_hex()); assert_eq!(slice[2], relay.as_str()); assert_eq!(slice[3], root.pubkey.to_hex()); - // The lowercase `e` tag references the parent, which for a top-level - // comment is the root itself. + // The lowercase `e` tag references the parent. + // For a top-level comment the parent is the root itself. let e = event.tags.iter().find(|t| t.kind() == "e").expect("e tag"); assert_eq!(e.as_slice()[1], root.id.to_hex()); @@ -1466,8 +1559,8 @@ mod tests { .finalize(&keys) .expect("signed event"); - // The uppercase `E` tag still scopes the root event, while the - // lowercase `e` tag references the parent comment. + // The uppercase `E` tag still scopes the root event. + // The lowercase `e` tag references the parent comment. let root_ref = event.tags.iter().find(|t| t.kind() == "E").expect("E tag"); let parent_ref = event.tags.iter().find(|t| t.kind() == "e").expect("e tag"); assert_eq!(root_ref.as_slice()[1], root.id.to_hex()); diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs index 4b31623..58a4efd 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repo_list.rs @@ -8,9 +8,11 @@ use nostr_sdk::prelude::*; use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr}; use crate::backend::{Backend, BackendEvent}; +use crate::refresh::{RefreshGate, RefreshRequest}; -/// Delay between a refresh request and the actual re-query, so bursts of -/// events (e.g. sync progress ticks) collapse into one query. +/// Delay between a refresh request and the actual re-query. +/// +/// Bursts of events, e.g. sync progress ticks, collapse into one query. const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); /// How far back activity events count toward a repository's last activity. @@ -20,53 +22,45 @@ struct GlobalRepoListStore(Entity); impl Global for GlobalRepoListStore {} -/// Counts of NIP-34 activity events per repository, used to rank the -/// explore list by popularity. Each patch event is a pushed commit (or a -/// small series), the closest proxy for commit count in the event data. +/// NIP-34 activity event counts per repository, ranking the explore list by popularity. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct RepoActivityCounts { /// Root `30611` issue events addressed to the repository. pub issues: u32, - /// Root `3063` pull request events addressed to the repository - /// (updates to a PR are not new PRs and don't count). + /// Root `3063` pull request events addressed to the repository. + /// + /// PR updates are not new PRs and do not count. pub pull_requests: u32, /// `1617` patch events addressed to the repository. pub commits: u32, } impl RepoActivityCounts { - /// Total issues + pull requests + commits; the popularity ranking key. + /// Total issues, pull requests and commits, the popularity ranking key. pub fn score(self) -> u32 { self.issues + self.pull_requests + self.commits } } -/// Store listing repository announcements (global discovery or per-author). -/// -/// The all-repos store (`author: None`) is created at startup by -/// [`crate::init`] and installed as a global, so the explore panel renders -/// what's in the local database without waiting for relays. +/// Store listing the discovered repository announcements, newest first. pub struct RepoListStore { /// Shared so views can clone the list per frame without a deep copy. pub announcements: Arc>, - /// Latest known activity timestamp per repository - /// (announcements, state updates, patches, PRs, issues, statuses). + /// Latest known activity timestamp per repository. + /// Covers announcements, state updates, patches, PRs, issues and statuses. pub last_activity: Arc>, - /// Issues + pull requests + commits per repository, for the Popular - /// ranking of the explore list. + /// Issues, pull requests and commits per repository. + /// + /// Used for the Popular ranking of the explore list. pub counts: Arc>, - author: Option, - refreshing: bool, - refresh_dirty: bool, - /// A refresh is waiting out [`REFRESH_DEBOUNCE`]. - debouncing: bool, + /// Refresh coalescing, see [`RefreshGate`]. + refresh: RefreshGate, tasks: Vec>>, _subscription: Subscription, } impl RepoListStore { - /// Retrieve the global explore store (all announcements, created at - /// startup by [`crate::init`]). + /// Retrieve the global repository list store. pub fn global(cx: &App) -> Entity { cx.global::().0.clone() } @@ -75,35 +69,34 @@ impl RepoListStore { cx.set_global(GlobalRepoListStore(entity)); } - /// Create a store. If `author` is `None`, all announcements are listed. - pub fn new(author: Option, cx: &mut Context) -> Self { + /// Create the store listing all announcements. + pub fn new(cx: &mut Context) -> Self { let backend = Backend::global(cx); let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { let relevant = match event { BackendEvent::NostrUpdate(update) => { - // Deletions may target anything we list; always refresh. + // Deletions may target anything we list, always refresh. if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish { true } else if filters::ACTIVITY_KINDS.contains(&update.kind) { - // Activity (patches, issues, ...) is addressed to repos via - // `a` tags, so its author isn't the repo owner; always refresh. + // Activity events are addressed to repos via `a` tags. + // Their author is not the repo owner, always refresh. true } else { let is_announcement = update.kind == Kind::GitRepoAnnouncement; let is_repo_state = update.kind == Kind::RepoState; - let tracked = is_announcement || is_repo_state; - tracked && this.author.is_none_or(|a| a == update.author) + is_announcement || is_repo_state } } BackendEvent::Published(event) => { - let announcement = event.kind == Kind::GitRepoAnnouncement - && this.author.is_none_or(|a| a == event.pubkey); - // Locally published deletions (e.g. deleting a repo) - // are already in the local database; refresh so they - // take effect immediately, like relay deletions. + let announcement = event.kind == Kind::GitRepoAnnouncement; + + // Locally published deletions are already in the local database. + // Refresh so they take effect immediately, like relay deletions. let deletion = event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; + announcement || deletion } BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true, @@ -119,105 +112,90 @@ impl RepoListStore { announcements: Arc::new(Vec::new()), last_activity: Arc::new(HashMap::new()), counts: Arc::new(HashMap::new()), - author, - refreshing: false, - refresh_dirty: false, - debouncing: false, + refresh: RefreshGate::default(), _subscription: subscription, tasks: Vec::new(), }; store.subscribe_remote(cx); - // Query the local database right away; the list never waits for the - // relay syncs started above to finish. + // Query the local database right away. + // The list never waits for the relay syncs started above to finish. store.refresh_initial(cx); store } - /// Scope the list to an author (or clear the scope with `None`). - pub fn set_author(&mut self, author: Option, cx: &mut Context) { - self.author = author; - self.subscribe_remote(cx); - self.refresh(cx); + /// The announcements of `user`, newest first. + pub fn announcements_of(&self, user: &PublicKey) -> Vec { + self.announcements + .iter() + .filter(|a| a.owner == *user) + .cloned() + .collect() + } + + /// Track a spawned task, pruning finished tasks first. + /// + /// Keeps the store's task list bounded by the number of in-flight tasks. + fn push_task(&mut self, task: Task>) { + self.tasks.retain(|task| !task.is_ready()); + self.tasks.push(task); } /// Negentropy-sync announcements with the bootstrap relays. fn subscribe_remote(&mut self, cx: &mut Context) { let backend = Backend::global(cx); - let author = self.author; backend.update(cx, |backend, cx| { - let filter = match author { - Some(a) => filters::announcements_by(a), - None => filters::all_announcements(), - }; - backend.sync_bootstrap(filter, cx); - // Deletion requests (NIP-09/62) must be known before any - // announcement can be shown. + backend.sync_bootstrap(filters::all_announcements(), cx); + // Deletion requests, NIP-09/62, must be known before any announcement is shown. backend.sync_bootstrap(filters::deletions(), cx); }); } - /// One-shot initial load: query the local database immediately (no - /// debounce), so stored announcements appear as soon as the app opens. - /// Only called from [`Self::new`], before any refresh can be pending. + /// One-shot initial load. + /// + /// Query the local database immediately, no debounce. + /// Stored announcements appear as soon as the app opens. fn refresh_initial(&mut self, cx: &mut Context) { - debug_assert!(!self.debouncing); - if self.refreshing { - self.refresh_dirty = true; + debug_assert!(!self.refresh.debouncing()); + if self.refresh.running() { + self.refresh.request(); return; } self.run_refresh(cx); } - /// Re-query the local database. Latest announcement per repository wins. - /// - /// Debounced: a short delay collapses bursts of requests (e.g. sync - /// progress ticks), and requests that arrive while a query is running - /// are folded into one follow-up query. The query and processing run on - /// a background thread; only the results are applied on the main thread. + /// Re-query the local database. pub fn refresh(&mut self, cx: &mut Context) { - if self.refreshing { - self.refresh_dirty = true; + if self.refresh.request() != RefreshRequest::Schedule { return; } - if self.debouncing { - return; - } - self.debouncing = true; let task = cx.spawn(async move |this, cx| { cx.background_executor().timer(REFRESH_DEBOUNCE).await; - this.update(cx, |this, cx| { - this.debouncing = false; - this.run_refresh(cx); - }) + this.update(cx, |this, cx| this.run_refresh(cx)) }); - self.tasks.push(task); + self.push_task(task); } - /// One query + apply cycle (debounced entry point). + /// One query and apply cycle, the debounced entry point. fn run_refresh(&mut self, cx: &mut Context) { - self.refreshing = true; + self.refresh.begin(); let backend = Backend::global(cx); let client = backend.read(cx).client(); - let author = self.author; let work = cx.background_spawn(async move { - let filter = match author { - Some(a) => filters::announcements_by(a), - None => filters::all_announcements(), - }; - + let filter = filters::all_announcements(); let events = client.database().query(filter).await?; + let deletion_events = client.database().query(filters::deletions()).await?; let deletions = Deletions::from_events(deletion_events); - // Dedup and sort off the main thread; only the final list - // crosses back into the entity. + // Dedup and sort off the main thread. + // Only the final list crosses back into the entity. let mut by_repo: HashMap = HashMap::new(); for event in events { @@ -242,8 +220,9 @@ impl RepoListStore { let mut announcements: Vec = by_repo.into_values().collect(); announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at)); - // Last activity per repository: state updates plus all NIP-34 - // activity events (patches, PRs, issues, statuses). + // Last activity per repository. + // State updates count, and all NIP-34 activity events. + // The activity events are patches, PRs, issues and statuses. let mut last_activity: HashMap = announcements .iter() .map(|a| (a.addr(), a.created_at)) @@ -264,8 +243,8 @@ impl RepoListStore { *entry = (*entry).max(event.created_at); } - // Bound the activity query to a recent window; older repos fall - // back to their announcement / state timestamps. + // Bound the activity query to a recent window. + // Older repos fall back to their announcement or state timestamps. let activity_filter = Filter::new() .kinds(filters::ACTIVITY_KINDS) .since(Timestamp::now() - ACTIVITY_WINDOW); @@ -277,8 +256,8 @@ impl RepoListStore { if addr.kind != Kind::GitRepoAnnouncement { continue; } - // Skip events for repos we don't list, so the map can't - // grow beyond the number of announcements. + // Skip events for repos we do not list. + // The map cannot grow beyond the number of announcements. let Some(entry) = last_activity.get_mut(&addr) else { continue; }; @@ -286,9 +265,8 @@ impl RepoListStore { } } - // Popularity counts per repository (issues, pull requests and - // patches). Unbounded, unlike the windowed activity query - // above, so totals are exact. + // Popularity counts per repository, issues, pull requests and patches. + // Unbounded, unlike the windowed activity query above, so totals are exact. let mut counts: HashMap = HashMap::new(); let count_filter = Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]); @@ -297,8 +275,8 @@ impl RepoListStore { continue; } for addr in event.tags.coordinates() { - // Skip events for repos we don't list, so the map can't - // grow beyond the number of announcements. + // Skip events for repos we do not list. + // The map cannot grow beyond the number of announcements. if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr) { continue; @@ -316,13 +294,13 @@ impl RepoListStore { Ok::<_, Error>((announcements, last_activity, counts)) }); - self.tasks.push(cx.spawn(async move |this, cx| { + self.push_task(cx.spawn(async move |this, cx| { let (announcements, last_activity, counts) = match work.await { Ok(results) => results, - // Database errors are transient; keep the last list. + // Database errors are transient, keep the last list. Err(_) => { return this.update(cx, |this, _cx| { - this.refreshing = false; + this.refresh.abort(); }); } }; @@ -333,17 +311,11 @@ impl RepoListStore { this.counts = Arc::new(counts); cx.notify(); - this.refreshing = false; - if this.refresh_dirty { - this.refresh_dirty = false; - true - } else { - false - } + this.refresh.finish() })?; - // Requests that arrived while the refresh was running are - // coalesced into one follow-up refresh. + // Requests that arrived while the refresh was running. + // They are coalesced into one follow-up refresh. if again { this.update(cx, |this, cx| this.refresh(cx))?; } diff --git a/crates/signed_ui/src/copy_row.rs b/crates/signed_ui/src/copy_row.rs index 8610c16..b94f30b 100644 --- a/crates/signed_ui/src/copy_row.rs +++ b/crates/signed_ui/src/copy_row.rs @@ -4,12 +4,14 @@ use gpui_component::clipboard::Clipboard; use gpui_component::menu::PopupMenuItem; use gpui_component::{ActiveTheme, StyledExt, h_flex}; -/// A muted command row with a copy button: the value in a mono-friendly, -/// truncated line, with a [`Clipboard`] button copying the full value. -pub fn copy_row(copy_id: E, command: &SharedString, cx: &App) -> Div +/// A muted command row with a copy button. +pub fn copy_row(copy_id: E, command: T, cx: &App) -> Div where E: Into, + T: Into, { + let command = command.into(); + h_flex() .h_8() .w_full() @@ -34,10 +36,7 @@ where ) } -/// One row of a copy menu: a small title above the compact label, with -/// a copy button that flips to a check while the value is on the clipboard. -/// Clicking the row copies and dismisses the menu; the copy button stops -/// propagation so the menu stays open. Both copy `copy`, never the label. +/// One row of a copy menu, with a small title above the compact label. pub fn menu_copy_row( id: &'static str, title: &'static str, diff --git a/crates/signed_ui/src/dropdown_button.rs b/crates/signed_ui/src/dropdown_button.rs index f83aa4d..ae7126a 100644 --- a/crates/signed_ui/src/dropdown_button.rs +++ b/crates/signed_ui/src/dropdown_button.rs @@ -7,11 +7,10 @@ use gpui_base::{Button as BaseButton, Popover, Selectable, StyledExt}; use gpui_component::menu::PopupMenu; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex}; -/// A split dropdown button built on `gpui_base::Popover`: an action element -/// with a separate caret trigger that opens a [`PopupMenu`]. -/// -/// The action and the caret are caller-supplied elements, so the look stays -/// in the application; this component only owns the popover wiring. +/// A split dropdown button built on `gpui_base::Popover`. +/// An action element with a separate caret trigger that opens a [`PopupMenu`]. +/// The action and the caret are caller-supplied elements, so the look stays in the app. +/// This component only owns the popover wiring. #[derive(IntoElement)] pub struct DropdownButton { id: ElementId, @@ -38,15 +37,16 @@ impl DropdownButton { } } - /// The action half of the button. It keeps its own icon, label, tooltip - /// and click handler. + /// The action half of the button. + /// It keeps its own icon, label, tooltip and click handler. pub fn action(mut self, action: impl IntoElement + 'static) -> Self { self.action = Some(action.into_any_element()); self } - /// The menu built by `builder` — the same signature as gpui-component's - /// `DropdownButton::dropdown_menu`, so existing menu code keeps working. + /// The menu built by `builder`. + /// Matches gpui-component's `DropdownButton::dropdown_menu` signature. + /// Existing menu code keeps working. pub fn dropdown_menu( mut self, builder: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, @@ -55,10 +55,9 @@ impl DropdownButton { self } - /// Which corner of the caret the menu anchors to. Defaults to - /// [`Anchor::TopRight`], so the menu's right edge lines up with the - /// caret's. - #[allow(dead_code)] // API knob; current call sites use the default anchor. + /// Which corner of the caret the menu anchors to. + /// Defaults to [`Anchor::TopRight`], lining the menu's right edge up with the caret's. + #[allow(dead_code)] // API knob, current call sites use the default anchor. pub fn anchor(mut self, anchor: impl Into) -> Self { self.anchor = anchor.into(); self @@ -71,8 +70,8 @@ impl Styled for DropdownButton { } } -/// Holds the [`PopupMenu`] entity of one popover between renders. Dismissal -/// drops it, so the menu is rebuilt with fresh items on the next open. +/// Holds the [`PopupMenu`] entity of one popover between renders. +/// Dismissal drops it, so the menu is rebuilt with fresh items on the next open. #[derive(Default)] struct DropdownMenuState { menu: Option>, @@ -85,7 +84,8 @@ impl RenderOnce for DropdownButton { "a DropdownButton needs a `dropdown_menu`" ); - // The popover needs its own id: both the container and the popover register keyed state on this window. + // The popover needs its own id. + // The container and the popover both register keyed state on this window. let popover_id = SharedString::from(format!("{}-popover", self.id)); let anchor = self.anchor; let menu_state = @@ -109,8 +109,8 @@ impl RenderOnce for DropdownButton { this.child( Popover::new(popover_id) .anchor(anchor) - // The menu dismisses itself on outside click or Escape; - // the subscription below closes the popover along with it. + // The menu dismisses itself on outside click or Escape. + // The subscription below closes the popover along with it. .overlay_closable(false) .trigger_with(caret) .content( @@ -148,8 +148,8 @@ impl RenderOnce for DropdownButton { } } -/// The default caret: a chevron button the height of a medium button, tinted -/// by the theme, with hover and menu-open states. +/// The default caret, a chevron button the height of a medium button. +/// It is tinted by the theme and styled for hover and menu-open states. fn default_caret(id: impl Into, cx: &App) -> BaseButton { BaseButton::new(id) .h(px(32.)) diff --git a/crates/signed_ui/src/image_cache.rs b/crates/signed_ui/src/image_cache.rs deleted file mode 100644 index 58d0ff2..0000000 --- a/crates/signed_ui/src/image_cache.rs +++ /dev/null @@ -1,139 +0,0 @@ -use std::collections::{HashMap, VecDeque}; -use std::mem::take; - -use futures::FutureExt; -use gpui::{ - App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache, - ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash, -}; - -/// Default number of images each view's cache retains. Loading a new image -/// evicts the least recently used entry once this is reached. -pub const MAX_IMAGES: usize = 128; - -pub fn image_cache(id: impl Into, max_items: usize) -> AppImageCacheProvider { - AppImageCacheProvider { - id: id.into(), - max_items, - } -} - -pub struct AppImageCacheProvider { - id: ElementId, - max_items: usize, -} - -impl ImageCacheProvider for AppImageCacheProvider { - fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache { - window - .with_global_id(self.id.clone(), |id, window| { - window.with_element_state(id, |cache, _| { - let cache = cache.unwrap_or_else(|| AppImageCache::new(self.max_items, cx)); - (cache.clone(), cache) - }) - }) - .into() - } -} - -pub struct AppImageCache { - max_items: usize, - usage_list: VecDeque, - cache: HashMap, -} - -impl AppImageCache { - pub fn new(max_items: usize, cx: &mut App) -> Entity { - cx.new(|cx| { - log::info!("Creating AppImageCacheProvider"); - cx.on_release(|this: &mut Self, cx| { - for (ix, (mut image, resource)) in take(&mut this.cache) { - if let Some(Ok(image)) = image.get() { - log::info!("Dropping image {ix}"); - cx.drop_image(image, None); - } - ImageSource::Resource(resource).remove_asset(cx); - } - }) - .detach(); - - AppImageCache { - max_items, - usage_list: VecDeque::with_capacity(max_items), - cache: HashMap::with_capacity(max_items), - } - }) - } -} - -impl ImageCache for AppImageCache { - fn load( - &mut self, - resource: &Resource, - window: &mut gpui::Window, - cx: &mut gpui::App, - ) -> Option, gpui::ImageCacheError>> { - let hash = hash(resource); - - if let Some(item) = self.cache.get_mut(&hash) { - let current_idx = self - .usage_list - .iter() - .position(|item| *item == hash) - .expect("cache has an item usage_list doesn't"); - - self.usage_list.remove(current_idx); - self.usage_list.push_front(hash); - - return item.0.get(); - } - - let load_future = AssetLogger::::load(resource.clone(), cx); - let task = cx.background_executor().spawn(load_future).shared(); - - if self.usage_list.len() >= self.max_items { - log::info!("Image cache is full, evicting oldest item"); - - if let Some(oldest) = self.usage_list.pop_back() { - let mut image = self - .cache - .remove(&oldest) - .expect("usage_list has an item cache doesn't"); - - if let Some(Ok(image)) = image.0.get() { - log::info!("requesting image to be dropped"); - cx.drop_image(image, Some(window)); - } - - ImageSource::Resource(image.1).remove_asset(cx); - } - } - - self.cache.insert( - hash, - ( - gpui::ImageCacheItem::Loading(task.clone()), - resource.clone(), - ), - ); - self.usage_list.push_front(hash); - - let entity = window.current_view(); - - window - .spawn(cx, async move |cx| { - let result = task.await; - - if let Err(err) = result { - log::error!("error loading image into cache: {:?}", err); - } - - cx.on_next_frame(move |_, cx| { - cx.notify(entity); - }); - }) - .detach(); - - None - } -} diff --git a/crates/signed_ui/src/lib.rs b/crates/signed_ui/src/lib.rs index 3af2bd6..7f63693 100644 --- a/crates/signed_ui/src/lib.rs +++ b/crates/signed_ui/src/lib.rs @@ -1,33 +1,3 @@ -//! Reusable UI components and elements for Signed. -//! -//! Everything here is presentation-only: the components read theme tokens -//! through [`gpui_component::ActiveTheme`] and render with plain GPUI -//! elements, so any view in the app can use them without depending on app -//! state. They are built on the unstyled `gpui-base` primitives and the -//! styled `gpui-component` library. -//! -//! The crate is organized by component: -//! -//! - [`PixelAvatar`] — deterministic, offline "pixel art" avatar -//! - [`NavItem`] — sidebar navigation row (leading element + label + suffix) -//! - [`DropdownButton`] — split button: an action plus a caret that opens a -//! [`PopupMenu`], wired through `gpui_base::Popover` -//! - [`SegmentButton`] / [`CountBadge`] — segmented filter/tab button with an -//! optional count badge -//! - [`UserAvatar`] — user picture avatar with a name-initials fallback -//! - [`status_badge`] — NIP-34 issue/PR status badge -//! - [`placeholder`] — centered muted placeholder message -//! - [`copy_row`] / [`menu_copy_row`] — rows with a copy-to-clipboard button -//! - [`tree_row`] — one row of a file tree -//! - [`setting_row`] / [`setting_block`] — label + description rows for a -//! settings dialog, with the control on the right (row) or below (block) -//! - [`SelectOption`] — dropdown option with a display label and a stored -//! value -//! - [`title_bar_drag_handlers`] — make an element behave like a window -//! title bar (drag moves the window, double-click zooms) -//! - [`image_cache`] — per-view LRU image cache provider -//! - [`middle_truncate`] — `[head]...[tail]` string truncation - mod dropdown_button; mod nav_item; mod pixel_avatar; @@ -40,12 +10,10 @@ mod tree_row; mod user_avatar; pub mod copy_row; -pub mod image_cache; pub mod util; pub use copy_row::{copy_row, menu_copy_row}; pub use dropdown_button::DropdownButton; -pub use image_cache::{MAX_IMAGES, image_cache}; pub use nav_item::NavItem; pub use pixel_avatar::PixelAvatar; pub use placeholder::placeholder; diff --git a/crates/signed_ui/src/nav_item.rs b/crates/signed_ui/src/nav_item.rs index 69195e6..f59a13f 100644 --- a/crates/signed_ui/src/nav_item.rs +++ b/crates/signed_ui/src/nav_item.rs @@ -2,9 +2,10 @@ use gpui::prelude::*; use gpui::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, div}; use gpui_component::{ActiveTheme, StyledExt, h_flex}; -/// A single navigation entry in a sidebar: an arbitrary leading element -/// (an icon, avatar, ...) and a text label with a hover highlight, -/// an optional trailing suffix (e.g. a status icon) and an optional click handler. +/// A single navigation entry in a sidebar. +/// It has an arbitrary leading element, such as an icon or avatar, and a text label. +/// Hover highlights the row. +/// It can carry a trailing suffix, such as a status icon, and an optional click handler. #[allow(clippy::type_complexity)] #[derive(IntoElement)] pub struct NavItem { @@ -12,7 +13,7 @@ pub struct NavItem { style: StyleRefinement, icon: gpui::AnyElement, label: SharedString, - /// Trailing element rendered at the right edge of the row, after the (ellipsized) label. + /// Trailing element at the right edge of the row, after the ellipsized label. suffix: Option, on_click: Option>, } diff --git a/crates/signed_ui/src/pixel_avatar.rs b/crates/signed_ui/src/pixel_avatar.rs index 601ba35..cb6ee19 100644 --- a/crates/signed_ui/src/pixel_avatar.rs +++ b/crates/signed_ui/src/pixel_avatar.rs @@ -9,27 +9,30 @@ const GRID_SIZE: usize = 8; 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). +/// Minimum number of filled left-half cells. +/// 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. +/// Side length of the avatar in pixels, no setter. +const AVATAR_SIZE: Pixels = px(16.); + +/// 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 struct PixelAvatar { seed: u64, - size: Pixels, style: StyleRefinement, } impl PixelAvatar { - /// Create an avatar seeded from `seed`. The seed should be a stable string - /// unique to the entity the avatar represents. + /// Create an avatar seeded from `seed`. + /// The seed should be a stable string unique to the entity the avatar represents. pub fn new(seed: impl AsRef) -> Self { Self { seed: fnv1a(seed.as_ref().as_bytes()), - size: px(16.), style: StyleRefinement::default(), } } @@ -77,7 +80,7 @@ impl RenderOnce for PixelAvatar { .grid() .grid_cols(GRID_SIZE as u16) .grid_rows(GRID_SIZE as u16) - .size(self.size) + .size(AVATAR_SIZE) .flex_shrink_0() .overflow_hidden() .bg(main.opacity(0.16)) @@ -85,8 +88,9 @@ impl RenderOnce for PixelAvatar { } } -/// 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. +/// Generate the 8×8 cell pattern for `seed`. +/// Cells are `0` for empty, `1` for main color and `2` for 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]; @@ -102,8 +106,8 @@ fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] { } } - // Sparse rolls can come out nearly empty; top the pattern up to the - // minimum fill, scanning from a seeded starting cell. + // 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; @@ -130,7 +134,7 @@ fn set_cell(pattern: &mut [u8; GRID_SIZE * GRID_SIZE], row: usize, col: usize, v pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)] = value; } -/// FNV-1a 64-bit hash; stable across platforms and runs. +/// 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 { diff --git a/crates/signed_ui/src/segment_button.rs b/crates/signed_ui/src/segment_button.rs index e13aa84..2160158 100644 --- a/crates/signed_ui/src/segment_button.rs +++ b/crates/signed_ui/src/segment_button.rs @@ -3,9 +3,9 @@ use gpui::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, di use gpui_base::{Button as BaseButton, StyledExt}; use gpui_component::ActiveTheme; -/// A small count badge shown after a label, e.g. on a segmented filter -/// button ("All 12") or a tab. Rendered from theme tokens; sized for the -/// compact header buttons it lives on. +/// A small count badge shown after a label. +/// Used on segmented filter buttons, like `All 12`, and on tabs. +/// Rendered from theme tokens and sized for the compact header buttons it lives on. #[derive(IntoElement)] pub struct CountBadge { count: usize, @@ -46,12 +46,11 @@ impl RenderOnce for CountBadge { } } -/// A segmented filter/tab button: an icon, a label, an optional [`CountBadge`] -/// and a selected (pressed) state, styled from the theme's button tokens. -/// -/// Built on the unstyled `gpui_base::Button`, like the app's other custom -/// controls; the `primary` variant uses the primary button tokens for -/// call-to-action buttons ("New issue", "New PR"). +/// A segmented filter/tab button with an icon, a label and an optional [`CountBadge`]. +/// The selected state renders the button pressed, styled from theme button tokens. +/// Built on the unstyled `gpui_base::Button`, like the app's other custom controls. +/// The `primary` variant uses the primary button tokens. +/// It suits call-to-action buttons such as `New issue` and `New PR`. #[allow(clippy::type_complexity)] #[derive(IntoElement)] pub struct SegmentButton { @@ -101,7 +100,7 @@ impl SegmentButton { self } - /// Use the primary button tokens (for call-to-action buttons). + /// Use the primary button tokens, for call-to-action buttons. pub fn primary(mut self) -> Self { self.primary = true; self diff --git a/crates/signed_ui/src/setting.rs b/crates/signed_ui/src/setting.rs index e24c727..94a539c 100644 --- a/crates/signed_ui/src/setting.rs +++ b/crates/signed_ui/src/setting.rs @@ -1,15 +1,11 @@ -//! Reusable settings UI: rows and blocks for building a settings dialog, plus -//! a labeled dropdown option. - use gpui::prelude::*; use gpui::{App, SharedString, div}; use gpui_component::searchable_list::SearchableListItem; use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex}; /// A dropdown option with a display label and a stored value. -/// -/// Renders the `label` in the trigger and the menu, while `value` is what a -/// [`gpui_component::select::SelectState`] reports as the selection. +/// The trigger and menu render the `label`. +/// The `value` is what [`gpui_component::select::SelectState`] reports as the selection. #[derive(Clone)] pub struct SelectOption { value: SharedString, @@ -48,7 +44,7 @@ impl SearchableListItem for SelectOption { } } -/// A settings row: label + description on the left, control on the right. +/// A settings row with the label and description on the left and the control on the right. pub fn setting_row( cx: &App, title: impl Into, @@ -85,8 +81,8 @@ pub fn setting_row( ) } -/// A full-width settings block: title + subtitle in one header, `gap_3` -/// between the header and the control below. +/// A full-width settings block with title and subtitle in one header. +/// `gap_3` separates the header from the control below. pub fn setting_block( cx: &App, title: impl Into, diff --git a/crates/signed_ui/src/status_badge.rs b/crates/signed_ui/src/status_badge.rs index 771b023..e458c0c 100644 --- a/crates/signed_ui/src/status_badge.rs +++ b/crates/signed_ui/src/status_badge.rs @@ -5,8 +5,8 @@ use gpui_component::tooltip::Tooltip; use gpui_component::{ActiveTheme, Icon, Sizable, v_flex}; use signed_core::RepoStatus; -/// The status badge shown next to an issue or pull request: icon + colored square, -/// with a tooltip describing the status. +/// The status badge shown next to an issue or pull request. +/// It has an icon and a colored square, with a tooltip describing the status. pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement { let (icon, label, tooltip, bg, fg) = match status { RepoStatus::Open => ( diff --git a/crates/signed_ui/src/title_bar.rs b/crates/signed_ui/src/title_bar.rs index b194596..e9302f1 100644 --- a/crates/signed_ui/src/title_bar.rs +++ b/crates/signed_ui/src/title_bar.rs @@ -8,12 +8,12 @@ struct WindowDragState { should_move: bool, } -/// Make an element behave like a window title bar: dragging it moves the -/// window, and double-clicking zooms the window (or performs the platform's -/// default title-bar double-click action on macOS). -/// -/// Only the bar's non-interactive areas should get this — tabs are draggable -/// (to reorder panels) and must not move the window. +/// Make an element behave like a window title bar. +/// Dragging it moves the window. +/// Double-clicking zooms the window. +/// On macOS it runs the platform's default title-bar double-click action. +/// Only the bar's non-interactive areas should get this. +/// Tabs are draggable to reorder panels and must not move the window. pub fn title_bar_drag_handlers( this: Stateful
, window: &mut Window, diff --git a/crates/signed_ui/src/tree_row.rs b/crates/signed_ui/src/tree_row.rs index d2598d8..8dd2657 100644 --- a/crates/signed_ui/src/tree_row.rs +++ b/crates/signed_ui/src/tree_row.rs @@ -4,8 +4,9 @@ use gpui_component::list::ListItem; use gpui_component::tree::TreeEntry; use gpui_component::{Icon, IconName, Sizable, h_flex}; -/// One row of a file tree: icon + name, indented by depth. -/// Clicking a file runs `on_click`; folders expand/collapse via the tree itself. +/// One row of a file tree, an icon and a name indented by depth. +/// Clicking a file runs `on_click`. +/// Folders expand and collapse via the tree itself. pub fn tree_row(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem where F: Fn(&mut Window, &mut App) + 'static, diff --git a/crates/signed_ui/src/user_avatar.rs b/crates/signed_ui/src/user_avatar.rs index ec51c24..6d986a9 100644 --- a/crates/signed_ui/src/user_avatar.rs +++ b/crates/signed_ui/src/user_avatar.rs @@ -3,8 +3,8 @@ use gpui::{App, SharedString, StyleRefinement, Window}; use gpui_component::avatar::Avatar; use gpui_component::{ActiveTheme, Sizable, StyledExt}; -/// A user avatar: the gpui-component [`Avatar`] sized small and rounded with -/// the theme radius, showing the user's picture or a name-initials fallback. +/// A small user avatar from gpui-component [`Avatar`], rounded with the theme radius. +/// It shows the user's picture or falls back to name initials. #[derive(IntoElement)] pub struct UserAvatar { name: SharedString, @@ -13,8 +13,8 @@ pub struct UserAvatar { } impl UserAvatar { - /// Create an avatar for `name`; the name seeds the initials fallback - /// shown when no picture is set. + /// Create an avatar for `name`. + /// The name seeds the initials fallback shown when no picture is set. pub fn new(name: impl Into) -> Self { Self { name: name.into(), diff --git a/crates/signed_ui/src/util.rs b/crates/signed_ui/src/util.rs index e5d33ef..3e4d0e5 100644 --- a/crates/signed_ui/src/util.rs +++ b/crates/signed_ui/src/util.rs @@ -1,5 +1,6 @@ -/// `[head chars]...[tail chars]` middle truncation; the value is left alone -/// when it is too short for the ellipsis to save space. +/// `[head chars]...[tail chars]` middle truncation. +/// +/// Values too short for the ellipsis to save space are left alone. pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String { let len = value.chars().count(); if len <= head + tail + 3 { @@ -32,7 +33,7 @@ mod tests { ), "30617:a008...3564d:ngit" ); - // Too short to save space with the ellipsis: left alone. + // Too short to save space with the ellipsis, left alone. assert_eq!(middle_truncate("short", 10, 10), "short"); } } diff --git a/crates/utils/src/time.rs b/crates/utils/src/time.rs index b48310c..c2c39b6 100644 --- a/crates/utils/src/time.rs +++ b/crates/utils/src/time.rs @@ -1,6 +1,6 @@ use nostr::prelude::*; -/// Format a timestamp as a short relative time (e.g. "3h ago"). +/// Format a timestamp as a short relative time, e.g. `3h ago`. pub fn relative_time(timestamp: Timestamp) -> String { let now = Timestamp::now().as_secs(); let secs = now.saturating_sub(timestamp.as_secs()); @@ -20,7 +20,7 @@ pub fn relative_time(timestamp: Timestamp) -> String { } } -/// Format a unix timestamp in seconds as a short relative time (e.g. "3h ago"). +/// Format a unix timestamp in seconds as a short relative time, e.g. `3h ago`. pub fn relative_time_secs(secs: i64) -> String { relative_time(Timestamp::from_secs(secs.max(0) as u64)) } diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index f5a2fc3..59d09f5 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -23,6 +23,5 @@ gix.workspace = true nostr.workspace = true anyhow.workspace = true -chrono.workspace = true futures.workspace = true log.workspace = true diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 59ed328..e475053 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -3,7 +3,6 @@ mod workspace; use gpui::{App, AppContext, Entity, Window}; use gpui_component::Root; -pub use signed_ui::image_cache; pub use views::{RepoListView, SidebarPanel}; pub use workspace::Workspace; diff --git a/crates/workspace/src/views/dialog_state.rs b/crates/workspace/src/views/dialog_state.rs new file mode 100644 index 0000000..b303490 --- /dev/null +++ b/crates/workspace/src/views/dialog_state.rs @@ -0,0 +1,36 @@ +use gpui::prelude::*; +use gpui::{AnyElement, App, SharedString, div}; +use gpui_component::ActiveTheme; + +/// Progress of an async dialog action: a busy flag disabling the form, +/// and an error line shown under it. +#[derive(Debug, Default)] +pub struct DialogProgress { + pub busy: bool, + pub error: Option, +} + +impl DialogProgress { + /// An action started, disable the form and clear the previous error. + pub fn begin(&mut self) { + self.busy = true; + self.error = None; + } + + /// An action failed, re-enable the form and surface `message`. + pub fn fail(&mut self, message: impl Into) { + self.busy = false; + self.error = Some(message.into()); + } +} + +/// The shared error line under a dialog form, `None` when there is no error. +pub fn error_row(error: &Option, cx: &App) -> Option { + error.as_ref().map(|message| { + div() + .text_sm() + .text_color(cx.theme().danger) + .child(message.clone()) + .into_any_element() + }) +} diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index 1ec23e7..830a84a 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -1,3 +1,4 @@ +mod dialog_state; mod repo_detail; mod repo_list; pub(crate) mod sidebar; diff --git a/crates/workspace/src/views/repo_detail/about.rs b/crates/workspace/src/views/repo_detail/about.rs index e0b9535..d9e7568 100644 --- a/crates/workspace/src/views/repo_detail/about.rs +++ b/crates/workspace/src/views/repo_detail/about.rs @@ -7,8 +7,7 @@ use signed_core::Announcement; use signed_state::ProfileStore; use signed_ui::{UserAvatar, middle_truncate}; -/// Open the "About" dialog: every field of the repository's announcement -/// event (NIP-34, kind 30617), as parsed into [`Announcement`]. +/// Open the About dialog showing every field of the announcement event. pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) { window.open_dialog(cx, move |dialog, _window, cx| { let announcement = announcement.clone(); @@ -22,8 +21,7 @@ pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, }); } -/// The announcement's fields as labeled rows; hex identifiers carry a copy -/// button, multi-value tags one line per value. +/// The announcement's fields as labeled rows. fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement { let mut rows: Vec = Vec::new(); @@ -32,8 +30,9 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement { text( announcement .name - .clone() - .unwrap_or_else(|| SharedString::from("—")), + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from("-")), ), cx, )); @@ -43,8 +42,9 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement { text( announcement .description - .clone() - .unwrap_or_else(|| SharedString::from("—")), + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from("-")), ), cx, )); @@ -116,7 +116,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement { v_flex().gap_3().w_full().children(rows).into_any_element() } -/// One info row: a small muted label above the value. +/// One info row with a small muted label above the value. fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement { v_flex() .gap_1() @@ -133,7 +133,12 @@ fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement { } /// Plain text value, wrapping within the dialog. -fn text(value: SharedString) -> AnyElement { +fn text(value: T) -> AnyElement +where + T: Into, +{ + let value = value.into(); + div() .text_sm() .w_full() @@ -159,8 +164,10 @@ fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement { .into_any_element() } -/// One row per maintainer: avatar and display name (falling back to a -/// shortened npub), with a copy button for the full pubkey. +/// One row per maintainer with avatar and display name. +/// The display name falls back to a shortened npub. +/// +/// A copy button copies the full pubkey. fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement { let profile_store = ProfileStore::global(cx); v_flex() @@ -190,8 +197,9 @@ fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement { .into_any_element() } -/// One row per item of a multi-value tag: the value is truncated to a single -/// line, with a copy button that copies the full value. +/// One row per item of a multi-value tag. +/// +/// The value is truncated to a single line, with a copy button for the full value. fn list(id: &'static str, items: impl IntoIterator, cx: &App) -> AnyElement { v_flex() .gap_2() diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs index eeda566..389aeae 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -16,8 +16,9 @@ use super::helpers::{code_language, is_markdown_path}; const TREE_WIDTH: f32 = 240.; /// Files larger than this are not previewed. pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024; -/// Preview cache caps: at most this many files (or this many text bytes) -/// are kept in memory at once; the oldest previews are evicted beyond that. +/// Preview cache caps, a file count and a text byte count. +/// +/// The oldest previews are evicted beyond the caps. pub(super) const MAX_PREVIEWED_FILES: usize = 32; pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024; @@ -34,19 +35,13 @@ pub(super) enum FileContent { } /// A markdown document loaded into a persistent [`TextViewState`]. -/// -/// The state is owned by the view rather than created per render: GPUI -/// drops keyed element state after one absent frame, which would re-parse -/// the whole document on every pane switch (README / file / spinner). pub(super) struct MarkdownView { - /// Source path; `None` means the repository README. + /// Source path, `None` means the repository README. pub(super) path: Option, pub(super) state: Entity, } -/// A code file loaded into a persistent [`InputState`], rendered as a -/// disabled (read-only) code editor with syntax highlighting, line numbers -/// and search. Persistent for the same reason as [`MarkdownView`]. +/// A code file loaded into a persistent [`InputState`]. pub(super) struct CodeView { /// Source path, relative to the worktree root. pub(super) path: SharedString, @@ -64,7 +59,7 @@ fn preview_spinner() -> AnyElement { } impl RepoDetailView { - /// One row of the file tree: icon + name, indented by depth. + /// One row of the file tree with icon and name, indented by depth. fn render_tree_item( ix: usize, entry: &TreeEntry, @@ -81,7 +76,7 @@ impl RepoDetailView { }) } - /// Left column: the file tree. + /// Left column showing the file tree. pub(super) fn render_tree_column( tree_state: Entity, view: WeakEntity, @@ -102,7 +97,7 @@ impl RepoDetailView { ))) } - /// Right column: README, selected file preview, or status text. + /// Right column, README, selected file preview or status text. pub(super) fn render_content_column( &self, pane_title: SharedString, @@ -148,7 +143,7 @@ impl RepoDetailView { self.code_element(path.as_ref(), cx) } } - Some(FileContent::Binary) => placeholder("Binary file — preview not supported", cx), + Some(FileContent::Binary) => placeholder("Binary file - preview not supported", cx), Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx), Some(FileContent::Failed(message)) => placeholder(message, cx), None => preview_spinner(), @@ -159,9 +154,8 @@ impl RepoDetailView { placeholder("No README found", cx) }; - // Latest commit for the current pane: the selected file, or the README - // while nothing is selected. Computed after the body above, which - // needs `&mut self`. + // Latest commit for the current pane, the selected file or the README. + // Computed after the body above, which needs `&mut self`. let commit = match &self.selected_file { Some(path) => self.commits.get(path.as_ref()), None => self @@ -217,9 +211,6 @@ impl RepoDetailView { } /// Load `text` into the persistent markdown TextView state. - /// - /// The state is created empty and fed via `push_str`, which parses on a - /// background task, so switching files never blocks the main thread. pub(super) fn set_markdown( &mut self, path: Option, @@ -231,16 +222,19 @@ impl RepoDetailView { self.md = Some(MarkdownView { path, state }); } - /// The persistent markdown TextView for `path` (`None` = README), or a - /// spinner while the document is being loaded/parsed. + /// The persistent markdown TextView for `path`, where `None` is the README. + /// + /// Shows a spinner while the document is being loaded or parsed. fn markdown_element(&self, path: Option<&str>, _cx: &mut Context) -> AnyElement { let Some(md) = &self.md else { return preview_spinner(); }; + let ready = match path { Some(path) => md.path.as_deref() == Some(path), None => md.path.is_none(), }; + if !ready { return preview_spinner(); } @@ -255,9 +249,7 @@ impl RepoDetailView { /// Load `text` into the persistent code editor state for `path`. /// - /// The state is created in code editor mode so the Input renders it as - /// a syntax-highlighted, read-only editor; the tree-sitter parse runs - /// on a background task like [`set_markdown`]'s. + /// Code editor mode makes the Input render it read-only and highlighted. pub(super) fn set_code( &mut self, path: SharedString, @@ -276,8 +268,7 @@ impl RepoDetailView { self.code = Some(CodeView { path, state }); } - /// The persistent code editor for `path`, or a spinner while the file is - /// being loaded/parsed. + /// The persistent code editor for `path`, or a spinner while the file loads or parses. fn code_element(&self, path: &str, _cx: &mut Context) -> AnyElement { let Some(code) = &self.code else { return preview_spinner(); diff --git a/crates/workspace/src/views/repo_detail/commits.rs b/crates/workspace/src/views/repo_detail/commits.rs index 14ce94c..49fb6ce 100644 --- a/crates/workspace/src/views/repo_detail/commits.rs +++ b/crates/workspace/src/views/repo_detail/commits.rs @@ -70,8 +70,6 @@ pub(super) fn commit_row( } impl RepoDetailView { - /// Full-height body of the Commits tab: all commits in a virtual - /// list, or a status message while loading / when there are none. pub(super) fn render_commits_tab(&self, cx: &mut Context) -> AnyElement { let Some(list) = self.all_commits.as_ref() else { return if self.loading_all_commits { @@ -90,9 +88,9 @@ impl RepoDetailView { return placeholder("No commits found", cx); } - // Copy only the values the element tree needs; the list itself is - // borrowed inside the renderer below instead of being cloned per - // frame (a full history can be tens of thousands of commits). + // Copy only the values the element tree needs. + // The list is borrowed by the renderer below instead of cloned per frame. + // A full history can be tens of thousands of commits. let view = cx.entity().clone(); let sizes = self.item_sizes.clone(); let scroll_handle = self.scroll_handle.clone(); @@ -137,7 +135,8 @@ impl RepoDetailView { .size_full(), ) .when(shown < total, |this| { - // The history is capped; tell the user the list is truncated. + // The history is capped. + // Tell the user the list is truncated. this.child( div() .py_2() diff --git a/crates/workspace/src/views/repo_detail/diff.rs b/crates/workspace/src/views/repo_detail/diff.rs index e724352..a03da9b 100644 --- a/crates/workspace/src/views/repo_detail/diff.rs +++ b/crates/workspace/src/views/repo_detail/diff.rs @@ -28,19 +28,15 @@ use super::helpers::{ /// Width of the changed-files column. const TREE_WIDTH: f32 = 260.; -/// The tree + per-file diff body shared by the commit diff panel and the -/// compare view of the new-pull-request panel. Owns the changed-files -/// explorer and the virtual list of the selected file's hunks; the host -/// feeds it a [`CommitDiff`] via [`DiffPane::set_diff`]. +/// Tree and per-file diff body, shared by the commit diff and compare views. pub struct DiffPane { - /// Loaded diff; `None` until [`Self::set_diff`] is called. + /// Loaded diff, `None` until [`Self::set_diff`] is called. diff: Option, /// Changed-files explorer state. tree_state: Entity, /// Path of the file whose diff is shown in the detail column. selected_file: Option, - /// Rows of the selected file's diff (hunk headers + lines), backing the - /// virtual list in the detail column. + /// Rows of the selected file's diff, hunk headers and lines. rows: Vec, /// Per-row heights of [`Self::rows`]. item_sizes: Rc>>, @@ -90,8 +86,9 @@ impl DiffPane { } } - /// Forget the diff (e.g. when the compared branches changed): clear the - /// tree, the selection and the diff rows. + /// Forget the diff, e.g. when the compared branches changed. + /// + /// Clears the tree, the selection and the diff rows. pub fn clear(&mut self, cx: &mut Context) { self.diff = None; self.selected_file = None; @@ -102,15 +99,14 @@ impl DiffPane { }); } - /// Show the diff of the file at `path` (selected in the tree). + /// Show the diff of the file at `path`, selected in the tree. fn select_file(&mut self, path: &str, cx: &mut Context) { self.selected_file = Some(path.into()); self.set_diff_rows(path); cx.notify(); } - /// Rebuild the virtual list state for the file at `path` and scroll back - /// to the top. + /// Rebuild the virtual list state for `path` and scroll back to the top. fn set_diff_rows(&mut self, path: &str) { let Some(diff) = self.diff.as_ref() else { return; @@ -123,7 +119,7 @@ impl DiffPane { self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); } - /// One row of the changed-files tree: icon + name, indented by depth. + /// One row of the changed-files tree, icon and name, indented by depth. fn render_tree_item( ix: usize, entry: &TreeEntry, @@ -140,7 +136,7 @@ impl DiffPane { }) } - /// Left column: the changed-files tree. + /// Left column showing the changed-files tree. fn render_tree_column(&self, cx: &mut Context) -> AnyElement { let tree_state = self.tree_state.clone(); let view = cx.entity().downgrade(); @@ -170,7 +166,7 @@ impl DiffPane { .into_any_element() } - /// Right column: header of the selected file plus its diff. + /// Right column, header of the selected file plus its diff. fn render_detail_column(&self, cx: &mut Context) -> AnyElement { let Some(diff) = self.diff.as_ref() else { return placeholder("No changes", cx); @@ -188,8 +184,7 @@ impl DiffPane { self.render_file_diff(file, cx.entity(), cx) } - /// The diff of one file: a header with status and stats, then the hunks - /// in a virtual list (a large diff is never materialized per frame). + /// The diff of one file, with a header showing status and stats. fn render_file_diff(&self, file: &FileDiff, view: Entity, cx: &App) -> AnyElement { let status_label = match file.status { DiffStatus::Added => "A", @@ -316,25 +311,21 @@ impl Render for DiffPane { } } -/// Detail panel showing the diff of one commit: a metadata header plus the -/// shared [`DiffPane`] body. +/// Detail panel showing the diff of one commit. pub struct CommitDiffView { focus_handle: FocusHandle, /// Local clone the commit lives in. worktree: PathBuf, /// Display name of the repository the commit belongs to. repo_name: SharedString, - /// The commit being shown (header and tab title). Starts as an id-only - /// stub; [`Self::load`] replaces it with the full metadata, which the - /// history list intentionally omits. + /// The commit being shown in the header and tab title. commit: FileCommit, /// The diff is being computed on a background task. loading: bool, error: Option, - /// Changed-files explorer and per-file diff, shared with the compare - /// view of the new-pull-request panel. + /// Changed-files explorer and per-file diff, also used by the new PR panel's compare view. pane: Entity, - /// In-flight tasks; pruned on every push (see [`helpers::track`]). + /// In-flight tasks, pruned on every push. tasks: Vec>>, } @@ -371,8 +362,7 @@ impl CommitDiffView { } } - /// Load the commit diff (and the full commit metadata) on a background - /// task and populate the tree. + /// Load the commit diff and the full commit metadata. fn load(&mut self, window: &mut Window, cx: &mut Context) { self.loading = true; self.error = None; @@ -419,7 +409,7 @@ impl CommitDiffView { self.tasks.push(task); } - /// Header: commit id, summary, author/time and overall change stats. + /// Header with the commit id, summary, author/time and overall change stats. fn render_header(&self, cx: &mut Context) -> AnyElement { let commit = &self.commit; let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| { diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs index 7c51eb3..fd7be3c 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -1,19 +1,25 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use assets::CustomIconName; use gpui::prelude::*; -use gpui::{AnyElement, App, SharedString, div, px}; +use gpui::{AnyElement, App, Entity, SharedString, div, px}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::combobox::{Caret, ComboboxTriggerContext}; +use gpui_component::input::{Textarea, TextareaState}; use gpui_component::menu::PopupMenu; +use gpui_component::searchable_list::SearchableVec; +use gpui_component::tag::Tag; use gpui_component::tree::TreeItem; -use gpui_component::{ActiveTheme, h_flex}; +use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex}; use nostr::nips::nip19::{Nip19Coordinate, ToBech32}; +use nostr::prelude::{Event, EventId, PublicKey}; use signed_core::Announcement; use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff}; -use signed_ui::{menu_copy_row, middle_truncate}; +use signed_state::{ProfileStore, RepoStore}; +use signed_ui::{UserAvatar, menu_copy_row, middle_truncate}; +use utils::relative_time; -/// A `Send` file-tree node: the tree is built on a background thread and -/// converted into [`TreeItem`]s (which hold `Rc` state, -/// so they cannot cross threads) on the main thread. pub(super) struct TreeItemSeed { /// Path of the node, relative to the worktree root. id: String, @@ -22,12 +28,6 @@ pub(super) struct TreeItemSeed { children: Vec, } -/// Convert tree seeds into [`TreeItem`]s, expanding every folder -/// when `expand_folders` is set. -/// -/// The commit diff explorer shows only changed files, -/// which is typically a handful of paths, so its folders start expanded; -/// the worktree explorer starts collapsed instead. pub(super) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec { fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem { let mut item = TreeItem::new(seed.id, seed.label); @@ -48,14 +48,9 @@ pub(super) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec< .collect() } -/// Build nested tree items from a flat, sorted (dirs-first) entry list. -/// -/// Returns [`TreeItemSeed`]s so the build can run off the main thread; a -/// worktree walk can yield tens of thousands of entries. Nodes live in an -/// arena and parents are found via a path -> index map, which keeps the -/// build linear in the number of path components. +/// Build nested tree items from a flat entry list sorted dirs-first. pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec { - // Node indices by full path, for O(1) parent lookup while inserting. + // Node indices by full path, so parents resolve in constant time while inserting. let mut index: HashMap = HashMap::new(); let mut nodes: Vec<(String, String, Vec)> = Vec::new(); let mut roots: Vec = Vec::new(); @@ -99,9 +94,6 @@ pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec { } /// The markdown fence language for a file path, or `None` for plain text. -/// -/// Names are chosen so `gpui_component`'s highlighter can resolve them -/// (`highlighter::Language::from_name` accepts short aliases such as `rs` and `js`). pub(super) fn code_language(path: &str) -> Option<&'static str> { let name = Path::new(path) .file_name() @@ -166,7 +158,7 @@ pub(super) fn is_markdown_path(path: &str) -> bool { } pub(super) struct ShareTargets { - /// NIP-19 `naddr1...` of the announcement (with its announced relays). + /// NIP-19 `naddr1...` of the announcement, with its announced relays. pub(super) naddr: String, /// Hex ID of the announcement event itself. pub(super) event_id: String, @@ -195,8 +187,9 @@ impl ShareTargets { } } - /// The share dropdown menu: one row per target, each showing a compact - /// label while the copy button (and row click) copy the full value. + /// The share dropdown menu, one row per target. + /// + /// Each shows a compact label, the copy button and row click copy the full value. pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu { menu.min_w(px(340.)) .item(menu_copy_row( @@ -226,9 +219,7 @@ impl ShareTargets { } } -/// Shorten an naddr link to `/naddr1...[last tail chars]`, e.g. -/// `https://gitworkshop.dev/naddr1...abcd`. Only the label is shortened; -/// the value to be copied stays the full URL. +/// Shorten an naddr link to `/naddr1...[last tail chars]`. fn truncate_naddr_link(url: &str, tail: usize) -> String { let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else { return url.to_string(); @@ -244,7 +235,8 @@ pub(super) const GUTTER_WIDTH: f32 = 44.; /// Height of one row in a virtual diff list. pub(super) const DIFF_ROW_HEIGHT: f32 = 20.; -/// One row of a virtual diff list: a hunk header, or a line of a hunk. +/// One row of a virtual diff list, a hunk header or a line of a hunk. +/// /// Shared by the commit diff and pull request diff viewers. #[derive(Clone, Copy)] pub(super) enum DiffRow { @@ -258,7 +250,7 @@ pub(super) enum DiffRow { Line { hunk: usize, line: usize }, } -/// The rows of `file`'s diff: one header row per hunk, then its lines. +/// The rows of `file`'s diff, one header row per hunk then its lines. pub(super) fn diff_rows(file: &FileDiff) -> Vec { let mut rows = Vec::new(); for (hunk_ix, hunk) in file.hunks.iter().enumerate() { @@ -276,7 +268,7 @@ pub(super) fn diff_rows(file: &FileDiff) -> Vec { rows } -/// One row of the virtual diff list: a hunk header or a single line. +/// One row of the virtual diff list, a hunk header or a single line. pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement { match row { DiffRow::Hunk { @@ -303,8 +295,9 @@ pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> Any } } -/// One diff line: old and new line numbers in gutters, then the content, -/// tinted by kind (addition / deletion / context). +/// One diff line, old and new line numbers in the gutters. +/// +/// The content is tinted by kind, addition, deletion or context. pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { let bg = match line.kind { DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)), @@ -313,8 +306,8 @@ pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { }; let gutter = cx.theme().muted_foreground; - // Fixed height and nowrap: the virtual list assumes every row has - // the same height, so long lines are clipped instead of wrapped. + // Fixed height and nowrap, the virtual list assumes every row has the same height. + // Long lines are clipped instead of wrapped. h_flex() .w_full() .h(px(DIFF_ROW_HEIGHT)) @@ -364,6 +357,257 @@ pub(super) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<& }) } +/// The root issue events of a repo store, for the shared detail sections. +pub(super) fn issue_roots(store: &RepoStore) -> &[Event] { + &store.issues +} + +/// The root pull request events of a repo store, for the shared detail sections. +pub(super) fn pr_roots(store: &RepoStore) -> &[Event] { + &store.pull_requests +} + +/// The trigger body of the branch/tag selectors. +/// +/// The kind icon, the selection or placeholder, and the caret. +/// `Combobox` replaces its default trigger entirely, +/// the only way to show an icon inside it. +pub(super) fn ref_selector_trigger( + ctx: &ComboboxTriggerContext>, + icon: CustomIconName, + cx: &App, +) -> AnyElement { + let muted = cx.theme().muted_foreground; + + h_flex() + .w_full() + .min_w_0() + .gap_1() + .items_center() + .child(Icon::new(icon).small().flex_shrink_0()) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .when(ctx.selection().is_empty(), |this| this.text_color(muted)) + .child( + ctx.selection() + .first() + .map(|(_, item)| item.clone()) + .or_else(|| ctx.placeholder().cloned()) + .unwrap_or_default(), + ), + ) + .child(Caret::new(ctx.size()).text_color(muted)) + .into_any_element() +} + +/// Section heading of a detail sidebar, shared by the issue and PR panels. +pub(super) fn sidebar_title(text: &str, cx: &App) -> AnyElement { + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child(text.to_string()) + .into_any_element() +} + +/// Right sidebar with participants and labels of a root event, issue or PR. +pub(super) fn sidebar_section( + store: &Entity, + id: EventId, + roots: fn(&RepoStore) -> &[Event], + top_gap: bool, + cx: &App, +) -> AnyElement { + let store = store.read(cx); + let Some(root) = roots(store).iter().find(|event| event.id == id) else { + // The caller bails out when the root is missing. + return div().into_any_element(); + }; + let profile_store = ProfileStore::global(cx); + + // Participants, the root author plus everyone who commented. + let mut participants: Vec = vec![root.pubkey]; + participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey)); + participants.sort_by_key(PublicKey::to_hex); + participants.dedup(); + + // Labels are NIP-34 `t` hashtag tags on the event. + let labels: Vec = root.tags.hashtags().map(|tag| tag.to_string()).collect(); + + v_flex() + .w(px(240.)) + .h_full() + .flex_none() + .px_4() + .gap_4() + .border_l(px(1.)) + .border_color(cx.theme().sidebar_border) + .child( + v_flex() + .when(top_gap, |this| this.mt_4()) + .gap_2() + .child(sidebar_title("Participants", cx)) + .children(participants.iter().map(|pubkey| { + let profile = profile_store.read(cx).get(pubkey); + let name = profile.name(); + let picture = profile.picture(); + + h_flex() + .gap_1() + .items_center() + .child(UserAvatar::new(name.clone()).picture(picture)) + .child(div().text_sm().truncate().text_ellipsis().child(name)) + .into_any_element() + })), + ) + .child( + v_flex() + .gap_2() + .child(sidebar_title("Labels", cx)) + .map(|this| { + if labels.is_empty() { + this.child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("None yet."), + ) + } else { + this.child(h_flex().gap_1().children({ + let mut items = vec![]; + + for label in labels.iter() { + items.push( + Tag::secondary() + .outline() + .xsmall() + .child(SharedString::from(label)), + ); + } + + items + })) + } + }), + ) + .into_any_element() +} + +/// The comments on a root event, issue or PR, one card per comment. +pub(super) fn comments_section(store: &Entity, root: EventId, cx: &App) -> AnyElement { + let store = store.read(cx); + let comments: Vec<&Event> = store.comments_of(&root).collect(); + let title = SharedString::from(format!("Discussions {}", comments.len())); + + v_flex() + .gap_4() + .child(div().text_xs().font_semibold().child(title)) + .children(comments.iter().map(|comment| { + let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey); + let author = profile.name(); + let picture = profile.picture(); + let age = relative_time(comment.created_at); + let content = SharedString::from(comment.content.as_str()); + + v_flex() + .gap_1() + .p_3() + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .child( + h_flex() + .gap_2() + .text_sm() + .child( + h_flex() + .gap_1() + .child(UserAvatar::new(author.clone()).picture(picture)) + .child(author), + ) + .child( + div() + .text_color(cx.theme().muted_foreground) + .child("commented"), + ) + .child( + div() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(age)), + ), + ) + .child(div().text_sm().child(content)) + })) + .into_any_element() +} + +/// The comment form posting to an issue or PR root event. +/// +/// `roots` selects the root's list within the store, issues or pull requests. +pub(super) fn comment_form( + store: &Entity, + root: EventId, + roots: fn(&RepoStore) -> &[Event], + comment_input: &Entity, + button_id: &'static str, + cx: &App, +) -> AnyElement { + let comment_input = comment_input.clone(); + let store = store.clone(); + + v_flex() + .gap_2() + .child( + Textarea::new(&comment_input) + .h_24() + .text_color(cx.theme().muted_foreground) + .bg(cx.theme().muted), + ) + .child( + h_flex() + .justify_between() + .child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(Icon::new(CustomIconName::Markdown).small()) + .child("Markdown is supported"), + ) + .child( + Button::new(button_id) + .primary() + .label("Comment") + .tooltip("Post comment") + .on_click(move |_event, window, cx| { + let content = comment_input.read(cx).value().trim().to_string(); + if content.is_empty() { + return; + } + let Some(root) = roots(store.read(cx)) + .iter() + .find(|event| event.id == root) + .cloned() + else { + return; + }; + store.update(cx, |store, cx| { + store.comment(&root, content, cx); + }); + comment_input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); + }), + ), + ) + .into_any_element() +} + #[cfg(test)] mod tests { use super::*; @@ -379,7 +623,7 @@ mod tests { let items = build_tree_items(&entries); - // Input order is preserved (dirs-first, as produced by worktree_entries). + // Input order is preserved, dirs-first as produced by worktree_entries. assert_eq!(items.len(), 3); assert_eq!(items[0].label, "src"); assert_eq!(items[0].id, "src"); @@ -411,9 +655,9 @@ mod tests { #[test] fn tree_builder_merges_shared_prefixes() { - // File children of a directory arrive after other directories' - // entries (the worktree list is dirs-first globally); the shared - // prefix must still resolve to one node. + // File children of a directory arrive after other directories' entries. + // The worktree list is dirs-first globally. + // The shared prefix must still resolve to one node. let entries = vec![ PathBuf::from("a/x.txt"), PathBuf::from("b/y.txt"), @@ -461,7 +705,7 @@ mod tests { truncate_naddr_link("https://gitworkshop.dev/naddr1qqqxyzabc1234", 4), "https://gitworkshop.dev/naddr1...1234" ); - // No naddr1 prefix: unchanged. + // Without the naddr1 prefix, unchanged. assert_eq!( truncate_naddr_link("https://example.com/x", 4), "https://example.com/x" diff --git a/crates/workspace/src/views/repo_detail/init_dialog.rs b/crates/workspace/src/views/repo_detail/init_dialog.rs index 99c8c3c..02083bf 100644 --- a/crates/workspace/src/views/repo_detail/init_dialog.rs +++ b/crates/workspace/src/views/repo_detail/init_dialog.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use assets::CustomIconName; use gpui::prelude::*; -use gpui::{App, Entity, SharedString, WeakEntity, Window, div, px}; +use gpui::{App, Entity, WeakEntity, Window, px}; use gpui_base::h_flex; use gpui_base::input::TextareaState; use gpui_component::button::{Button, ButtonVariants}; @@ -14,22 +14,15 @@ use settings::SettingsStore; use signed_state::Backend; use super::RepoDetailView; +use crate::views::dialog_state::{DialogProgress, error_row}; use crate::views::sidebar::grasp_servers::{ GraspServersState, grasp_servers_field, load_user_grasp_servers, }; /// Shared state for the Init dialog, so async results can be rendered. -#[derive(Default)] -pub struct InitRepoState { - pub busy: bool, - pub error: Option, -} +pub type InitRepoState = DialogProgress; /// Open the Init dialog for the local repository at `local_path`. -/// -/// The dialog loads the user's default grasp servers (kind `10317` grasp -/// list) and falls back to the shared defaults when none are set. On -/// success the dialog closes and `view` switches into NIP-34 mode. pub fn open( local_path: PathBuf, view: WeakEntity, @@ -40,23 +33,25 @@ pub fn open( .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_default(); + + let grasp_settings = SettingsStore::global(cx) + .read(cx) + .settings() + .grasp_servers + .clone(); + + let state = cx.new(|_| InitRepoState::default()); + let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings)); + + let relay_input = cx.new(|cx| InputState::new(window, cx).placeholder("relay.example.com")); let name_input = cx.new(|cx| InputState::new(window, cx).default_value(default_name)); let desc_input = cx.new(|cx| { TextareaState::new(window, cx) .auto_grow(3, 5) .placeholder("Short description") }); - let relay_input = cx.new(|cx| { - InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com") - }); - let state = cx.new(|_| InitRepoState::default()); - let grasp_settings = SettingsStore::global(cx) - .read(cx) - .settings() - .grasp_servers - .clone(); - let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings)); + // Load the user's grasp servers. load_user_grasp_servers(grasp_state.clone(), window, cx); window.open_dialog(cx, move |dialog, _window, _cx| { @@ -115,9 +110,7 @@ pub fn open( ) .child(grasp_servers_field(&grasp_state, &relay_input, cx)), ) - .children(error.map(|message| { - div().text_sm().text_color(cx.theme().danger).child(message) - })) + .children(error_row(&error, cx)) .child( DialogFooter::new().justify_end().child( Button::new("init") @@ -153,8 +146,9 @@ pub fn open( }); } -/// Run the init flow; closes the dialog and switches the repository into -/// its NIP-34 mode on success. +/// Run the init flow. +/// +/// Closes the dialog and switches the repository into NIP-34 mode on success. fn init_repository( local_path: PathBuf, inputs: (Entity, Entity), @@ -170,23 +164,16 @@ fn init_repository( let servers = grasp_state.read(cx).grasp_servers.clone(); if name.is_empty() { - state.update(cx, |state, _| { - state.error = Some("Repository name is required".into()); - }); + state.update(cx, |state, _| state.fail("Repository name is required")); return; } if servers.is_empty() { - state.update(cx, |state, _| { - state.error = Some("Add at least one grasp server".into()); - }); + state.update(cx, |state, _| state.fail("Add at least one grasp server")); return; } - state.update(cx, |state, _| { - state.busy = true; - state.error = None; - }); + state.update(cx, |state, _| state.begin()); let backend = Backend::global(cx); let task = backend.update(cx, |backend, cx| { @@ -211,10 +198,7 @@ fn init_repository( } Err(e) => { cx.update_window(handle, |_, _window, cx| { - state.update(cx, |state, _| { - state.busy = false; - state.error = Some(e.to_string().into()); - }); + state.update(cx, |state, _| state.fail(e.to_string())); }) .ok(); } diff --git a/crates/workspace/src/views/repo_detail/issue_detail.rs b/crates/workspace/src/views/repo_detail/issue_detail.rs index 1cfe6bf..08b23e2 100644 --- a/crates/workspace/src/views/repo_detail/issue_detail.rs +++ b/crates/workspace/src/views/repo_detail/issue_detail.rs @@ -1,31 +1,26 @@ -use std::collections::HashMap; - -use assets::CustomIconName; use dock::{BasePanel, Panel, PanelEvent}; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, - Window, div, px, relative, + App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Window, div, + relative, }; -use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::input::{Textarea, TextareaState}; +use gpui_component::input::TextareaState; use gpui_component::scroll::ScrollableElement; -use gpui_component::tag::Tag; -use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex}; -use nostr::prelude::{Event, EventId, PublicKey}; +use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex}; +use nostr::prelude::EventId; use signed_core::activity_subject; use signed_state::{ProfileStore, RepoStore}; -use signed_ui::image_cache::{MAX_IMAGES, image_cache}; use signed_ui::{UserAvatar, placeholder, status_badge}; use utils::relative_time; +use super::helpers::{comment_form, comments_section, issue_roots, sidebar_section}; + /// Detail panel of a single issue. pub struct IssueDetailView { /// Repo store holding the issues and their statuses. store: Entity, issue_id: EventId, - contents: HashMap, - /// Input state of the "leave a comment" textarea. + /// Input state of the comment textarea. comment_input: Entity, focus_handle: FocusHandle, } @@ -45,193 +40,8 @@ impl IssueDetailView { store, issue_id, comment_input, - contents: HashMap::new(), } } - - fn render_sidebar(&self, cx: &mut Context) -> impl IntoElement { - let profile_store = ProfileStore::global(cx); - let store = self.store.read(cx); - - let Some(issue) = store.issues.iter().find(|issue| issue.id == self.issue_id) else { - // `render` already bails out when the issue is missing. - return div().into_any_element(); - }; - - // Participants: the issue author plus everyone who commented. - let mut participants: Vec = vec![issue.pubkey]; - participants.extend(store.comments_of(&issue.id).map(|comment| comment.pubkey)); - participants.sort_by_key(PublicKey::to_hex); - participants.dedup(); - - // Issue labels are NIP-34 `t` hashtag tags on the event. - let labels: Vec = issue.tags.hashtags().map(|tag| tag.to_string()).collect(); - - v_flex() - .w(px(240.)) - .h_full() - .flex_none() - .px_4() - .gap_4() - .border_l(px(1.)) - .border_color(cx.theme().sidebar_border) - .child( - v_flex() - .gap_2() - .child(sidebar_title("Participants", cx)) - .children(participants.iter().map(|pubkey| { - let profile = profile_store.read(cx).get(pubkey); - let name = profile.name(); - let picture = profile.picture(); - - h_flex() - .gap_1() - .items_center() - .child(UserAvatar::new(name.clone()).picture(picture)) - .child(div().text_sm().truncate().text_ellipsis().child(name)) - .into_any_element() - })), - ) - .child( - v_flex() - .gap_2() - .child(sidebar_title("Labels", cx)) - .map(|this| { - if labels.is_empty() { - this.child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("None yet."), - ) - } else { - this.child(h_flex().gap_1().children({ - let mut items = vec![]; - - for label in labels.iter() { - items.push( - Tag::secondary() - .outline() - .xsmall() - .child(SharedString::from(label)), - ); - } - - items - })) - } - }), - ) - .into_any_element() - } - - fn render_comments(&mut self, id: &EventId, cx: &mut Context) -> impl IntoElement { - let store = self.store.read(cx); - let comments: Vec<&Event> = store.comments_of(id).collect(); - let title = SharedString::from(format!("Discussions {}", comments.len())); - - v_flex() - .gap_4() - .child(div().text_xs().font_semibold().child(title)) - .children(comments.iter().map(|comment| { - let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey); - let author = profile.name(); - let picture = profile.picture(); - let age = relative_time(comment.created_at); - // Comment bodies are cloned into shared strings once per - // comment, not on every render. - let content = self - .contents - .entry(comment.id) - .or_insert_with(|| SharedString::from(comment.content.clone())) - .clone(); - - v_flex() - .gap_1() - .p_3() - .border_1() - .border_color(cx.theme().border) - .rounded(cx.theme().radius) - .child( - h_flex() - .gap_2() - .text_sm() - .child( - h_flex() - .gap_1() - .child(UserAvatar::new(author.clone()).picture(picture)) - .child(author), - ) - .child( - div() - .text_color(cx.theme().muted_foreground) - .child("commented"), - ) - .child( - div() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(age)), - ), - ) - .child(div().text_sm().child(content)) - })) - .into_any_element() - } - - fn render_form(&mut self, id: &EventId, cx: &mut Context) -> impl IntoElement { - let comment_input = self.comment_input.clone(); - let store = self.store.clone(); - let id = id.to_owned(); - - v_flex() - .gap_2() - .child( - Textarea::new(&self.comment_input) - .h_24() - .text_color(cx.theme().muted_foreground) - .bg(cx.theme().muted), - ) - .child( - h_flex() - .justify_between() - .child( - h_flex() - .gap_1() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(Icon::new(CustomIconName::Markdown).small()) - .child("Markdown is supported"), - ) - .child( - Button::new("comment") - .primary() - .label("Comment") - .tooltip("Post comment") - .on_click(move |_event, window, cx| { - let content = comment_input.read(cx).value().trim().to_string(); - if content.is_empty() { - return; - } - let Some(root) = store - .read(cx) - .issues - .iter() - .find(|issue| issue.id == id) - .cloned() - else { - return; - }; - store.update(cx, |store, cx| { - store.comment(&root, content, cx); - }); - comment_input.update(cx, |input, cx| { - input.set_value("", window, cx); - }); - }), - ), - ) - .into_any_element() - } } impl BasePanel for IssueDetailView { @@ -277,17 +87,11 @@ impl Render for IssueDetailView { let (title, author, picture, status, age, issue_id, content) = { let profile_store = ProfileStore::global(cx); let profile = profile_store.read(cx).get(&issue.pubkey); - let content = self - .contents - .entry(issue.id) - .or_insert_with(|| { - if issue.content.is_empty() { - SharedString::from("No description provided.") - } else { - SharedString::from(&issue.content) - } - }) - .clone(); + let content = if issue.content.is_empty() { + SharedString::from("No description provided.") + } else { + SharedString::from(&issue.content) + }; ( activity_subject(issue), @@ -301,7 +105,7 @@ impl Render for IssueDetailView { }; h_flex() - .image_cache(image_cache("issue-detail", MAX_IMAGES)) + .image_cache(gpui::retain_all("issue-detail")) .id("issue-detail") .size_full() .child( @@ -358,20 +162,24 @@ impl Render for IssueDetailView { ) .child(div().text_sm().child(content)), ) - .child(self.render_comments(&issue_id, cx)) - .child(self.render_form(&issue_id, cx)), + .child(comments_section(&self.store, issue_id, cx)) + .child(comment_form( + &self.store, + issue_id, + issue_roots, + &self.comment_input, + "comment", + cx, + )), ), ) - .child(self.render_sidebar(cx)) + .child(sidebar_section( + &self.store, + issue_id, + issue_roots, + false, + cx, + )) .into_any_element() } } - -fn sidebar_title(text: &str, cx: &App) -> AnyElement { - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().muted_foreground) - .child(text.to_string()) - .into_any_element() -} diff --git a/crates/workspace/src/views/repo_detail/issues.rs b/crates/workspace/src/views/repo_detail/issues.rs index 403f402..6820606 100644 --- a/crates/workspace/src/views/repo_detail/issues.rs +++ b/crates/workspace/src/views/repo_detail/issues.rs @@ -1,7 +1,7 @@ use std::rc::Rc; use assets::CustomIconName; -use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; +use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle}; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, @@ -18,14 +18,12 @@ use gpui_component::{ use nostr::prelude::EventId; use signed_core::{RepoStatus, activity_subject}; use signed_state::{ProfileStore, RepoStore}; -use signed_ui::image_cache::{MAX_IMAGES, image_cache}; use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge}; use utils::relative_time; use super::issue_detail::IssueDetailView; -/// Height of one issue row in the virtual list: `py_2` padding, a 32px -/// title line (`h_8`), a 24px meta line (`h_6`) and the 1px bottom border. +/// Height of one issue row in the virtual list. const ISSUE_ROW_HEIGHT: f32 = 73.; /// Status filter of the issues list, chosen via the header's filter buttons. @@ -35,8 +33,7 @@ enum IssueFilter { All, /// Issues whose resolved status is [`RepoStatus::Open`]. Open, - /// Issues whose resolved status is [`RepoStatus::Closed`] or - /// [`RepoStatus::Applied`] (both are "done" states). + /// Issues whose resolved status is [`RepoStatus::Closed`]. Closed, } @@ -63,14 +60,11 @@ pub struct IssuesView { filter: IssueFilter, /// Per-row heights of the virtual list. item_sizes: Rc>>, - /// Number of rows [`Self::item_sizes`] was built for (the filtered issue count). + /// The filtered issue count [`Self::item_sizes`] was built for. issue_len: usize, - /// Indices into the store's `issues` matching [`Self::filter`]; the - /// virtual list renders this slice. Rebuilt only when the store - /// version or the filter changes, keyed by [`Self::cache_key`]. + /// Indices into the store's `issues` matching [`Self::filter`]. visible_issues: Vec, - /// Header counts `(total, open, closed)`, rebuilt with - /// [`Self::visible_issues`]. + /// Header counts `(total, open, closed)`, rebuilt with [`Self::visible_issues`]. counts: (usize, usize, usize), /// Store version and filter the cached rows/counts were built from. cache_key: Option<(u64, IssueFilter)>, @@ -82,10 +76,11 @@ impl IssuesView { pub fn new( dock_area: WeakEntity, store: Entity, - repo_name: SharedString, _window: &mut Window, cx: &mut Context, ) -> Self { + let repo_name = store.read(cx).name(); + Self { focus_handle: cx.focus_handle(), dock_area, @@ -101,7 +96,7 @@ impl IssuesView { } } - /// Open the detail panel of `issue_id` at the bottom of the dock area. + /// Open the detail panel of `issue_id` in the dock area. fn open_issue_detail( &mut self, issue_id: EventId, @@ -115,12 +110,10 @@ impl IssuesView { let panel = cx.new(|cx| IssueDetailView::new(self.store.clone(), issue_id, window, cx)); dock_area.update(cx, |dock_area, cx| { - dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx); + add_center_panel(dock_area, panel_handle(panel), window, cx); }); } - /// Render one row of the issue list; `ix` is the row index and - /// `issue_ix` the index of the issue in the store's `issues`. fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context) -> AnyElement { let issue = &self.store.read(cx).issues[issue_ix]; let title = activity_subject(issue); @@ -183,8 +176,7 @@ impl IssuesView { } fn render_header(&self, cx: &mut Context) -> AnyElement { - // Counts of the last list rebuild (`render` rebuilds first when the - // store version or filter changed, so this is never stale). + // Counts of the last list rebuild. let (total, open, closed) = self.counts; h_flex() @@ -242,8 +234,7 @@ impl IssuesView { } } -/// Open the "new issue" dialog: a title and a content input that submit -/// through [`RepoStore::open_issue`] when confirmed. +/// Open the new issue dialog, a title and a content input. pub(super) fn open_new_issue_dialog(store: Entity, window: &mut Window, cx: &mut App) { let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Issue title")); let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue...")); @@ -332,9 +323,9 @@ impl Render for IssuesView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let filter = self.filter; - // Rebuild the filtered rows and header counts only when the store - // refreshed or the filter changed; other renders reuse the cache. + // Rows and counts are rebuilt only when the store refreshed or filter changed. let version = self.store.read(cx).version(); + if self.cache_key != Some((version, filter)) { let store = self.store.read(cx); let mut counts = (0usize, 0usize, 0usize); @@ -359,8 +350,8 @@ impl Render for IssuesView { let count = self.visible_issues.len(); - // The virtual list's item count comes from `item_sizes`; rebuild it - // whenever the filtered issue count changes. + // The virtual list's item count comes from `item_sizes`. + // Rebuild it whenever the filtered issue count changes. if count != self.issue_len { self.issue_len = count; self.item_sizes = Rc::new(vec![size(px(0.), px(ISSUE_ROW_HEIGHT)); count]); @@ -371,7 +362,7 @@ impl Render for IssuesView { v_flex() .size_full() - .image_cache(image_cache("issues", MAX_IMAGES)) + .image_cache(gpui::retain_all("issues")) .child(self.render_header(cx)) .child( v_flex() diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index d6279ec..f955430 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -5,20 +5,18 @@ use std::time::Duration; use anyhow::Error; use assets::CustomIconName; -use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; +use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle}; use gix::Repository; use gpui::prelude::*; use gpui::{ Action, Anchor, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, Task, - WeakEntity, Window, div, px, relative, size, + WeakEntity, Window, div, px, relative, size, transparent_white, }; use gpui_base::{Button as BaseButton, Disableable, Popover}; use gpui_component::alert::Alert; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::combobox::{ - Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext, -}; +use gpui_component::combobox::{Combobox, ComboboxEvent, ComboboxState}; use gpui_component::menu::DropdownMenu; use gpui_component::searchable_list::SearchableVec; use gpui_component::tree::TreeState; @@ -26,12 +24,14 @@ use gpui_component::{ ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, VirtualListScrollHandle, h_flex, v_flex, }; -use nostr::prelude::{EventId, RelayUrl, ToBech32}; -use signed_core::{Announcement, RepoAddr, filters}; +use nostr::prelude::{RelayUrl, ToBech32}; +use signed_core::{Announcement, RepoAddr, RepoStatus, filters}; use signed_git::{CommitList, FileCommit}; -use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoListStore, RepoStore}; -use signed_ui::image_cache::{MAX_IMAGES, image_cache}; -use signed_ui::{DropdownButton, PixelAvatar, UserAvatar, copy_row}; +use signed_state::{ + Backend, CheckoutStatus, CheckoutsStore, GitStore, LocalReposStore, ProfileStore, + RepoListStore, RepoStore, pr_proposes_checkout, +}; +use signed_ui::{CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row}; mod about; mod browser; @@ -53,7 +53,10 @@ use browser::{ }; use commits::COMMIT_ROW_HEIGHT; use diff::CommitDiffView; -use helpers::{ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, tree_items}; +use helpers::{ + ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, ref_selector_trigger, + tree_items, +}; use issues::{IssuesView, open_new_issue_dialog}; use pull_requests::PullRequestsView; use send_patch::open_send_patch_panel; @@ -63,35 +66,36 @@ use crate::views::repo_detail::new_pull_request::open_new_pull_panel; /// What kind of ref the header selectors switch to. #[derive(Clone, Copy, PartialEq, Eq)] enum RefKind { - /// A local branch (`refs/heads/*`); HEAD stays attached. + /// A local branch `refs/heads/*`, HEAD stays attached. Branch, - /// A tag (`refs/tags/*`); HEAD becomes detached. + /// A tag `refs/tags/*`, HEAD becomes detached. Tag, } /// Header actions dispatched by the dropdown menus of the header buttons. -/// `pub(super)`: the pull-request list panel offers the same New-PR / Send- -/// patch actions in its own dropdown. +/// `pub(super)` because the pull-request list panel shares this action set. +/// It offers the New-PR and Send-patch actions in its own dropdown. #[derive(Clone, Action, PartialEq, Eq)] #[action(namespace = repo_detail, no_json)] pub(super) enum RepoAction { - /// Open the "new issue" dialog. + /// Open the new issue dialog. NewIssue, - /// Open the "new pull request" dialog. + /// Open the new pull request dialog. NewPR, - /// Open the "send patch" panel. + /// Open the send patch panel. SendPatch, /// Open the about dialog. About, /// Re-push the repository to its grasp servers. Push, - /// Delete the repository from nostr (owner only). + /// Delete the repository from nostr, owner only. Delete, } -/// Everything loaded from the local clone for the explorer: the tree seeds, -/// README, refs and HEAD commit. Computed on a background thread (see -/// [`load_repo_data`]) and applied on the main thread. +/// Everything loaded from the local clone for the explorer. +/// The tree seeds, README, refs and HEAD commit. +/// Computed on a background thread, see [`load_repo_data`]. +/// Applied on the main thread. struct RepoData { tree: Vec, readme_path: Option, @@ -103,68 +107,54 @@ struct RepoData { head_commit: Option, } -/// Derived NIP-34 header data, cached so renders don't re-encode bech32 -/// share targets and rebuild clone command strings on every frame. -struct HeaderCache { - /// Announcement event ID and owner NIP-05 this cache was built from; - /// rebuilt when either changes (a new announcement version, or the - /// owner's profile arriving with a NIP-05 identifier). - key: (EventId, Option), - announcement: Rc, - share: Rc, - ngit_command: SharedString, - nak_command: SharedString, - git_commands: Rc>, -} - -/// Detail view of a repository: header, stats, a file explorer with README -/// preview (cloned from the announcement's `clone` URLs), and metadata. +/// Detail view of a repository, header, stats and metadata. +/// A file explorer with README preview, cloned from the announcement's `clone` URLs. pub struct RepoDetailView { focus_handle: FocusHandle, - /// Dock area the detail view lives in; new panels (commit diffs) are - /// added there. + /// Dock area the detail view lives in. + /// New panels, commit diffs, are added there. dock_area: WeakEntity, - /// Snapshot taken at open time, shown until the store's first refresh - /// completes (and as a fallback while the store has no announcement). + /// Snapshot taken at open time. + /// Shown until the store's first refresh completes. + /// Also a fallback while the store has no announcement. /// `None` for local repositories that haven't been published yet. initial: Option, - /// Per-repository nostr store (announcement, issues, PRs, statuses). - /// `None` until a local repository is initialized (published) to - /// NIP-34. + /// Per-repository nostr store, holding announcement, issues, PRs and statuses. + /// `None` until a local repository is initialized to NIP-34. store: Option>, - /// Path of the local repository when opened from the scan; `None` once - /// it has been initialized to NIP-34 (or for announced repositories). + /// Path of the local repository when opened from the scan. + /// `None` once it is initialized to NIP-34, or for announced repositories. local_path: Option, - /// File explorer state (worktree of the local clone). + /// File explorer state, the worktree of the local clone. tree_state: Entity, /// Root of the local clone, for reading files on demand. worktree: Option, - /// Markdown document currently in the preview pane (README or a file). + /// Markdown document currently in the preview pane, README or a file. md: Option, /// Code file currently in the preview pane. code: Option, readme_name: Option, - /// Currently previewed file (relative path) and its contents. + /// Currently previewed file, a relative path, and its contents. selected_file: Option, files: HashMap, - /// Paths of cached previews, oldest first; feeds the eviction caps in - /// [`Self::evict_previews`]. + /// Paths of cached previews, oldest first. + /// Feeds the eviction caps in [`Self::evict_previews`]. file_order: VecDeque, /// Total text bytes held by [`Self::files`]. preview_bytes: usize, /// Reads in flight, to avoid duplicate loads. loading_files: HashSet, - /// Latest commit touching a previewed file (or the README), keyed by path. + /// Latest commit touching a previewed file or the README, keyed by path. commits: HashMap, - /// Paths queued for the next batched commit query (see [`Self::load_commits`]). + /// Paths queued for the next batched commit query, see [`Self::load_commits`]. pending_commits: Vec, /// A batched commit query is in flight. loading_commits: bool, - /// Active header tab: 0 = Files (tree), 1 = Commits. + /// Active header tab, 0 = Files tree, 1 = Commits. active_tab: usize, - /// Commits reachable from HEAD, newest first; `None` until the walk - /// finishes (or fails). `commits` may be capped by - /// [`CommitList`]; `total` feeds the tab badge. + /// Commits reachable from HEAD, newest first. + /// `None` until the walk finishes or fails. + /// [`CommitList`] caps the list, `total` feeds the tab badge. all_commits: Option, /// Commit walk in flight. loading_all_commits: bool, @@ -173,59 +163,74 @@ pub struct RepoDetailView { item_sizes: Rc>>, /// A clone/fetch is in flight. loading: bool, - /// The header clone button is cloning into a user-chosen folder. - cloning: bool, - /// A push to the grasp servers is in flight. - pushing: bool, error: Option, /// Commit HEAD currently points to, shown in the header button. head_commit: Option, - /// Branch selector (header): local branches, searchable. + /// Branch selector in the header, local branches, searchable. branch_select: Entity>>, - /// Tag selector (header): tags, searchable. + /// Tag selector in the header, tags, searchable. tag_select: Entity>>, - /// A branch/tag switch is in flight (checkout plus explorer reload). + /// A branch/tag switch is in flight, checkout plus explorer reload. switching_ref: bool, - /// Bumped on every branch/tag switch; in-flight loads tagged with an - /// older generation are discarded when they complete. + /// Bumped on every branch/tag switch. + /// In-flight loads with an older generation are discarded when they complete. ref_generation: u64, - /// Derived NIP-34 header data (share targets, clone commands), - /// rebuilt only when the announcement or the owner's NIP-05 changes - /// instead of on every render. - header_cache: Option, - /// In-flight tasks; finished tasks are pruned on every push, so the vec - /// stays bounded by the number of concurrent loads. + /// In-flight tasks, finished tasks are pruned on every push. + /// The vec stays bounded by the number of concurrent loads. tasks: Vec>>, /// Subscriptions keeping the selectors' confirm events alive. _subscriptions: Vec, - /// Upstream repository (from this fork's `u` tag) the user asked to - /// open, while its announcement is still being fetched. + /// `(path, branch)` ready-suggestions dismissed by the user, per panel. + banner_dismissed: HashSet<(PathBuf, String)>, + /// The announced HEAD the ready statuses were last requested with. + /// Whether they were requested at all. + /// Re-requested only when the HEAD, the base default, changes. + /// e.g. when the store's first refresh lands. + ready_requested: bool, + ready_head: Option, + /// The global checkouts store's ready-to-contribute statuses of this + /// repository, last seen when they drove a render. + /// + /// The store notifies on any recompute pass; the observer re-renders this + /// panel only when these slices changed. + ready_statuses: Vec, + /// The global checkouts store's ready-to-push statuses of this repository, + /// last seen when they drove a render. + push_statuses: Vec, + /// Upstream repository, from this fork's `u` tag, the user asked to open. + /// Its announcement is still being fetched. pending_upstream: Option, } impl RepoDetailView { - /// Open a repository announced on NIP-34: the store connects to the - /// announcement's relays and loads issues, PRs and statuses. + /// Open a repository announced. + /// + /// The store connects to the announcement's relays and loads issues, PRs and statuses. pub fn new( dock_area: WeakEntity, initial: Announcement, window: &mut Window, cx: &mut Context, ) -> Self { - // The announcement we opened from already carries the repository's - // NIP-34 `relays` tag, so the store can connect to those relays - // immediately instead of waiting for the bootstrap fetch. + // The announcement we opened from already carries the NIP-34 `relays` tag. + // The store connects to those relays immediately, no bootstrap fetch wait. let addr = initial.addr(); let relays = initial.relays.clone(); let store = cx.new(|cx| RepoStore::new(addr, relays, cx)); - Self::new_common(dock_area, Some(initial), Some(store), None, window, cx) + let mut view = Self::new_common( + dock_area, + Some(initial), + Some(store.clone()), + None, + window, + cx, + ); + view.attach_store(&store, cx); + view } - /// Open a local repository discovered by the scan. There is no - /// announcement and no nostr store until the user initializes - /// (publishes) it to NIP-34, so the header shows an Init button - /// instead of the NIP-34 actions. + /// Open a local repository discovered by the scan. pub fn new_local( dock_area: WeakEntity, local_path: PathBuf, @@ -235,8 +240,9 @@ impl RepoDetailView { Self::new_common(dock_area, None, None, Some(local_path), window, cx) } - /// Shared construction: file explorer state, ref selectors and the - /// deferred repository load. + /// Shared construction. + /// + /// File explorer state, ref selectors and the deferred repository load. fn new_common( dock_area: WeakEntity, initial: Option, @@ -247,7 +253,7 @@ impl RepoDetailView { ) -> Self { let tree_state = cx.new(|cx| TreeState::new(cx)); - // Empty until the clone completes; populated with the local refs. + // Empty until the clone completes, then filled with the local refs. let branch_select: Entity>> = cx.new(|cx| { ComboboxState::new( SearchableVec::new(Vec::::new()), @@ -267,11 +273,11 @@ impl RepoDetailView { .searchable(true) }); - let subscriptions = vec![ + let mut subscriptions = vec![ cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| { - // `Change` fires only when the selection actually changed - // (picking the already-selected branch emits nothing), so a - // confirmed value always means a switch. + // `Change` fires only when the selection actually changed. + // Picking the already-selected branch emits nothing. + // A confirmed value always means a switch. if let ComboboxEvent::Change(values) = event && let Some(name) = values.first() { @@ -287,6 +293,17 @@ impl RepoDetailView { }), ]; + // The ready-to-contribute and ready-to-push banners are driven by the + // global checkouts store. It notifies on every recompute; compare the + // statuses of this repository so unrelated updates (the sidebar badges, + // other open panels) do not re-render this panel. + let checkouts = CheckoutsStore::global(cx); + subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| { + if this.refresh_statuses(cx) { + cx.notify(); + } + })); + // Defer loading the repository until the window is ready. cx.defer_in(window, |this, window, cx| { this.load_repo(window, cx); @@ -316,34 +333,35 @@ impl RepoDetailView { scroll_handle: VirtualListScrollHandle::new(), item_sizes: Rc::new(Vec::new()), loading: true, - cloning: false, - pushing: false, error: None, head_commit: None, branch_select, tag_select, switching_ref: false, ref_generation: 0, - header_cache: None, + banner_dismissed: HashSet::new(), + ready_requested: false, + ready_head: None, + ready_statuses: Vec::new(), + push_statuses: Vec::new(), + pending_upstream: None, focus_handle: cx.focus_handle(), tasks: Vec::new(), _subscriptions: subscriptions, - pending_upstream: None, } } - /// Load the repository and populate the file explorer. A local - /// (not yet published) repository is opened straight from disk. An - /// announced repository's local clone (if any) is loaded first without - /// touching the network, so an unreachable server can't block the - /// panel; a background fetch then refreshes the refs and commit list. + /// Load the repository and populate the file explorer. + /// + /// A local, not yet published, repository opens straight from disk. + /// An announced repository's clone, if any, loads first without touching the network. fn load_repo(&mut self, window: &mut Window, cx: &mut Context) { self.loading = true; self.error = None; cx.notify(); - // Local repositories live on disk at their scan path; there is no - // clone to ensure and no network refresh. + // Local repositories live on disk at their scan path. + // No clone step or network refresh applies here. if let Some(local_path) = self.local_path.clone() { let task = cx.spawn_in(window, async move |this, cx| { let data = cx @@ -361,20 +379,25 @@ impl RepoDetailView { this.loading = false; cx.notify(); })?; + Ok(()) }); + self.tasks.push(task); + return; } let Some(initial) = self.initial.as_ref() else { return; }; + let cache = GitStore::global(cx).cache().clone(); let addr = initial.addr(); let clone_urls: Vec = initial.clone.iter().map(ToString::to_string).collect(); - // Captured before the loads start: a branch/tag switch bumps it, and - // the refresh below is discarded when that happens. + + // Captured before the loads start. + // A branch/tag switch bumps the generation, discarding the refresh below. let refresh_generation = self.ref_generation; let disk = { @@ -392,7 +415,7 @@ impl RepoDetailView { let disk = disk.await; let had_clone = matches!(&disk, Ok(Some(_))); - // No local clone yet: clone from the network (blocking), then load. + // No local clone yet, so clone from the network then load. let data = match disk { Ok(Some(data)) => Ok(data), Ok(None) => { @@ -417,12 +440,13 @@ impl RepoDetailView { cx.notify(); })?; - // Refresh the clone from the network in the background; when it - // completes, update the refs and commit list. Loads started - // before a branch/tag switch are discarded via the generation. + // Refresh the clone from the network in the background. + // When it completes, update the refs and commit list. + // Loads started before a branch/tag switch are discarded via the generation. if !had_clone { return Ok(()); } + let refresh = { let cache = cache.clone(); let addr = addr.clone(); @@ -430,10 +454,24 @@ impl RepoDetailView { let Some(repo) = cache.open(&addr)? else { return Ok::<_, Error>(None); }; - // Best-effort: a failed fetch (e.g. offline) keeps the - // cached state, which is already shown. + + // Best-effort, a fetch failure, e.g. offline, keeps the cached state. + // The state is already shown. signed_git::fetch_all(&repo).ok(); + let worktree = repo.workdir().map(Path::to_path_buf); + // A fetch never moves a mirror's local branches. + // A push landing on the grasp servers would never show up. + // That covers own repo pushes from a checkout and updates fetched here. + // Fast-forward branches from the remote, like `git pull --ff-only`. + // Only the checked-out branch's worktree can change on disk. + let moved = match &worktree { + Some(worktree) => { + signed_git::fast_forward_branches(worktree).unwrap_or(false) + } + None => false, + }; + let (branches, tags) = match &worktree { Some(_) => ( signed_git::repo_branches(&repo).unwrap_or_default(), @@ -441,9 +479,11 @@ impl RepoDetailView { ), None => (Vec::new(), Vec::new()), }; + let current_branch = signed_git::current_branch(&repo).unwrap_or(None); let head_commit = signed_git::head_commit(&repo).unwrap_or(None); - Ok::<_, Error>(Some((branches, tags, current_branch, head_commit))) + + Ok::<_, Error>(Some((moved, branches, tags, current_branch, head_commit))) }) } .await; @@ -452,7 +492,16 @@ impl RepoDetailView { if refresh_generation != this.ref_generation { return; } - if let Ok(Some((branches, tags, current_branch, head_commit))) = refresh { + if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh { + if moved { + // The mirror caught up with the remote. + // E.g. the push of an owned checkout just landed. + // Rebuild the explorer, previews and commit list from the worktree. + this.reload_worktree(cx); + cx.notify(); + return; + } + let branches: Vec = branches.iter().map(Into::into).collect(); let tags: Vec = tags.iter().map(Into::into).collect(); @@ -489,8 +538,7 @@ impl RepoDetailView { self.tasks.push(task); } - /// Apply the loaded repository data: explorer tree, README preview, - /// ref selectors and HEAD commit, then start the commit-list walk. + /// Apply the loaded repository data. fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context) { let RepoData { tree, @@ -513,8 +561,8 @@ impl RepoDetailView { state.set_items(tree_items(tree, false), cx); }); - // Populate the branch/tag selectors with the local refs, - // selecting the branch HEAD points to. + // Populate the branch/tag selectors with the local refs. + // Select the branch HEAD points to. let branches: Vec = branches.into_iter().map(Into::into).collect(); let tags: Vec = tags.into_iter().map(Into::into).collect(); @@ -541,22 +589,19 @@ impl RepoDetailView { } } - /// Clone the repository into a folder chosen by the user (outside the cache), - /// then open the new clone in the system file manager. + /// Clone the repository into a user-chosen folder outside the cache. fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context) { - if self.cloning { + let Some(store) = self.store.clone() else { return; - } + }; - let (clone_urls, name) = { + let name = { let Some(announcement) = self.announcement(cx) else { return; }; let addr = announcement.addr(); - let clone_urls: Vec = - announcement.clone.iter().map(ToString::to_string).collect(); - // Directory name: the display name, falling back to the repo id; - // both sanitized to a safe single path component. + // Directory name, the display name falling back to the repo id. + // Both are sanitized to a safe single path component. let name = announcement .name .as_ref() @@ -564,17 +609,13 @@ impl RepoDetailView { .filter(|name| !name.trim().is_empty()) .unwrap_or_else(|| addr.identifier.clone()); let name = signed_git::sanitize_path_component(&name); - let name = if name.is_empty() { + if name.is_empty() { "repository".to_owned() } else { name - }; - (clone_urls, name) + } }; - self.cloning = true; - cx.notify(); - let prompt = cx.prompt_for_paths(PathPromptOptions { files: false, directories: true, @@ -583,52 +624,46 @@ impl RepoDetailView { }); let task = cx.spawn_in(window, async move |this, cx| { - // `Ok(Ok(Some(paths)))` means the user picked a folder; a - // cancel (or a picker failure) resolves to anything else. + // `Ok(Ok(Some(paths)))` means the user picked a folder. + // A cancel or picker failure resolves to anything else. let picked = match prompt.await { Ok(Ok(Some(mut paths))) => paths.pop(), _ => None, }; let Some(folder) = picked else { - this.update_in(cx, |this, _window, cx| { - this.cloning = false; - cx.notify(); - })?; return Ok(()); }; let destination = folder.join(&name); let destination_for_open = destination.clone(); - let result = cx - .background_spawn(async move { signed_git::clone_repo(&clone_urls, &destination) }) - .await; - this.update_in(cx, |this, _window, cx| { - this.cloning = false; - match result { - Ok(_) => cx.open_with_system(&destination_for_open), - Err(error) => { - this.error = Some(format!("Failed to clone: {error}").into()); - } - } - cx.notify(); + // The store owns the clone, its busy flag and error reporting. + let clone = this.update_in(cx, |_this, _window, cx| { + store.update(cx, |store, cx| store.clone_to_folder(destination, cx)) })?; + // Reveal the new clone in the system file manager on success. + // Failures already surfaced in the store's error banner. + if let Ok(()) = clone.await { + this.update_in(cx, |_this, _window, cx| { + cx.open_with_system(&destination_for_open); + })?; + } + Ok(()) }); self.tasks.push(task); } - /// Preview the file at `path` (relative to the worktree root). + /// Preview the file at `path`, relative to the worktree root. fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context) { self.selected_file = Some(path.into()); if self.files.contains_key(path) { - // The file is cached, but the persistent markdown/code state may - // still hold a different file; re-point it at this one (the parse - // runs on a background task either way). Without this, the pane - // would show a spinner forever. + // The file is cached, but the markdown or code state may hold a different file. + // Re-point it at this one, the parse runs on a background task either way. + // Without this, the pane would show a spinner forever. if let Some(FileContent::Text(text)) = self.files.get(path) { let text = text.clone(); if is_markdown_path(path) { @@ -647,8 +682,8 @@ impl RepoDetailView { return; } - // Paths come from our own tree walk, but never trust them: refuse - // anything that could escape the worktree. + // Paths come from our own tree walk, but never trust them. + // Refuse anything that could escape the worktree. let rel = Path::new(path); let unsafe_path = rel.is_absolute() || rel.components().any(|c| { @@ -668,6 +703,7 @@ impl RepoDetailView { self.loading_files.insert(path.to_string()); let path = path.to_string(); + self.load_commit(&path, cx); let generation = self.ref_generation; @@ -676,9 +712,9 @@ impl RepoDetailView { let content = cx .background_spawn(async move { let full = worktree.join(&path_for_read); - // Refuse oversized files before reading them: reading a - // multi-gigabyte file just to classify it as too large - // would waste the disk and memory bandwidth. + // Refuse oversized files before reading them. + // Reading a multi-gigabyte file just to classify it is wasteful. + // It would burn disk and memory bandwidth. let metadata = match std::fs::metadata(&full) { Ok(metadata) => metadata, Err(error) => return Err(anyhow::anyhow!("{}", error)), @@ -698,10 +734,10 @@ impl RepoDetailView { .await; this.update_in(cx, |this, window, cx| { - // The worktree was switched while this file was reading; - // the result belongs to the previous branch. Clear the - // in-flight marker either way, or the path could never be - // loaded again. + // The worktree was switched while this file was reading. + // The result belongs to the previous branch. + // Clear the in-flight marker either way. + // Otherwise the path could never be loaded again. if generation != this.ref_generation { this.loading_files.remove(&path); return; @@ -743,8 +779,8 @@ impl RepoDetailView { self.tasks.push(task); } - /// Queue `path` for the per-file commit query; requests are batched into - /// one history walk (see [`Self::load_commits`]). + /// Queue `path` for the per-file commit query. + /// Requests are batched into one history walk, see [`Self::load_commits`]. fn load_commit(&mut self, path: &str, cx: &mut Context) { if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) { return; @@ -755,10 +791,10 @@ impl RepoDetailView { } } - /// Walk history once for every queued path on a background task, and - /// cache the latest commit touching each of them in [`Self::commits`] - /// (for the file header in the content column). Batching shares one - /// walk across all paths queued while the previous walk was in flight. + /// Walk history once for every queued path on a background task. + /// Cache the latest commit touching each path in [`Self::commits`]. + /// That feeds the file header in the content column. + /// Batching shares one walk across paths queued while the previous walk ran. fn load_commits(&mut self, cx: &mut Context) { if self.pending_commits.is_empty() || self.loading_commits { return; @@ -790,10 +826,9 @@ impl RepoDetailView { .insert(path.to_string_lossy().into_owned(), commit); } } - // Paths queued while the walk was in flight start the next - // batch. A stale walk (branch switched mid-flight) must not - // strand them, so this runs under the current generation - // regardless of whether the result was applied. + // Paths queued while the walk was in flight start the next batch. + // A stale walk, branch switched mid-flight, must not strand them. + // This runs under the current generation regardless of the result. if !this.pending_commits.is_empty() { this.load_commits(cx); } @@ -806,9 +841,9 @@ impl RepoDetailView { self.tasks.push(task); } - /// Walk all commits reachable from HEAD on a background task, for the - /// Commits tab and its total-count badge. The list is capped by - /// [`CommitList`]; only the newest commits are materialized. + /// Walk all commits reachable from HEAD on a background task. + /// For the Commits tab and its total-count badge. + /// [`CommitList`] caps the list, only the newest commits are materialized. fn load_all_commits(&mut self, cx: &mut Context) { if self.loading_all_commits || self.all_commits.is_some() { return; @@ -827,8 +862,8 @@ impl RepoDetailView { .await; this.update(cx, |this, cx| { - // A stale walk (branch switched mid-flight) must not leave - // the flag set, or the Commits tab would spin forever. + // A stale walk, branch switched mid-flight, must not leave the flag set. + // Otherwise the Commits tab would spin forever. if generation != this.ref_generation { this.loading_all_commits = false; return; @@ -848,9 +883,7 @@ impl RepoDetailView { self.tasks.push(task); } - /// Open a new panel showing the diff of `commit_id` (all files it - /// changed, with the line diff of each). Called from the Commits tab - /// rows and the latest-commit button in the header. + /// Open a new panel showing the diff of `commit_id`. fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context) { let Some(worktree) = self.worktree.clone() else { return; @@ -867,65 +900,71 @@ impl RepoDetailView { cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx)); dock_area.update(cx, |dock_area, cx| { - dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx); + add_center_panel(dock_area, panel_handle(panel), window, cx); }); } - /// Re-push the repository's refs to its announced grasp servers; the - /// menu trigger shows a spinner while the push is in flight, failures - /// appear in the panel's error banner. - fn push_repository(&mut self, window: &mut Window, cx: &mut Context) { - if self.pushing { - return; - } - let Some(announcement) = self.announcement(cx).cloned() else { + /// Re-push the repository's refs to its announced grasp servers. + fn push_repository(&mut self, _window: &mut Window, cx: &mut Context) { + let Some(store) = self.store.clone() else { return; }; - self.pushing = true; + self.error = None; cx.notify(); - let backend = Backend::global(cx); - let task = backend.update(cx, |backend, cx| backend.push_repository(announcement, cx)); - - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - let result = task.await; - this.update_in(cx, |this, _window, cx| { - if let Err(error) = result { - this.error = Some(format!("Push failed: {error}").into()); - } - this.pushing = false; - cx.notify(); - })?; - Ok(()) - })); + self.tasks + .push(store.update(cx, |store, cx| store.push_repository(cx))); } - /// Delete the repository from nostr (announcement, state and activity); - /// only offered to the repository owner. The sidebar list updates when - /// the deletion events arrive. - fn delete_repository(&mut self, window: &mut Window, cx: &mut Context) { - let Some(announcement) = self.announcement(cx).cloned() else { + /// Push the unpushed commits of the local checkout at `path`. + fn push_unpushed_checkout( + &mut self, + path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + let Some(store) = self.store.clone() else { return; }; - let backend = Backend::global(cx); - let task = backend.update(cx, |backend, cx| { - backend.delete_repository(announcement.addr(), cx) + + if store.read(cx).pushing { + return; + } + + self.error = None; + cx.notify(); + + let task = cx.spawn_in(window, async move |this, cx| { + // The store owns the push, its busy flag and error reporting. + let push = this.update_in(cx, |_this, _window, cx| { + store.update(cx, |store, cx| store.push_checkout(path.clone(), cx)) + })?; + + // The remote moved, refresh the mirror browsing. + // Failures already surfaced in the store's error banner. + if let Ok(()) = push.await { + this.update_in(cx, |this, window, cx| { + this.load_repo(window, cx); + })?; + } + + Ok(()) }); - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - let result = task.await; - this.update_in(cx, |this, _window, cx| { - if let Err(error) = result { - this.error = Some(format!("Delete failed: {error}").into()); - } - cx.notify(); - })?; - Ok(()) - })); + self.tasks.push(task); } - /// Open the issues panel at the bottom of the dock area. + /// Delete the repository from nostr, announcement, state and activity. + fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context) { + let Some(store) = self.store.clone() else { + return; + }; + self.tasks + .push(store.update(cx, |store, cx| store.delete_repository(cx))); + } + + /// Open the issues list panel in the dock area. fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) { let Some(store) = self.store.clone() else { return; @@ -934,22 +973,14 @@ impl RepoDetailView { return; }; - let panel = cx.new(|cx| { - IssuesView::new( - self.dock_area.clone(), - store, - self.display_name(cx), - window, - cx, - ) - }); + let panel = cx.new(|cx| IssuesView::new(self.dock_area.clone(), store, window, cx)); dock_area.update(cx, |dock_area, cx| { - dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx); + add_center_panel(dock_area, panel_handle(panel), window, cx); }); } - /// Open the pull requests panel at the bottom of the dock area. + /// Open the pull requests list panel in the dock area. fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context) { let Some(store) = self.store.clone() else { return; @@ -958,24 +989,16 @@ impl RepoDetailView { return; }; - let panel = cx.new(|cx| { - PullRequestsView::new( - self.dock_area.clone(), - store, - self.display_name(cx), - window, - cx, - ) - }); + let panel = cx.new(|cx| PullRequestsView::new(self.dock_area.clone(), store, window, cx)); dock_area.update(cx, |dock_area, cx| { - dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx); + add_center_panel(dock_area, panel_handle(panel), window, cx); }); } - /// Open the upstream repository (the `u` tag of this fork's announcement). - /// When the upstream announcement is not in the local database yet, - /// subscribe for it and open the panel as soon as it lands. + /// Open the upstream repository, the `u` tag of this fork's announcement. + /// The upstream announcement may not be in the local database yet. + /// Subscribe for it and open the panel as soon as it lands. fn open_upstream(&mut self, window: &mut Window, cx: &mut Context) { if self.pending_upstream.is_some() { return; @@ -1044,8 +1067,8 @@ impl RepoDetailView { self.tasks.push(task); } - /// Check out `name` (a branch or tag picked in the header) and refresh - /// the explorer once the switch completes. + /// Check out `name`, a branch or tag picked in the header. + /// Refresh the explorer once the switch completes. fn switch_ref( &mut self, kind: RefKind, @@ -1060,9 +1083,9 @@ impl RepoDetailView { return; }; - // Branches and tags are mutually exclusive states of HEAD: selecting - // one clears the other selector. Remember the previous selections so - // they can be restored if the checkout fails. + // Branches and tags are mutually exclusive states of HEAD. + // Selecting one clears the other selector. + // Remember the previous selections to restore them if the checkout fails. let previous_branch = self.branch_select.read(cx).selected_value(); let previous_tag = self.tag_select.read(cx).selected_value(); @@ -1077,8 +1100,7 @@ impl RepoDetailView { } } self.switching_ref = true; - // In-flight loads of the previous branch are discarded when they - // complete. + // In-flight loads of the previous branch are discarded when they complete. self.ref_generation += 1; cx.notify(); @@ -1116,7 +1138,7 @@ impl RepoDetailView { self.tasks.push(task); } - /// Restore a selector to `previous`, or clear it (after a failed switch). + /// Restore a selector to `previous`, or clear it after a failed switch. fn restore_selection( &self, select: &Entity>>, @@ -1130,46 +1152,10 @@ impl RepoDetailView { }); } - /// Trigger body for the branch/tag selectors: the kind icon, the - /// selection (or placeholder) and the caret. `Combobox` replaces its - /// default trigger entirely, the only way to show an icon inside it. - fn render_ref_trigger( - ctx: &ComboboxTriggerContext>, - icon: CustomIconName, - cx: &App, - ) -> AnyElement { - let muted = cx.theme().muted_foreground; - - h_flex() - .w_full() - .min_w_0() - .gap_1() - .items_center() - .child(Icon::new(icon).small().flex_shrink_0()) - .child( - div() - .flex_1() - .min_w_0() - .overflow_hidden() - .text_ellipsis() - .whitespace_nowrap() - .when(ctx.selection().is_empty(), |this| this.text_color(muted)) - .child( - ctx.selection() - .first() - .map(|(_, item)| item.clone()) - .or_else(|| ctx.placeholder().cloned()) - .unwrap_or_default(), - ), - ) - .child(Caret::new(ctx.size()).text_color(muted)) - .into_any_element() - } - - /// Refresh the file explorer, preview pane and commit list after a - /// successful branch or tag switch. The selectors were already updated - /// by [`Self::switch_ref`]; [`Self::switching_ref`] stays set until this - /// reload finishes, so a second switch cannot interleave. + /// Refresh the file explorer, preview pane and commit list after a successful switch. + /// The selectors were already updated by [`Self::switch_ref`]. + /// [`Self::switching_ref`] stays set until this reload finishes. + /// A second switch cannot interleave. fn reload_worktree(&mut self, cx: &mut Context) { let Some(worktree) = self.worktree.clone() else { return; @@ -1190,9 +1176,9 @@ impl RepoDetailView { match result { Ok((snapshot, tree)) => { this.head_commit = snapshot.head_commit; - // Rebuild the tree from scratch: entries of the - // previous branch are gone, and with them the - // expansion state. + // Rebuild the tree from scratch. + // Entries of the previous branch are gone. + // The expansion state goes with them. this.tree_state.update(cx, |state, cx| { state.set_items(tree_items(tree, false), cx); }); @@ -1239,9 +1225,10 @@ impl RepoDetailView { self.tasks.push(task); } - /// Drop the oldest previews beyond the cache caps, keeping the currently - /// selected file. The parsed editor state of an evicted file is dropped - /// along with its entry, so re-opening it re-parses on a background task. + /// Drop the oldest previews beyond the cache caps. + /// Keep the currently selected file. + /// An evicted file's parsed editor state drops with its entry. + /// Re-opening it re-parses on a background task. fn evict_previews(&mut self) { while (self.files.len() > MAX_PREVIEWED_FILES || self.preview_bytes > MAX_PREVIEW_CACHE_BYTES) @@ -1269,7 +1256,7 @@ impl RepoDetailView { } } - /// The latest announcement from the store, or the open-time snapshot; + /// The latest announcement from the store or the open-time snapshot. /// `None` for local repositories that haven't been published yet. fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> { let store = self.store.as_ref()?; @@ -1280,8 +1267,8 @@ impl RepoDetailView { .or(self.initial.as_ref()) } - /// Display name: the announcement's name (or ID) for announced - /// repositories, the directory name for local ones. + /// Display name, the announcement's name or ID for announced repositories. + /// The directory name for local ones. fn display_name(&self, cx: &App) -> SharedString { if let Some(path) = &self.local_path { return SharedString::from( @@ -1294,15 +1281,15 @@ impl RepoDetailView { .map(|announcement| { announcement .name - .clone() + .as_deref() + .map(SharedString::from) .unwrap_or_else(|| SharedString::from(announcement.id.clone())) }) .unwrap_or_default() } - /// The NIP-34 header (actions, issues/PR counts) or, for a local - /// repository that hasn't been published yet, the local header with an - /// Init button. + /// The NIP-34 header, actions and issues/PR counts. + /// Or the local header with an Init button for an unpublished repository. fn render_header(&mut self, cx: &mut Context) -> AnyElement { if self.local_path.is_some() { return self.render_local_header(cx); @@ -1311,17 +1298,21 @@ impl RepoDetailView { let Some(store_entity) = self.store.as_ref() else { return div().into_any_element(); }; + let store = store_entity.read(cx); let issue_count = SharedString::from(store.issue_count().to_string()); let pr_count = SharedString::from(store.pull_request_count().to_string()); + // Busy flags are owned by the store; observers re-render on their changes. + let pushing = store.pushing; + let cloning = store.cloning; + let Some(source) = store.announcement.as_ref().or(self.initial.as_ref()) else { return div().into_any_element(); }; - // The header derives bech32 share targets and clone command strings - // from the announcement; rebuild them only when the announcement or - // the owner's NIP-05 changes, not on every render. + // Derived NIP-34 header data, share targets and clone commands. + // Rebuilt per frame: two bech32 encodes and a couple of format strings. let nip05 = ProfileStore::global(cx) .read(cx) .get(&source.owner) @@ -1329,32 +1320,14 @@ impl RepoDetailView { .nip05 .clone() .filter(|nip05| !nip05.trim().is_empty()); - let key = (source.event_id, nip05); - if self - .header_cache - .as_ref() - .is_none_or(|cache| cache.key != key) - { - let announcement = source.clone(); - let share = ShareTargets::from_announcement(&announcement); - let nostr_url = nostr_clone_url(&announcement, key.1.as_deref()); - self.header_cache = Some(HeaderCache { - ngit_command: SharedString::from(format!("git clone {nostr_url}")), - nak_command: SharedString::from(format!("nak git clone {nostr_url}")), - git_commands: Rc::new(announcement.clone_urls()), - share: Rc::new(share), - announcement: Rc::new(announcement), - key, - }); - } + let announcement = Rc::new(source.clone()); + let share = Rc::new(ShareTargets::from_announcement(&announcement)); - let cache = self.header_cache.as_ref().expect("cache just built"); - let announcement = cache.announcement.clone(); - let share = cache.share.clone(); - let ngit_command = cache.ngit_command.clone(); - let nak_command = cache.nak_command.clone(); - let git_commands = cache.git_commands.clone(); + let nostr_url = nostr_clone_url(&announcement, nip05.as_deref()); + let ngit_command = SharedString::from(format!("git clone {nostr_url}")); + let nak_command = SharedString::from(format!("nak git clone {nostr_url}")); + let git_commands = Rc::new(announcement.clone_urls()); let name = self.display_name(cx); let description = announcement.description(); @@ -1387,8 +1360,7 @@ impl RepoDetailView { RepoAction::Delete => this.delete_repository(window, cx), }), ) - .px_4() - .pb_4() + .p_4() .w_full() .gap_8() .border_b_1() @@ -1561,8 +1533,8 @@ impl RepoDetailView { .tooltip("Repository management") .compact() .secondary() - .loading(self.pushing) - .disabled(self.pushing) + .loading(pushing) + .disabled(pushing) .dropdown_menu(move |menu, _, cx| { let backend = Backend::global(cx); let current_user = backend.read(cx).current_user(); @@ -1613,8 +1585,8 @@ impl RepoDetailView { Button::new("clone") .icon(CustomIconName::GitClone) .tooltip("Clone") - .loading(self.cloning) - .disabled(self.cloning) + .loading(cloning) + .disabled(cloning) .primary(), ) .content(move |_, _window, cx| { @@ -1708,9 +1680,8 @@ impl RepoDetailView { .into_any_element() } - /// Header for a local (not yet published) repository: the directory - /// name and path with an Init button instead of the NIP-34 actions - /// (issues, pull requests, share, info, clone). + /// Header for a local, not yet published, repository. + /// The directory name and path with an Init button instead of the NIP-34 actions. fn render_local_header(&self, cx: &mut Context) -> AnyElement { let name = self.display_name(cx); let path = self @@ -1772,8 +1743,7 @@ impl RepoDetailView { .into_any_element() } - /// Open the dialog guiding the user through publishing the local - /// repository to NIP-34. + /// Open the dialog guiding the user through publishing the local repository to NIP-34. fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context) { let Some(local_path) = self.local_path.clone() else { return; @@ -1782,32 +1752,350 @@ impl RepoDetailView { init_dialog::open(local_path, view, window, cx); } - /// Switch the repository into its NIP-34 mode after a successful init: - /// create the nostr store for the announced repository and drop the - /// local (scan) identity. The worktree is unchanged, so the file - /// explorer keeps its loaded content. + /// Switch the repository into its NIP-34 mode after a successful init. + /// Creates the nostr store for the announced repository. + /// Drops the local scan identity. + /// The worktree is unchanged, so the explorer keeps its loaded content. pub(crate) fn apply_announcement( &mut self, announcement: Announcement, cx: &mut Context, ) { - // The repository is no longer a bare local repo: drop it from the - // scan results so it leaves the sidebar's local section immediately. + // The repository is no longer a bare local repo. + // Drop it from the scan results so it leaves the sidebar's local section. if let Some(path) = self.local_path.take() { LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx)); } let store = cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)); - // Re-render when the store refreshes (issues, PRs, statuses). - self._subscriptions - .push(cx.observe(&store, |_this, _store, cx| cx.notify())); + // Re-render on store refreshes, issues, PRs and statuses. + // Keep the ready-to-contribute statuses of this repository requested. + self.attach_store(&store, cx); self.store = Some(store); self.initial = Some(announcement); cx.notify(); } - /// The tab row shared by both header variants: Files/Commits tabs, the - /// HEAD commit button and the branch/tag selectors. + /// Observe the repository's store, re-render on refreshes. + /// Request the ready-to-contribute statuses for it. + fn attach_store(&mut self, store: &Entity, cx: &mut Context) { + self._subscriptions + .push(cx.observe(store, |this, _store, cx| { + this.refresh_ready_statuses(cx); + cx.notify(); + })); + self.refresh_ready_statuses(cx); + } + + /// Request the statuses of this repository again when the announced HEAD changes. + /// The HEAD is the base the checkouts are compared against. + /// Owned repositories are watched for unpushed commits. + /// Other repositories for ready-to-contribute checkouts. + fn refresh_ready_statuses(&mut self, cx: &mut Context) { + let Some(entity) = self.store.clone() else { + return; + }; + + let head = entity.read(cx).head.clone(); + + if self.ready_requested && self.ready_head == head { + return; + } + + self.ready_requested = true; + self.ready_head = head.clone(); + + let addr = entity.read(cx).addr().clone(); + let backend = Backend::global(cx); + let checkout = CheckoutsStore::global(cx); + + let owned = backend + .read(cx) + .current_user() + .is_some_and(|user| entity.read(cx).is_author(&user)); + + checkout.update(cx, |store, cx| { + // The ready statuses keep the fast poll running while the panel is open. + // The sidebar's push watch alone polls slower. + store.request_statuses(&addr, head, cx); + + if owned { + store.request_push_statuses(&addr, cx); + } + }); + } + + /// The ready-to-push statuses of this repository in the global checkouts + /// store changed since they last drove a render. + /// + /// Updates the cached slices. `None` store (a local, not yet published, + /// repository) has no statuses. + fn refresh_statuses(&mut self, cx: &mut Context) -> bool { + let Some(entity) = self.store.clone() else { + return false; + }; + let addr = entity.read(cx).addr().clone(); + let checkouts = CheckoutsStore::global(cx).read(cx); + let ready_statuses = checkouts.ready_statuses_of(&addr); + let push_statuses = checkouts.push_statuses_of(&addr); + + let changed = ready_statuses != self.ready_statuses || push_statuses != self.push_statuses; + self.ready_statuses = ready_statuses; + self.push_statuses = push_statuses; + changed + } + + /// The first checkout ready for a pull request on this repository. + /// Not covered by an open PR of the signed-in user. + /// Not dismissed in this panel. + /// The repository's own checkouts are not suggested here. + /// Their work is pushed, see [`Self::push_suggestion`]. + fn ready_suggestion(&self, cx: &App) -> Option { + let store = self.store.as_ref()?; + let addr = store.read(cx).addr().clone(); + let user = Backend::global(cx).read(cx).current_user()?; + if store.read(cx).is_author(&user) { + return None; + } + + let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(&addr); + + 'status: for status in statuses { + if self + .banner_dismissed + .contains(&(status.path.clone(), status.branch.clone())) + { + continue; + } + let store = store.read(cx); + for pr in &store.pull_requests { + if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status) + { + continue 'status; + } + } + return Some(status); + } + + None + } + + /// The first checkout of this owned repository with unpushed commits. + /// + /// Not dismissed in this panel. + fn push_suggestion(&self, cx: &App) -> Option { + let entity = self.store.as_ref()?; + let user = Backend::global(cx).read(cx).current_user()?; + + if !entity.read(cx).is_author(&user) { + return None; + } + + let addr = entity.read(cx).addr().clone(); + let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(&addr); + + statuses.into_iter().find(|status| { + !self + .banner_dismissed + .contains(&(status.path.clone(), status.branch.clone())) + }) + } + + /// The ready-to-push banner of an owned repository. + /// + /// A local checkout has unpushed commits, with a Push action and a dismiss control. + fn render_push_banner(&self, cx: &Context) -> Option { + let status = self.push_suggestion(cx)?; + let key = (status.path.clone(), status.branch.clone()); + let path = status.path.clone(); + // The push busy flag lives on the store; it disables the banner's triggers. + let pushing = self + .store + .as_ref() + .is_some_and(|store| store.read(cx).pushing); + + let commits = if status.ahead == 1 { + SharedString::from("1 commit") + } else { + SharedString::from(format!("{} commits", status.ahead)) + }; + + Some( + h_flex() + .p_4() + .gap_2() + .w_full() + .items_center() + .justify_between() + .bg(cx.theme().muted) + .child( + h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().info) + .child( + h_flex() + .px_1() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().info) + .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) + .text_xs() + .font_semibold() + .font_family(cx.theme().mono_font_family.clone()) + .child(status.branch), + ) + .child("has") + .child( + h_flex() + .px_1() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().info) + .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) + .text_xs() + .font_semibold() + .font_family(cx.theme().mono_font_family.clone()) + .child(commits), + ) + .child("ready to push"), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("push-checkout-banner") + .icon(IconName::ArrowUp) + .label("Push") + .small() + .info() + .loading(pushing) + .disabled(pushing) + .on_click(cx.listener(move |this, _event, window, cx| { + this.push_unpushed_checkout(path.clone(), window, cx); + })), + ) + .child( + Button::new("close-repo") + .icon(IconName::Close) + .tooltip("Dismiss") + .small() + .ghost() + .disabled(pushing) + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.banner_dismissed.insert(key.clone()); + cx.notify(); + })), + ), + ) + .into_any_element(), + ) + } + + /// The ready-to-contribute banner of the repository panel. + /// + /// A checkout has commits ahead of its base branch, with a Create action + /// opening the prefilled New PR panel, and a dismiss control. + fn render_ready_banner(&self, cx: &Context) -> Option { + let status = self.ready_suggestion(cx)?; + let key = (status.path.clone(), status.branch.clone()); + + let commits = if status.ahead == 1 { + SharedString::from("1 commit") + } else { + SharedString::from(format!("{} commits", status.ahead)) + }; + + Some( + h_flex() + .p_4() + .gap_2() + .w_full() + .items_center() + .justify_between() + .bg(cx.theme().muted) + .child( + h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().info) + .child( + h_flex() + .px_1() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().info) + .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) + .text_xs() + .font_semibold() + .font_family(cx.theme().mono_font_family.clone()) + .child(status.branch), + ) + .child("is") + .child( + h_flex() + .px_1() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().info) + .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) + .text_xs() + .font_semibold() + .font_family(cx.theme().mono_font_family.clone()) + .child(commits), + ) + .child("ahead of") + .child( + h_flex() + .px_1() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().info) + .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) + .text_xs() + .font_semibold() + .font_family(cx.theme().mono_font_family.clone()) + .child(status.base), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("create-pr-from-banner") + .icon(IconName::Plus) + .label("Create") + .small() + .info() + .on_click(cx.listener(|this, _event, window, cx| { + if let Some(store) = this.store.clone() { + open_new_pull_panel( + this.dock_area.clone(), + store, + window, + cx, + ); + } + })), + ) + .child( + Button::new("dismiss-ready-banner") + .icon(IconName::Close) + .tooltip("Dismiss") + .small() + .ghost() + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.banner_dismissed.insert(key.clone()); + cx.notify(); + })), + ), + ) + .into_any_element(), + ) + } + + /// The tab row shared by both header variants. + /// Files and Commits tabs, the HEAD commit button and the branch/tag selectors. fn render_header_tabs(&self, cx: &mut Context) -> AnyElement { let commits_count = self.all_commits.as_ref().map(|list| list.total); let worktree_empty = self.switching_ref || self.worktree.is_none(); @@ -1857,19 +2145,7 @@ impl RepoDetailView { .child("Commits"), ) .when_some(commits_count, |this, count| { - this.child( - h_flex() - .justify_center() - .px_1() - .py_0p5() - .min_w_4() - .text_size(px(8.)) - .bg(cx.theme().muted) - .text_color(cx.theme().muted_foreground) - .rounded(cx.theme().radius) - .line_height(relative(1.)) - .child(SharedString::from(count.to_string())), - ) + this.child(CountBadge::new(count)) }) .text_color(cx.theme().button_foreground) .rounded(cx.theme().radius) @@ -1933,7 +2209,7 @@ impl RepoDetailView { .bg(cx.theme().muted) .rounded(cx.theme().radius) .render_trigger(|ctx, _window, cx| { - Self::render_ref_trigger(ctx, CustomIconName::GitBranch, cx) + ref_selector_trigger(ctx, CustomIconName::GitBranch, cx) }), ), ) @@ -1947,7 +2223,7 @@ impl RepoDetailView { .bg(cx.theme().muted) .rounded(cx.theme().radius) .render_trigger(|ctx, _window, cx| { - Self::render_ref_trigger(ctx, CustomIconName::Tag, cx) + ref_selector_trigger(ctx, CustomIconName::Tag, cx) }), ), ), @@ -2031,17 +2307,33 @@ impl Render for RepoDetailView { .or_else(|| self.readme_name.clone()) .unwrap_or_else(|| "Overview".into()); + let banner = self + .render_ready_banner(cx) + .or_else(|| self.render_push_banner(cx)); + + // View-level load/switch errors, plus the errors of the store-owned + // operations, republish, checkout push, delete and clone-to-folder. + let error = self.error.clone().or_else(|| { + self.store + .as_ref() + .and_then(|store| store.read(cx).last_error.clone().map(SharedString::from)) + }); + v_flex() - .image_cache(image_cache("repo", MAX_IMAGES)) + .image_cache(gpui::retain_all("repo")) .id("repo") .size_full() + .when_some(banner, |this, banner| this.child(banner)) .child(self.render_header(cx)) - .when_some(self.error.clone(), |this, error| { + .when_some(error, |this, error| { this.child( Alert::error("repo-error", error) .banner() .on_close(cx.listener(|this, _event, _window, cx| { this.error = None; + if let Some(store) = this.store.clone() { + store.update(cx, |store, _| store.last_error = None); + } cx.notify(); })), ) @@ -2061,8 +2353,9 @@ impl Render for RepoDetailView { } } -/// Read the worktree state of `repo` (no network): entries, README, refs -/// and HEAD commit. +/// Read the worktree state of `repo`, no network. +/// +/// Entries, README, refs and HEAD commit. fn load_repo_data(repo: &Repository) -> Result { let entries = signed_git::worktree_entries(repo)?; let tree = build_tree_items(&entries); @@ -2072,8 +2365,9 @@ fn load_repo_data(repo: &Repository) -> Result { None => None, }; let worktree = repo.workdir().map(Path::to_path_buf); - // Ref listing is auxiliary UI: a broken ref must not prevent the - // explorer from loading, so failures degrade to empty selectors. + // Ref listing is auxiliary UI. + // A broken ref must not prevent the explorer from loading. + // Failures degrade to empty selectors. let (branches, tags, current_branch) = match &worktree { Some(_) => ( signed_git::repo_branches(repo).unwrap_or_default(), @@ -2096,15 +2390,12 @@ fn load_repo_data(repo: &Repository) -> Result { }) } -/// The `nostr://...` clone URL of an announcement (NIP-34): the owner as a -/// NIP-05 identifier when known (npub otherwise), the first announced relay -/// as a hint, and the repository identifier. `nip05` is the owner's -/// NIP-05 identifier from the profile store, already blank-filtered. +/// The `nostr://...` clone URL of an announcement, NIP-34. fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString { let owner = announcement.owner; let user = nip05 .map(str::to_owned) - .unwrap_or_else(|| owner.to_bech32().unwrap_or_else(|_| owner.to_hex())); + .unwrap_or_else(|| owner.to_bech32().unwrap()); let mut url = format!("nostr://{user}"); if let Some(hint) = announcement.relays.first().and_then(RelayUrl::domain) { @@ -2117,16 +2408,17 @@ fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedSt SharedString::from(url) } -/// The "Forked from …" row of the detail header: a clickable link to the -/// upstream repository when the `u` tag references a NIP-34 repo, -/// plain text when it only carries a git URL. +/// The forked-from row of the detail header. +/// +/// Clickable link to the upstream repository when the `u` tag references a NIP-34 repo. +/// Plain text when it only carries a git URL. fn fork_row(announcement: &Announcement, cx: &mut Context) -> Option { let upstream = announcement.upstream.as_ref()?; let (label, clickable) = match &upstream.addr { Some(addr) => { - // Prefer the upstream's display name when its announcement - // is already known locally fall back to its repository id. + // Prefer the upstream's display name when its announcement is known locally. + // Fall back to its repository id otherwise. let name = RepoListStore::global(cx) .read(cx) .announcements @@ -2134,13 +2426,14 @@ fn fork_row(announcement: &Announcement, cx: &mut Context) -> Op .find(|a| a.addr() == *addr) .map(|a| { a.name - .clone() + .as_deref() + .map(SharedString::from) .unwrap_or_else(|| SharedString::from(a.id.clone())) }) .unwrap_or_else(|| SharedString::from(addr.identifier.clone())); (SharedString::from(format!("Forked from {name}")), true) } - None => (upstream.display(), false), + None => (SharedString::from(upstream.display().as_str()), false), }; let row = h_flex() @@ -2163,9 +2456,7 @@ fn fork_row(announcement: &Announcement, cx: &mut Context) -> Op }) } -/// Open `announcement` as a repository panel in the dock's center, returning -/// the new detail view. Shared by the explore list, the sidebar and fork -/// links so every entry point opens repositories identically. +/// Open `announcement` as a repository panel in the dock's center. pub(crate) fn open_repo_panel( dock_area: &WeakEntity, announcement: &Announcement, @@ -2177,13 +2468,7 @@ pub(crate) fn open_repo_panel( if let Some(dock_area) = dock_area.upgrade() { dock_area.update(cx, |dock_area, cx| { - dock_area.add_panel_view( - panel_handle(detail.clone()), - DockPlacement::Center, - None, - window, - cx, - ); + add_center_panel(dock_area, panel_handle(detail.clone()), window, cx); }); } diff --git a/crates/workspace/src/views/repo_detail/new_pull_request.rs b/crates/workspace/src/views/repo_detail/new_pull_request.rs index 6b7ef6c..6512ac4 100644 --- a/crates/workspace/src/views/repo_detail/new_pull_request.rs +++ b/crates/workspace/src/views/repo_detail/new_pull_request.rs @@ -1,14 +1,8 @@ -//! The "new pull request" panel: pick a local checkout, a base and a -//! compare branch (GitHub-style), review the diff and the commit list, then -//! publish the PR with only a title and an optional description. The patch -//! series is generated from the checkout at submit time; there is no patch -//! input. - use std::path::{Path, PathBuf}; use std::rc::Rc; use assets::CustomIconName; -use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; +use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle}; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, PathPromptOptions, @@ -17,10 +11,9 @@ use gpui::{ }; use gpui_base::{Button as BaseButton, StyledExt}; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::combobox::{ - Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext, -}; -use gpui_component::input::{Input, InputEvent, InputState, Textarea, TextareaState}; +use gpui_component::combobox::{Combobox, ComboboxEvent, ComboboxState}; +use gpui_component::input::{Input, InputState, Textarea, TextareaState}; +use gpui_component::menu::{DropdownMenu, PopupMenu, PopupMenuItem}; use gpui_component::scroll::Scrollbar; use gpui_component::searchable_list::SearchableVec; use gpui_component::spinner::Spinner; @@ -28,45 +21,45 @@ use gpui_component::{ ActiveTheme, Disableable, Icon, IconName, Sizable, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; +use nostr::prelude::*; +use signed_core::{Announcement, RepoAddr}; use signed_git::{ - format_patch_between, merge_base, worktree_commit_range_commits, worktree_commit_range_diff, + delete_refs_with_prefix, fetch_repo_refs, format_patch_between, merge_base, refs_with_prefix, + sanitize_path_component, worktree_commit_range_commits, worktree_commit_range_diff, }; -use signed_state::RepoStore; -use signed_ui::placeholder; +use signed_state::{Backend, CheckoutsStore, GitStore, RepoListStore, RepoStore}; +use signed_ui::{CountBadge, placeholder}; use super::commits::{COMMIT_ROW_HEIGHT, commit_row}; use super::diff::{CommitDiffView, DiffPane}; +use super::helpers::ref_selector_trigger; -/// The "new pull request" panel of a repository. -/// -/// Both branch selectors list the branches of a user-chosen local checkout; -/// the compare view (Files/Commits tabs) is built from `merge-base..compare` -/// in that checkout, and the patch series published with the PR is generated -/// from the same range at submit time. +/// The new pull request panel of a repository. pub struct NewPullRequestView { focus_handle: FocusHandle, - /// Dock area the panel lives in; commit diffs are opened there. + /// Dock area the panel lives in, commit diffs are opened there. dock_area: WeakEntity, - /// Store of the target repository (for the announced HEAD default). + /// Store of the target repository, source of the announced HEAD default. store: Entity, /// Display name of the repository, for the panel title. repo_name: SharedString, - /// The user's checkout: where both branches live and where the tip is - /// pushed from. + /// The user's local checkout. repo_path: Option, - /// Branches of the checkout, backing both selectors. + /// Branches of the checkout, backing both selectors in checkout mode. branches: Vec, - /// Selected base branch (the target of the PR). + /// Fork-backed compare state. + fork: Option, + /// Selected base branch, the PR target, stored as a short name. base: SharedString, - /// Selected compare branch (the source of the PR). + /// Selected compare branch, the PR source, stored as a short name. compare: SharedString, base_select: Entity>>, compare_select: Entity>>, - /// Title input (required). + /// Title input, required. subject: Entity, - /// Description input (optional). + /// Description input, optional. description: Entity, - /// Merge base of the selected branches; `None` until the compare loads. + /// Merge base of the selected branches, `None` until the compare loads. merge_base: Option, /// Commits in `merge_base..compare`, newest first. commits: Option>, @@ -74,13 +67,13 @@ pub struct NewPullRequestView { loading: bool, /// Error of the last compare or submit attempt. error: Option, - /// A submit (patch generation + publish) is in flight. + /// A submit, patch generation and publish, is in flight. submitting: bool, - /// Bumped on every branch switch; stale compare results are discarded. + /// Bumped on every branch switch, stale compare results are discarded. compare_generation: u64, - /// Active tab: 0 = Files, 1 = Commits. + /// Active tab, 0 = Files and 1 = Commits. active_tab: usize, - /// The compare diff (Files tab). + /// The compare diff, the Files tab body. pane: Entity, /// Virtual list state of the Commits tab. scroll_handle: VirtualListScrollHandle, @@ -89,6 +82,207 @@ pub struct NewPullRequestView { tasks: Vec>>, } +/// A fork-backed compare. +struct ForkCompare { + /// Fork announcement the compare branch is imported from. + announcement: Announcement, + /// Import namespace of the form `/`. + namespace: String, + /// Path of the target repository's GitCache mirror. + mirror_path: PathBuf, +} + +impl ForkCompare { + /// The full ref of the base branch `name` in the mirror. + fn base_ref(name: &str) -> String { + format!("refs/remotes/origin/{name}") + } + + /// The full ref of the compare branch `name` in the mirror. + fn compare_ref(&self, name: &str) -> String { + format!("refs/fork/{}/{}", self.namespace, name) + } +} + +/// The refs namespace of a fork's import in the target mirror. +fn fork_namespace(announcement: &Announcement) -> String { + format!( + "{}/{}", + announcement.owner.to_hex(), + sanitize_path_component(&announcement.id) + ) +} + +/// The announced forks of `base` a New PR compare can be built from. +fn fork_candidates<'a>( + announcements: &'a [Announcement], + base: &RepoAddr, + base_euc: Option<&str>, + user: Option, +) -> Vec<&'a Announcement> { + let (mut own, mut others) = (Vec::new(), Vec::new()); + for announcement in announcements { + if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) { + continue; + } + if Some(announcement.owner) == user { + own.push(announcement); + } else { + others.push(announcement); + } + } + own.into_iter().chain(others).collect() +} + +/// The display name of an announcement. +/// +/// Its human-readable name, falling back to the repository id. +fn fork_display_name(announcement: &Announcement) -> SharedString { + announcement + .name + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(announcement.id.clone())) +} + +/// A short label of a fork's owner for the source picker, a hex prefix. +fn shorten_owner(owner: &PublicKey) -> String { + let hex = owner.to_hex(); + hex.chars().take(10).collect() +} + +/// Truncate a label for the fixed-width controls of the compare bar. +fn truncate_label(label: &str) -> SharedString { + const MAX: usize = 18; + let mut chars = label.chars(); + let (prefix, rest) = (chars.by_ref().take(MAX).collect::(), chars.next()); + let label = if rest.is_some() { + format!("{}…", &prefix[..prefix.len().saturating_sub(1)]) + } else { + prefix + }; + SharedString::from(label) +} + +/// The compare-source menu entry of one local checkout folder. +/// +/// Applies the folder directly, no picker. +fn checkout_source_item( + view: WeakEntity, + path: PathBuf, + active: bool, +) -> PopupMenuItem { + let subtitle = path.display().to_string(); + let title = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| subtitle.clone()); + PopupMenuItem::element(move |_window, cx| { + source_row( + IconName::Folder, + truncate_label(&title), + truncate_label(&subtitle), + cx, + ) + }) + .checked(active) + .on_click(move |_event, window, cx| { + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| { + this.apply_folder_path(path.clone(), window, cx) + }); + } + }) +} + +/// The compare-source menu entry prompting for an arbitrary folder. +fn choose_folder_source_item(view: WeakEntity) -> PopupMenuItem { + PopupMenuItem::element(move |_window, cx| { + source_row( + IconName::FolderOpen, + "Choose another folder…", + "Pick any local checkout", + cx, + ) + }) + .on_click(move |_event, window, cx| { + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| this.choose_checkout(window, cx)); + } + }) +} + +/// The compare-source menu entry of one announced fork. +/// +/// Imports its branches into the target's mirror and switches the panel to fork mode. +fn fork_source_item( + view: WeakEntity, + announcement: Announcement, + subtitle: SharedString, + _active: bool, +) -> PopupMenuItem { + let title = truncate_label(&fork_display_name(&announcement)); + + PopupMenuItem::element(move |_window, cx| { + source_row( + CustomIconName::GitBranch, + title.clone(), + truncate_label(&subtitle), + cx, + ) + }) + .on_click(move |_event, window, cx| { + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| { + this.choose_fork(announcement.clone(), window, cx) + }); + } + }) +} + +/// One row of the compare-source menu, icon, title and a muted subtitle. +fn source_row(icon: impl Into, title: T, subtitle: T, cx: &App) -> AnyElement +where + T: Into, +{ + let title = title.into(); + let subtitle = subtitle.into(); + + h_flex() + .gap_2() + .w_full() + .min_w_0() + .items_center() + .child(Icon::new(icon).small().flex_shrink_0()) + .child( + v_flex() + .min_w_0() + .flex_1() + .child( + div() + .w_full() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .text_sm() + .line_height(relative(1.25)) + .child(title), + ) + .child( + div() + .w_full() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .text_xs() + .text_color(cx.theme().muted_foreground) + .line_height(relative(1.25)) + .child(subtitle), + ), + ) + .into_any_element() +} + impl NewPullRequestView { pub fn new( dock_area: WeakEntity, @@ -101,7 +295,7 @@ impl NewPullRequestView { let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title")); let description = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe...")); - let base_select: Entity>> = cx.new(|cx| { + let base_select = cx.new(|cx| { ComboboxState::new( SearchableVec::new(Vec::::new()), Vec::new(), @@ -111,7 +305,7 @@ impl NewPullRequestView { .searchable(true) }); - let compare_select: Entity>> = cx.new(|cx| { + let compare_select = cx.new(|cx| { ComboboxState::new( SearchableVec::new(Vec::::new()), Vec::new(), @@ -122,11 +316,6 @@ impl NewPullRequestView { }); let subscriptions = vec![ - // Re-evaluate the Create button's enabled state as the title - // changes. - cx.subscribe(&subject, |_this, _state, _event: &InputEvent, cx| { - cx.notify(); - }), cx.subscribe_in(&base_select, window, |this, _state, event, window, cx| { if let ComboboxEvent::Change(values) = event && let Some(name) = values.first() @@ -149,13 +338,14 @@ impl NewPullRequestView { ), ]; - Self { + let mut view = Self { focus_handle: cx.focus_handle(), dock_area, store, repo_name, repo_path: None, branches: Vec::new(), + fork: None, base: SharedString::default(), compare: SharedString::default(), base_select, @@ -174,12 +364,60 @@ impl NewPullRequestView { item_sizes: Rc::new(Vec::new()), _subscriptions: subscriptions, tasks: Vec::new(), + }; + + // Prefill with the store's freshest associated checkout, no folder dialog. + let addr = view.store.read(cx).addr().clone(); + if let Some(path) = CheckoutsStore::global(cx) + .read(cx) + .associations_of(&addr) + .into_iter() + .next() + { + view.apply_folder_path(path, window, cx); + } + + view + } + + /// Whether a compare source, a checkout or a fork, is applied. + fn has_source(&self) -> bool { + self.repo_path.is_some() || self.fork.is_some() + } + + /// The path git ops run against. + /// + /// The target's mirror in fork mode, the user's checkout otherwise. + fn work_path(&self) -> Option { + match &self.fork { + Some(fork) => Some(fork.mirror_path.clone()), + None => self.repo_path.clone(), } } - /// Prompt for a local checkout; on success populate the branch selectors - /// (defaults: the announced HEAD branch for the base, the checkout's - /// current branch for the compare) and load the compare. + /// The full ref the selected base branch resolves to. + /// The mirror's remote-tracking ref in fork mode. + /// + /// The plain branch name in checkout mode, git resolves it through `refs/heads`. + fn base_ref(&self) -> String { + match &self.fork { + Some(_) => ForkCompare::base_ref(&self.base), + None => self.base.to_string(), + } + } + + /// The full ref the selected compare branch resolves to. + /// The imported `refs/fork/` ref in fork mode. + /// + /// The plain branch name in checkout mode. + fn compare_ref(&self) -> String { + match &self.fork { + Some(fork) => fork.compare_ref(&self.compare), + None => self.compare.to_string(), + } + } + + /// Prompt for a local checkout. fn choose_checkout(&mut self, window: &mut Window, cx: &mut Context) { let prompt = cx.prompt_for_paths(PathPromptOptions { files: false, @@ -189,17 +427,33 @@ impl NewPullRequestView { }); let task = cx.spawn_in(window, async move |this, cx| { - // `Ok(Ok(Some(paths)))` means the user picked a folder; a - // cancel (or a picker failure) resolves to anything else. + // `Ok(Ok(Some(paths)))` means the user picked a folder. + // A cancel or picker failure resolves to anything else. let picked = match prompt.await { Ok(Ok(Some(mut paths))) => paths.pop(), _ => None, }; + let Some(path) = picked else { return Ok(()); }; - let path = path.to_string_lossy().to_string(); + this.update_in(cx, |this, window, cx| { + this.apply_folder_path(path, window, cx); + })?; + + Ok(()) + }); + self.tasks.push(task); + } + + /// Apply `path` as the local checkout, no picker. + /// + /// Branches and current branch are read off the UI thread, then applied. + fn apply_folder_path(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context) { + let path = path.to_string_lossy().to_string(); + + let task = cx.spawn_in(window, async move |this, cx| { // Branches and the current branch are read off the UI thread. let info = cx .background_spawn({ @@ -223,7 +477,7 @@ impl NewPullRequestView { self.tasks.push(task); } - /// Apply a picked checkout: fill the selectors and load the compare. + /// Apply a picked checkout, filling the selectors and loading the compare. fn apply_checkout( &mut self, path: String, @@ -231,6 +485,8 @@ impl NewPullRequestView { window: &mut Window, cx: &mut Context, ) { + self.fork = None; + let Some((branches, current)) = info else { self.error = Some("The chosen folder is not a git repository".into()); self.repo_path = None; @@ -241,6 +497,7 @@ impl NewPullRequestView { cx.notify(); return; }; + if branches.is_empty() { self.error = Some("The repository has no branches yet".into()); self.repo_path = None; @@ -249,16 +506,18 @@ impl NewPullRequestView { return; } - // Defaults: the announced HEAD branch when the checkout has it - // (falling back to `main`, then the first branch); the checkout's - // current branch for the compare side. + // Defaults, the announced HEAD branch when the checkout has it. + // Falling back to `main`, then the first branch. + // The checkout's current branch is the compare side default. let announced = self.store.read(cx).head.clone(); + let base = announced .as_ref() .filter(|branch| branches.contains(branch)) .cloned() .or_else(|| branches.iter().find(|branch| *branch == "main").cloned()) .unwrap_or_else(|| branches[0].clone()); + let compare = current .filter(|branch| branches.contains(branch)) .unwrap_or_else(|| base.clone()); @@ -267,15 +526,26 @@ impl NewPullRequestView { self.error = None; self.branches = branches.into_iter().map(SharedString::from).collect(); + // Remember this folder as a checkout of the target repository. + // The next panel pre-fills it. + let addr = self.store.read(cx).addr().clone(); + let checkout_store = CheckoutsStore::global(cx); + checkout_store.update(cx, |store, cx| { + store.record(PathBuf::from(&path), addr, cx); + }); + let branches = self.branches.clone(); let base = SharedString::from(base.clone()); let compare = SharedString::from(compare.clone()); + self.base = base.clone(); self.compare = compare.clone(); + self.base_select.update(cx, |state, cx| { state.set_items(SearchableVec::from(branches.clone()), window, cx); state.set_selected_values(&[base], window, cx); }); + self.compare_select.update(cx, |state, cx| { state.set_items(SearchableVec::from(branches), window, cx); state.set_selected_values(&[compare], window, cx); @@ -284,18 +554,273 @@ impl NewPullRequestView { self.reload_compare(window, cx); } - /// (Re)compute `merge_base..compare` of the selected branches on a - /// background task: the merge base, the commit list and the diff. + /// The base repository of the panel, its address and announced EUC. + /// + /// Used to find fork candidates. + fn base_repo(&self, cx: &App) -> (RepoAddr, Option) { + let store = self.store.read(cx); + let euc = store.announcement.as_ref().and_then(|a| a.euc.clone()); + (store.addr().clone(), euc) + } + + /// Announced forks of the target repository a compare can use, own first. + /// + /// Re-read whenever the picker opens. + fn fork_candidates(&self, cx: &App) -> Vec { + let (base, euc) = self.base_repo(cx); + let user = Backend::global(cx).read(cx).current_user(); + let announcements = RepoListStore::global(cx).read(cx).announcements.clone(); + fork_candidates(&announcements, &base, euc.as_deref(), user) + .into_iter() + .cloned() + .collect() + } + + /// Compare against an announced fork. + fn choose_fork( + &mut self, + announcement: Announcement, + window: &mut Window, + cx: &mut Context, + ) { + let refresh = self + .fork + .as_ref() + .is_some_and(|fork| fork.announcement.addr() == announcement.addr()); + + let (base, _euc) = self.base_repo(cx); + let cache = GitStore::global(cx).cache().clone(); + let mirror_path = cache.repo_path(&base); + let namespace = fork_namespace(&announcement); + let clone_urls: Vec = announcement.clone.iter().map(ToString::to_string).collect(); + + let base_clone_urls: Vec = self + .store + .read(cx) + .announcement + .as_ref() + .map(|a| a.clone.iter().map(ToString::to_string).collect()) + .unwrap_or_default(); + + // Keep the current compare and base when the fork is already applied. + // apply_fork drops them when the branch no longer exists. + let keep_compare = refresh.then(|| self.compare.clone()); + let keep_base = refresh.then(|| self.base.clone()); + + // The fork applied when the fetch started. + // A source switch mid-flight must not let the stale result clobber the newer state. + let expected_fork = self.fork.as_ref().map(|fork| fork.announcement.addr()); + + self.loading = true; + self.error = None; + cx.notify(); + + let task = cx.spawn_in(window, async move |this, cx| { + // The fork and base must share history for a merge-base to exist. + // The target's mirror is the object store both sides land in. + // `ensure_clone` fetches `origin` when the mirror already exists. + let result = cx + .background_spawn({ + let cache = cache.clone(); + let base = base.clone(); + let base_clone_urls = base_clone_urls.clone(); + let namespace = namespace.clone(); + let clone_urls = clone_urls.clone(); + let mirror_path = mirror_path.clone(); + async move { + // The fork and base must share history for a merge-base to exist. + // The target's mirror is the object store both sides land in. + // `ensure_clone` fetches `origin` when the mirror already exists. + cache.ensure_clone(&base, &base_clone_urls)?; + + // Prune stale imports of any fork. + // Then import this fork's heads under its namespace. + delete_refs_with_prefix(&mirror_path, "refs/fork")?; + + fetch_repo_refs( + &mirror_path, + &clone_urls, + &format!("+refs/heads/*:refs/fork/{namespace}/*"), + )?; + + // Both branch lists are short names, sorted like the checkout's. + let strip = |refs: Vec, prefix: &str| { + let mut names: Vec = refs + .into_iter() + .filter_map(|name| { + name.strip_prefix(prefix) + .map(|rest| rest.trim_start_matches('/').to_owned()) + }) + .filter(|name| !name.is_empty()) + .collect(); + names.sort(); + names + }; + + let base_branches = strip( + refs_with_prefix(&mirror_path, "refs/remotes/origin")?, + "refs/remotes/origin", + ); + + let compare_branches = strip( + refs_with_prefix(&mirror_path, &format!("refs/fork/{namespace}"))?, + &format!("refs/fork/{namespace}"), + ); + + Ok::<_, anyhow::Error>((base_branches, compare_branches)) + } + }) + .await; + + this.update_in(cx, |this, window, cx| { + // A source switch mid-flight discards the stale result. + // E.g. the user picked a folder while the fork was fetching. + let applied = this.fork.as_ref().map(|fork| fork.announcement.addr()); + if applied != expected_fork { + this.loading = false; + cx.notify(); + return; + } + + this.apply_fork( + announcement, + mirror_path, + namespace, + result, + keep_base, + keep_compare, + window, + cx, + ); + })?; + + Ok(()) + }); + self.tasks.push(task); + } + + /// Apply an imported fork, filling the selectors and loading the compare. + #[allow(clippy::too_many_arguments)] + fn apply_fork( + &mut self, + announcement: Announcement, + mirror_path: PathBuf, + namespace: String, + result: Result<(Vec, Vec), anyhow::Error>, + keep_base: Option, + keep_compare: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.loading = false; + + let (base_branches, compare_branches) = match result { + Ok(branches) => branches, + Err(error) => { + // Keep the previous source, if any. + // The error shows inline next to the compare bar. + self.error = Some(format!("Could not compare against the fork: {error}").into()); + cx.notify(); + return; + } + }; + + if compare_branches.is_empty() { + self.error = Some("The fork has no branches to compare".into()); + cx.notify(); + return; + } + + if base_branches.is_empty() { + self.error = + Some("Could not list the target repository's branches; try again later".into()); + cx.notify(); + return; + } + + let base_branches: Vec = + base_branches.into_iter().map(SharedString::from).collect(); + + let compare_branches: Vec = compare_branches + .into_iter() + .map(SharedString::from) + .collect(); + + // Base defaults to the announced HEAD branch when the mirror has it. + // Otherwise `main`, then the first branch. + // The fork's `main` is the compare default, else the first branch. + // A refresh keeps the previous selection when the branch still exists. + let announced = self.store.read(cx).head.clone(); + let contains = + |name: &str, list: &[SharedString]| list.iter().any(|branch| branch.as_ref() == name); + + let keep_base = keep_base.filter(|name| contains(name, &base_branches)); + let keep_compare = keep_compare.filter(|name| contains(name, &compare_branches)); + + let base = keep_base + .or_else(|| { + announced + .as_ref() + .filter(|branch| contains(branch, &base_branches)) + .map(SharedString::from) + }) + .or_else(|| { + base_branches + .iter() + .find(|branch| branch.as_ref() == "main") + .cloned() + }) + .unwrap_or_else(|| base_branches[0].clone()); + + let compare = keep_compare + .or_else(|| { + compare_branches + .iter() + .find(|branch| branch.as_ref() == "main") + .cloned() + }) + .unwrap_or_else(|| compare_branches[0].clone()); + + self.fork = Some(ForkCompare { + announcement, + namespace, + mirror_path, + }); + + self.error = None; + self.base = base.clone(); + self.compare = compare.clone(); + + self.base_select.update(cx, |state, cx| { + state.set_items(SearchableVec::from(base_branches), window, cx); + state.set_selected_values(&[base], window, cx); + }); + + self.compare_select.update(cx, |state, cx| { + state.set_items(SearchableVec::from(compare_branches), window, cx); + state.set_selected_values(&[compare], window, cx); + }); + + self.reload_compare(window, cx); + } + + /// Recompute `merge_base..compare` of the selected branches on a background task. fn reload_compare(&mut self, window: &mut Window, cx: &mut Context) { - let Some(repo_path) = self.repo_path.clone() else { + let Some(repo_path) = self.work_path() else { return; }; - let base = self.base.to_string(); - let compare = self.compare.to_string(); + + let base = self.base_ref(); + let compare = self.compare_ref(); + + // Short names for the error copy, the full refs go to git. + let base_name = self.base.to_string(); + let compare_name = self.compare.to_string(); self.loading = true; self.error = None; self.compare_generation += 1; + let generation = self.compare_generation; cx.notify(); @@ -315,10 +840,14 @@ impl NewPullRequestView { let repo_path = repo_path.clone(); let base = base.clone(); let compare = compare.clone(); + let base_name = base_name.clone(); + let compare_name = compare_name.clone(); async move { let merge_base = merge_base(Path::new(&repo_path), &base, &compare)? .ok_or_else(|| { - anyhow::anyhow!("{base} and {compare} share no common ancestor") + anyhow::anyhow!( + "{base_name} and {compare_name} share no common ancestor" + ) })?; let commits = worktree_commit_range_commits( Path::new(&repo_path), @@ -336,12 +865,12 @@ impl NewPullRequestView { .await; this.update_in(cx, |this, _window, cx| { - // A stale result (the branches changed mid-flight) must not - // clobber a newer compare; the newer task clears the flag. + // A stale result, branches changed mid-flight, must not clobber a newer compare. if generation != this.compare_generation { return; } this.loading = false; + match result { Ok((merge_base, commits, diff)) => { this.merge_base = Some(merge_base); @@ -357,30 +886,39 @@ impl NewPullRequestView { this.error = Some(error.to_string().into()); } } + cx.notify(); })?; Ok(()) }); + self.tasks.push(task); } - /// Publish the pull request: generate the patch series from the checkout - /// on a background task, hand it to the store, and close the panel once - /// the publish is underway (errors surface in the pull request list). + /// Publish the pull request. fn submit(&mut self, window: &mut Window, cx: &mut Context) { if self.submitting || self.loading { return; } - let Some(repo_path) = self.repo_path.clone() else { - return; - }; + let Some(merge_base) = self.merge_base.clone() else { return; }; + + let Some(repo_path) = self.work_path() else { + return; + }; + let subject = self.subject.read(cx).value().to_string(); let description = self.description.read(cx).value().to_string(); + + // The published `branch-name` is the compare branch's short name. let branch_name = self.compare.to_string(); + + // The patch comes from the compare ref. + // Plain branch name in checkout mode, imported `refs/fork/…` ref in fork mode. + let compare_ref = self.compare_ref(); let store = self.store.clone(); let dock_area = self.dock_area.clone(); let entity = cx.entity().clone(); @@ -390,15 +928,15 @@ impl NewPullRequestView { cx.notify(); let task = cx.spawn_in(window, async move |this, cx| { - // Regenerate the series at submit time so the published patch - // covers the current tip of the compare branch. + // Regenerate the series at submit time. + // The published patch covers the current tip of the compare branch. let patch = cx .background_spawn({ let repo_path = repo_path.clone(); let merge_base = merge_base.clone(); - let branch_name = branch_name.clone(); + let compare_ref = compare_ref.clone(); async move { - format_patch_between(Path::new(&repo_path), &merge_base, &branch_name) + format_patch_between(Path::new(&repo_path), &merge_base, &compare_ref) } }) .await; @@ -425,6 +963,7 @@ impl NewPullRequestView { this.update_in(cx, |this, window, cx| { this.submitting = false; + store.update(cx, |store, cx| { store.open_pull_request( (!subject.is_empty()).then_some(subject), @@ -437,6 +976,7 @@ impl NewPullRequestView { cx, ); }); + // Close the panel once the publish is underway. cx.defer_in(window, { let dock_area = dock_area.clone(); @@ -449,17 +989,19 @@ impl NewPullRequestView { } } }); + cx.notify(); })?; Ok(()) }); + self.tasks.push(task); } - /// Open the diff of `commit_id` (from the Commits tab) in a new panel. + /// Open the diff of `commit_id`, from the Commits tab, in a new panel. fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context) { - let Some(repo_path) = self.repo_path.clone() else { + let Some(repo_path) = self.work_path() else { return; }; let Some(dock_area) = self.dock_area.upgrade() else { @@ -477,16 +1019,13 @@ impl NewPullRequestView { }); dock_area.update(cx, |dock_area, cx| { - dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx); + add_center_panel(dock_area, panel_handle(panel), window, cx); }); } - /// The compare bar: base/compare selectors, the checkout chooser and the - /// Create button. fn render_compare_bar(&self, cx: &mut Context) -> AnyElement { - let has_checkout = self.repo_path.is_some(); - let checkout = self.repo_path.clone(); - let can_submit = has_checkout + let has_source = self.has_source(); + let can_submit = has_source && !self.loading && !self.submitting && self.merge_base.is_some() @@ -496,6 +1035,25 @@ impl NewPullRequestView { .is_some_and(|commits| !commits.is_empty()) && !self.subject.read(cx).value().is_empty(); + // Source-picker data snapshotted when the menu is built. + // Each open rebuilds the items from the live announcements. + let source_menu = self.source_menu(cx); + let source_label = self.source_trigger(); + let source_tooltip = match &self.fork { + Some(fork) => { + format!( + "Comparing against {}", + fork_display_name(&fork.announcement) + ) + } + None => self.repo_path.as_ref().map_or_else( + || "Choose a compare source".into(), + |p| p.display().to_string(), + ), + }; + + let refresh_fork = self.fork.as_ref().map(|fork| fork.announcement.clone()); + h_flex() .px_4() .h_16() @@ -518,11 +1076,11 @@ impl NewPullRequestView { .placeholder("branch") .appearance(false) .menu_width(px(220.)) - .disabled(!has_checkout) + .disabled(!has_source) .bg(cx.theme().muted) .rounded(cx.theme().radius) .render_trigger(|ctx, _window, cx| { - render_ref_trigger(ctx, CustomIconName::GitBranch, cx) + ref_selector_trigger(ctx, CustomIconName::GitBranch, cx) }), ), ), @@ -543,27 +1101,48 @@ impl NewPullRequestView { .placeholder("branch") .appearance(false) .menu_width(px(220.)) - .disabled(!has_checkout) + .disabled(!has_source) .bg(cx.theme().muted) .rounded(cx.theme().radius) .render_trigger(|ctx, _window, cx| { - render_ref_trigger(ctx, CustomIconName::GitBranch, cx) + ref_selector_trigger(ctx, CustomIconName::GitBranch, cx) }), ), ), ) .child( - Button::new("choose-checkout") - .icon(IconName::Folder) - .ghost() - .tooltip(checkout.as_ref().map_or_else( - || "Choose a local checkout".into(), - |path| path.display().to_string(), - )) - .on_click(cx.listener(|this, _event, window, cx| { - this.choose_checkout(window, cx); - })), + v_flex() + .gap_1() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child("Source"), + ) + .child( + Button::new("compare-source") + .ghost() + .w(px(190.)) + .child(div().text_sm().child(source_label)) + .dropdown_caret(true) + .tooltip(source_tooltip) + .dropdown_menu(source_menu), + ), ) + .when_some(refresh_fork, |this, fork| { + this.child( + v_flex().gap_1().child(div()).child( + Button::new("refresh-fork") + .icon(CustomIconName::Refresh) + .ghost() + .tooltip("Re-fetch the fork") + .on_click(cx.listener(move |this, _event, window, cx| { + this.choose_fork(fork.clone(), window, cx); + })), + ), + ) + }) .child(div().flex_1()) .child( Button::new("create-pr") @@ -578,6 +1157,70 @@ impl NewPullRequestView { .into_any_element() } + /// The source picker's trigger, a truncated label of the applied source. + fn source_trigger(&self) -> SharedString { + match &self.fork { + Some(fork) => truncate_label(&fork_display_name(&fork.announcement)), + None => self.repo_path.as_ref().map_or_else( + || SharedString::from("No source"), + |path| truncate_label(&path.display().to_string()), + ), + } + } + + /// Build the compare-source menu. + fn source_menu( + &self, + cx: &Context, + ) -> impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static { + let view = cx.entity().downgrade(); + let addr = self.store.read(cx).addr().clone(); + let associated = CheckoutsStore::global(cx).read(cx).associations_of(&addr); + + let active_path = (self.fork.is_none()) + .then(|| self.repo_path.clone()) + .flatten(); + + let candidates = self.fork_candidates(cx); + let user = Backend::global(cx).read(cx).current_user(); + let active_fork = self.fork.as_ref().map(|fork| fork.announcement.addr()); + + move |mut menu, _window, _cx| { + for path in &associated { + menu = menu.item(checkout_source_item( + view.clone(), + path.clone(), + active_path.as_ref() == Some(path), + )); + } + + menu = menu.item(choose_folder_source_item(view.clone())); + menu = menu.item(PopupMenuItem::separator()); + + if candidates.is_empty() { + menu = menu.item(PopupMenuItem::label( + "No announced forks of this repository", + )); + } else { + for candidate in candidates.iter() { + let subtitle: SharedString = if Some(candidate.owner) == user { + "Your fork".into() + } else { + SharedString::from(format!("by {}", shorten_owner(&candidate.owner))) + }; + menu = menu.item(fork_source_item( + view.clone(), + candidate.clone(), + subtitle, + active_fork == Some(candidate.addr()), + )); + } + } + + menu + } + } + /// The title and description inputs. fn render_inputs(&self, _cx: &mut Context) -> AnyElement { v_flex() @@ -616,7 +1259,7 @@ impl NewPullRequestView { .child(Icon::new(CustomIconName::GitFile).small()) .child("Files"), ) - .child(count_badge(files, cx)) + .child(CountBadge::new(files)) .text_color(cx.theme().button_foreground) .rounded(cx.theme().radius) .hover(|this| this.bg(cx.theme().button_hover)) @@ -644,7 +1287,7 @@ impl NewPullRequestView { .child(Icon::new(CustomIconName::GitCommit).small()) .child("Commits"), ) - .child(count_badge(commits, cx)) + .child(CountBadge::new(commits)) .text_color(cx.theme().button_foreground) .rounded(cx.theme().radius) .hover(|this| this.bg(cx.theme().button_hover)) @@ -671,8 +1314,11 @@ impl NewPullRequestView { .child(Spinner::new().small()) .into_any_element(); } - if self.repo_path.is_none() { - return placeholder("Choose a local checkout to compare branches", cx); + if !self.has_source() { + return placeholder( + "Choose a local checkout or an announced fork to compare", + cx, + ); } if self.commits.is_none() && self.error.is_some() { return placeholder("Nothing to compare", cx); @@ -683,8 +1329,6 @@ impl NewPullRequestView { } } - /// The Commits tab: `merge_base..compare` in a virtual list; clicking a - /// row opens the commit's diff in a new panel. fn render_commits_tab(&self, cx: &mut Context) -> AnyElement { let Some(commits) = self.commits.as_ref() else { return placeholder("No commits", cx); @@ -746,57 +1390,7 @@ impl NewPullRequestView { } } -/// The count badge of a tab, styled like the repository panel's. -fn count_badge(count: usize, cx: &App) -> impl IntoElement { - h_flex() - .justify_center() - .px_1() - .py_0p5() - .min_w_4() - .text_size(px(8.)) - .bg(cx.theme().muted) - .text_color(cx.theme().muted_foreground) - .rounded(cx.theme().radius) - .line_height(relative(1.)) - .child(SharedString::from(count.to_string())) -} - -/// The trigger of a branch selector: icon + current selection (or -/// placeholder) + caret. `Combobox` replaces its default trigger entirely. -fn render_ref_trigger( - ctx: &ComboboxTriggerContext>, - icon: CustomIconName, - cx: &App, -) -> AnyElement { - let muted = cx.theme().muted_foreground; - - h_flex() - .w_full() - .min_w_0() - .gap_1() - .items_center() - .child(Icon::new(icon).small().flex_shrink_0()) - .child( - div() - .flex_1() - .min_w_0() - .overflow_hidden() - .text_ellipsis() - .whitespace_nowrap() - .when(ctx.selection().is_empty(), |this| this.text_color(muted)) - .child( - ctx.selection() - .first() - .map(|(_, item)| item.clone()) - .or_else(|| ctx.placeholder().cloned()) - .unwrap_or_default(), - ), - ) - .child(Caret::new(ctx.size()).text_color(muted)) - .into_any_element() -} - -/// Open the "new pull request" panel in the center dock. +/// Open the new pull request panel in the center dock. pub(super) fn open_new_pull_panel( dock_area: WeakEntity, store: Entity, @@ -806,7 +1400,7 @@ pub(super) fn open_new_pull_panel( let panel = cx.new(|cx| NewPullRequestView::new(dock_area.clone(), store, window, cx)); let _ = dock_area.update(cx, |dock_area, cx| { - dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx); + add_center_panel(dock_area, panel_handle(panel), window, cx); }); } @@ -865,3 +1459,140 @@ impl Render for NewPullRequestView { ) } } + +#[cfg(test)] +mod tests { + use nostr::prelude::*; + use signed_core::repo_addr; + + use super::*; + + const OWNER_KEYS: [&str; 3] = [ + "0000000000000000000000000000000000000000000000000000000000000001", + "0000000000000000000000000000000000000000000000000000000000000002", + "0000000000000000000000000000000000000000000000000000000000000003", + ]; + + /// Build a signed kind-30617 event for `owner` with the given tags. + fn announcement_event(owner: &str, tags: &[&[&str]]) -> Event { + let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key")); + let tags: Vec = tags + .iter() + .map(|t| Tag::parse(t.to_vec()).expect("valid tag")) + .collect(); + EventBuilder::new(Kind::GitRepoAnnouncement, "") + .tags(tags) + .finalize(&keys) + .expect("signed event") + } + + fn announcements(owner_ix: usize, tags: &[&[&str]]) -> Vec { + vec![ + Announcement::from_event(&announcement_event(OWNER_KEYS[owner_ix], tags)) + .expect("parses"), + ] + } + + #[test] + fn fork_candidates_orders_own_forks_first() { + let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; + let clone = "https://grasp.example/npub1x/my-fork.git"; + + let base_addr = repo_addr( + PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"), + "upstream", + ); + // Newest first, as RepoListStore keeps them. + // Unrelated repo, the user's fork with the shared EUC, another fork with a `u` tag. + let all = vec![ + announcements( + 2, + &[ + &["d", "other-project"], + &["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"], + ], + ) + .pop() + .unwrap(), + announcements( + 1, + &[&["d", "my-fork"], &["r", euc, "euc"], &["clone", clone]], + ) + .pop() + .unwrap(), + announcements( + 2, + &[ + &["d", "their-fork"], + &["u", &base_addr.to_string()], + &["clone", clone], + ], + ) + .pop() + .unwrap(), + ]; + + let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey"); + let forks = fork_candidates(&all, &base_addr, Some(euc), Some(user)); + + // The user's fork comes first, then the other author's. + let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect(); + assert_eq!(ids, vec!["my-fork", "their-fork"]); + } + + #[test] + fn fork_candidates_excludes_base_unrelated_and_unfetchable() { + let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; + let base_owner = PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"); + let base_addr = repo_addr(base_owner, "upstream"); + + let mut all = vec![ + announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]]) + .pop() + .unwrap(), + announcements(1, &[&["d", "no-clone-fork"], &["r", euc, "euc"]]) + .pop() + .unwrap(), + announcements( + 2, + &[ + &["d", "other"], + &["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"], + ], + ) + .pop() + .unwrap(), + announcements( + 2, + &[ + &["d", "mirror"], + &["r", euc, "euc"], + &["clone", "https://grasp.example/x/mirror.git"], + ], + ) + .pop() + .unwrap(), + ]; + + let forks = fork_candidates(&all, &base_addr, Some(euc), Some(base_owner)); + assert_eq!(forks.len(), 1); + assert_eq!(forks[0].id, "mirror"); + + // Without a base EUC only `u`-tag forks match. + all.push( + announcements( + 2, + &[ + &["d", "u-fork"], + &["u", &base_addr.to_string()], + &["clone", "https://grasp.example/x/u-fork.git"], + ], + ) + .pop() + .unwrap(), + ); + let forks = fork_candidates(&all, &base_addr, None, Some(base_owner)); + let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect(); + assert_eq!(ids, vec!["u-fork"]); + } +} diff --git a/crates/workspace/src/views/repo_detail/pull_request_detail.rs b/crates/workspace/src/views/repo_detail/pull_request_detail.rs index 927790f..e6409ce 100644 --- a/crates/workspace/src/views/repo_detail/pull_request_detail.rs +++ b/crates/workspace/src/views/repo_detail/pull_request_detail.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::path::PathBuf; use std::rc::Rc; @@ -7,97 +6,68 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, - ScrollStrategy, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, relative, - size, + SharedString, Size, Task, WeakEntity, Window, div, px, relative, size, }; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::clipboard::Clipboard; use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; use gpui_component::form::{field, v_form}; use gpui_component::input::{Textarea, TextareaState}; -use gpui_component::list::ListItem; use gpui_component::scroll::{ScrollableElement, Scrollbar}; use gpui_component::spinner::Spinner; use gpui_component::tab::{Tab, TabBar}; -use gpui_component::tag::Tag; -use gpui_component::tree::{TreeEntry, TreeState, tree}; use gpui_component::{ - ActiveTheme, Icon, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex, + ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list, }; -use nostr::prelude::{Event, EventId, Kind, Nip34Tag, PublicKey}; +use nostr::prelude::{Event, EventId, Kind, Nip34Tag}; use signed_core::{activity_subject, pull_request_patch}; -use signed_git::{CommitDiff, FileCommit, FileDiff, patch_commits, patch_diffs}; +use signed_git::{FileCommit, patch_commits, patch_diffs}; use signed_state::{Backend, GitStore, ProfileStore, RepoStore}; -use signed_ui::image_cache::{MAX_IMAGES, image_cache}; -use signed_ui::{UserAvatar, placeholder, status_badge, tree_row}; +use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge}; use utils::{relative_time, relative_time_secs}; -use super::diff::CommitDiffView; -use super::helpers::{ - DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items, -}; +use super::diff::{CommitDiffView, DiffPane}; +use super::helpers::{comment_form, comments_section, pr_roots, sidebar_section}; -/// Width of the changed-files column. -const TREE_WIDTH: f32 = 260.; - -/// Height of one commit row in the commits tab's virtual list: a single -/// text line plus the 1px bottom border. -const PR_COMMIT_ROW_HEIGHT: f32 = 37.; +/// Height of one commit row in the commits tab's virtual list. +const ROW_HEIGHT: f32 = 37.; /// Detail panel of a single pull request. pub struct PullRequestDetailView { focus_handle: FocusHandle, - /// Dock area new panels (commit diffs) are added to. + /// Dock area where new panels, e.g. commit diffs, are added. dock_area: WeakEntity, /// Repo store holding the PR, its status and comments. store: Entity, - /// Event id of the root PR event (kind 1618; updates are revisions). + /// Event id of the root PR event, kind 1618. + /// Updates are revisions. pr_id: EventId, - /// Input state of the "leave a comment" textarea. + /// Input state of the comment textarea. comment_input: Entity, /// Display name of the repository, for panels opened from here. repo_name: SharedString, - /// Local clone the PR's git changes come from; `None` while the diff is - /// parsed from the nostr patch set (no commit diff viewer then). + /// Local clone the PR's git changes come from. worktree: Option, /// Root PR's content, shown as plain text. description: SharedString, - /// Tip commit of the PR: the latest update's `c` tag, else the root's. + /// Tip commit of the PR, the latest update's `c` tag or the root's. current_commit: Option, - /// Commits of the patch series, in patch order (oldest first). + /// Commits of the patch series, in patch order, oldest first. commits: Vec, - /// Parsed file changes of the patch; `None` while loading or on failure. - diff: Option, /// The patch is being parsed on a background task. loading: bool, error: Option, - /// Active header tab: 0 = Discussion, 1 = Files, 2 = Commits. + /// Active header tab, 0 = Discussion, 1 = Files, 2 = Commits. active_tab: usize, - /// Changed-files explorer state. - tree_state: Entity, - /// Path of the file whose diff is shown in the detail column. - selected_file: Option, - /// Rows of the selected file's diff (hunk headers + lines). - rows: Vec, - /// Per-row heights of [`Self::rows`]. - item_sizes: Rc>>, - /// Virtual list state of the diff rows. - scroll_handle: VirtualListScrollHandle, - /// Per-row heights of the commits tab's virtual list, built when the - /// patch series is loaded. + /// Changed-files explorer and per-file diff, like the commit and compare views. + pane: Entity, + /// Per-row heights of the commits tab's virtual list, built when the patch series loads. commit_item_sizes: Rc>>, /// Virtual list state of the commits tab. commit_scroll_handle: VirtualListScrollHandle, - /// Comment bodies as shared strings, keyed by comment event ID, so - /// re-renders don't clone full contents again (events are immutable, - /// so the cache never needs invalidation). - contents: HashMap, - /// In-flight tasks; finished tasks are pruned on every push, so the vec - /// stays bounded by the number of concurrent loads. + /// In-flight tasks, finished tasks are pruned on every push. tasks: Vec>>, - /// Subscriptions keeping the view live as the store refreshes. - _subscriptions: Vec, } impl PullRequestDetailView { @@ -108,26 +78,12 @@ impl PullRequestDetailView { window: &mut Window, cx: &mut Context, ) -> Self { - let tree_state = cx.new(|cx| TreeState::new(cx)); + let repo_name = store.read(cx).name(); + let pane = cx.new(DiffPane::new); + let comment_input = cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment...")); - // Same display name as the repo detail panel's title. - let repo_name = store - .read(cx) - .announcement - .as_ref() - .map(|announcement| { - announcement - .name - .clone() - .unwrap_or_else(|| SharedString::from(announcement.id.clone())) - }) - .unwrap_or_default(); - - // Re-render when the store refreshes (new comments, status changes). - let subscriptions = vec![cx.observe(&store, |_this, _store, cx| cx.notify())]; - // Defer loading until the window is ready, like the commit diff view. cx.defer_in(window, |this, window, cx| { this.load(window, cx); @@ -144,29 +100,17 @@ impl PullRequestDetailView { description: SharedString::default(), current_commit: None, commits: Vec::new(), - diff: None, loading: true, error: None, active_tab: 0, - tree_state, - selected_file: None, - rows: Vec::new(), - item_sizes: Rc::new(Vec::new()), - scroll_handle: VirtualListScrollHandle::new(), + pane, commit_item_sizes: Rc::new(Vec::new()), commit_scroll_handle: VirtualListScrollHandle::new(), - contents: HashMap::new(), tasks: Vec::new(), - _subscriptions: subscriptions, } } - /// Snapshot the PR events from the store, then compute the file changes - /// and commit list on a background task and populate the tree. - /// - /// The changes come from the PR's patch set (NIP-34 `e`-linked patch - /// events) when present; otherwise from the git repository (`c`, - /// `clone` and `merge-base` tags), diffing the `merge-base..tip` range. + /// Snapshot the PR events from the store. fn load(&mut self, window: &mut Window, cx: &mut Context) { self.loading = true; self.error = None; @@ -176,6 +120,7 @@ impl PullRequestDetailView { let (description, patch, current_commit, merge_base, clone_urls, addr, has_patch_link) = { let store = self.store.read(cx); + let Some(root) = store .pull_requests .iter() @@ -186,19 +131,24 @@ impl PullRequestDetailView { cx.notify(); return; }; + let update = latest_update(store.pull_requests.iter(), root); + let tip = update .and_then(current_commit_of) .or_else(|| current_commit_of(root)); + let base = update .and_then(merge_base_of) .or_else(|| merge_base_of(root)); + let clone_urls = clone_urls_of(root).or_else(|| { store .announcement .as_ref() .map(|a| a.clone.iter().map(ToString::to_string).collect()) }); + ( root.content.clone(), pull_request_patch(root, store.patches.iter()), @@ -209,16 +159,17 @@ impl PullRequestDetailView { root.tags.event_ids().next().is_some(), ) }; + self.description = description.into(); let task = cx.spawn_in(window, async move |this, cx| { - // Parse the nostr patch set first. let nostr_diff = cx .background_spawn({ let patch = patch.clone(); async move { patch_diffs(&patch) } }) .await; + let nostr_commits = cx .background_spawn({ let patch = patch.clone(); @@ -226,13 +177,13 @@ impl PullRequestDetailView { }) .await; - // PRs without patch events (e.g. published by ngit) carry their - // changes in the git repository: fetch the clone and diff the - // `merge-base..tip` range. + // PRs without patch events, e.g. published by ngit, carry their changes in git. + // Fetch the clone and diff the `merge-base..tip` range. let use_nostr = match &nostr_diff { Ok(diff) => has_patch_link || !diff.files.is_empty(), Err(_) => true, }; + let git = if use_nostr { None } else { @@ -241,19 +192,22 @@ impl PullRequestDetailView { let clone_urls = clone_urls.clone(); let base = merge_base.clone(); let tip = current_commit.clone(); + Some( cx.background_spawn(async move { let repo = cache.ensure_clone(&addr, &clone_urls)?; + let workdir = repo .workdir() .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? .to_path_buf(); + let tip = tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?; + let base = match base { Some(base) => base, - // No `merge-base` tag: use the merge base of the - // tip with the default branch. + // No `merge-base` tag. Use the merge base of the tip and the default branch. None => { let head = repo .head_id() @@ -262,9 +216,11 @@ impl PullRequestDetailView { repo.merge_base(tip_id, head)?.to_string() } }; + let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?; let commits = signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?; + Ok::<_, anyhow::Error>((diff, commits, workdir)) }) .await, @@ -281,37 +237,18 @@ impl PullRequestDetailView { this.loading = false; this.worktree = worktree; this.current_commit = current_commit.map(SharedString::from); - this.commit_item_sizes = - Rc::new(vec![size(px(0.), px(PR_COMMIT_ROW_HEIGHT)); commits.len()]); + this.commit_item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]); this.commits = commits; + match diff { Ok(diff) => { - let mut paths: Vec = diff - .files - .iter() - .map(|file| PathBuf::from(&file.path)) - .collect(); - paths.sort(); - let items = tree_items(build_tree_items(&paths), true); - let first = diff - .files - .first() - .map(|file| SharedString::from(file.path.as_str())); - this.tree_state.update(cx, |state, cx| { - state.set_items(items.clone(), cx); - let item = find_item(&items, first.as_deref()); - state.set_selected_item(item, cx); - }); - this.selected_file = first.clone(); - this.diff = Some(diff); - if let Some(path) = first { - this.set_diff_rows(path.as_ref()); - } + this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx)); } Err(error) => { this.error = Some(error.to_string().into()); } } + cx.notify(); })?; @@ -322,27 +259,6 @@ impl PullRequestDetailView { self.tasks.push(task); } - /// Show the diff of the file at `path` (selected in the tree). - fn select_file(&mut self, path: &str, cx: &mut Context) { - self.selected_file = Some(path.into()); - self.set_diff_rows(path); - cx.notify(); - } - - /// Rebuild the virtual list state for the file at `path` and scroll back - /// to the top. - fn set_diff_rows(&mut self, path: &str) { - let Some(diff) = self.diff.as_ref() else { - return; - }; - let Some(file) = diff.files.iter().find(|file| file.path == path) else { - return; - }; - self.rows = diff_rows(file); - self.item_sizes = Rc::new(vec![size(px(0.), px(DIFF_ROW_HEIGHT)); self.rows.len()]); - self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); - } - /// Open the diff of `commit_id` in the bottom dock of the area. fn open_commit_diff( &mut self, @@ -370,204 +286,9 @@ impl PullRequestDetailView { }); } - /// One row of the changed-files tree: icon + name, indented by depth. - fn render_tree_item( - ix: usize, - entry: &TreeEntry, - selected: bool, - view: &WeakEntity, - ) -> ListItem { - let view = view.clone(); - let id = entry.item().id.clone(); - - tree_row(ix, entry, selected, move |_window, cx| { - if let Some(view) = view.upgrade() { - view.update(cx, |this, cx| this.select_file(&id, cx)); - } - }) - } - - /// Left column: the changed-files tree. - fn render_tree_column(&self, cx: &mut Context) -> AnyElement { - let tree_state = self.tree_state.clone(); - let view = cx.entity().downgrade(); - - v_flex() - .h_full() - .w(px(TREE_WIDTH)) - .flex_none() - .border_r_1() - .border_color(cx.theme().border) - .child( - div() - .flex_1() - .min_h_0() - .when(self.diff.is_some(), |this| { - this.child( - tree(&tree_state, move |ix, entry, selected, _window, _cx| { - Self::render_tree_item(ix, entry, selected, &view) - }) - .p_2(), - ) - }) - .when(self.diff.is_none() && !self.loading, |this| { - this.child(placeholder("Failed to load diff", cx)) - }), - ) - .into_any_element() - } - - /// Right column: header of the selected file plus its diff. - fn render_detail_column(&self, cx: &mut Context) -> AnyElement { - if self.loading { - return v_flex() - .size_full() - .items_center() - .justify_center() - .child(Spinner::new().small()) - .into_any_element(); - } - if let Some(error) = self.error.clone() { - return placeholder(&error, cx); - } - let Some(diff) = self.diff.as_ref() else { - return placeholder("Failed to load diff", cx); - }; - let Some(path) = self.selected_file.clone() else { - return if diff.files.is_empty() { - placeholder("No files changed in this pull request", cx) - } else { - placeholder("Select a file", cx) - }; - }; - let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else { - return placeholder("File not found", cx); - }; - self.render_file_diff(file, cx.entity(), cx) - } - - /// The diff of one file: a header with status and stats, then the hunks - /// in a virtual list (a large diff is never materialized per frame). - fn render_file_diff(&self, file: &FileDiff, view: Entity, cx: &App) -> AnyElement { - let status_label = match file.status { - signed_git::DiffStatus::Added => "A", - signed_git::DiffStatus::Modified => "M", - signed_git::DiffStatus::Deleted => "D", - signed_git::DiffStatus::Renamed => "R", - signed_git::DiffStatus::Copied => "C", - }; - let status_color = match file.status { - signed_git::DiffStatus::Added => cx.theme().success, - signed_git::DiffStatus::Modified => cx.theme().info, - signed_git::DiffStatus::Deleted => cx.theme().danger, - signed_git::DiffStatus::Renamed | signed_git::DiffStatus::Copied => { - cx.theme().muted_foreground - } - }; - let title = match &file.old_path { - Some(old) => format!("{old} → {}", file.path), - None => file.path.clone(), - }; - - let body: AnyElement = if file.binary { - placeholder("Diff not available", cx) - } else if file.hunks.is_empty() { - placeholder("No content changes", cx) - } else { - let sizes = self.item_sizes.clone(); - let scroll_handle = self.scroll_handle.clone(); - v_flex() - .size_full() - .relative() - .child( - v_virtual_list( - view, - "pr-diff-rows", - sizes, - move |this, range, _window, cx| { - let Some(diff) = this.diff.as_ref() else { - return Vec::new(); - }; - let Some(path) = this.selected_file.as_deref() else { - return Vec::new(); - }; - let Some(file) = diff.files.iter().find(|file| file.path == path) - else { - return Vec::new(); - }; - range - .map(|ix| render_diff_row(&file.hunks, this.rows[ix], cx)) - .collect() - }, - ) - .track_scroll(&scroll_handle) - .size_full(), - ) - .child( - div() - .absolute() - .top_0() - .left_0() - .right_0() - .bottom_0() - .child(Scrollbar::vertical(&scroll_handle)), - ) - .into_any_element() - }; - - v_flex() - .flex_1() - .min_w_0() - .h_full() - .child( - h_flex() - .px_3() - .h_9() - .gap_2() - .items_center() - .child( - div() - .text_xs() - .font_semibold() - .text_color(status_color) - .child(status_label), - ) - .child( - div() - .flex_1() - .min_w_0() - .text_xs() - .font_semibold() - .text_ellipsis() - .whitespace_nowrap() - .child(title), - ) - .when(!file.binary, |this| { - this.child( - h_flex() - .gap_2() - .text_xs() - .child( - div() - .text_color(cx.theme().success) - .child(format!("+{}", file.insertions)), - ) - .child( - div() - .text_color(cx.theme().danger) - .child(format!("-{}", file.deletions)), - ), - ) - }), - ) - .child(div().id("pr-diff-body").flex_1().min_h_0().child(body)) - .into_any_element() - } - - /// Underline tab bar: Discussion, Files and Commits. fn render_tabs(&self, cx: &mut Context) -> AnyElement { let active = self.active_tab; - let files_count = self.diff.as_ref().map(|diff| diff.files.len()); + let files_count = self.pane.read(cx).diff().map(|diff| diff.files.len()); let commits_count = if self.commits.is_empty() { None } else { @@ -589,29 +310,19 @@ impl PullRequestDetailView { Tab::new() .label("Files") .when_some(files_count, |this, count| { - this.suffix( - Tag::secondary() - .xsmall() - .child(SharedString::from(count.to_string())), - ) + this.suffix(CountBadge::new(count)) }), ) .child( Tab::new() .label("Commits") .when_some(commits_count, |this, count| { - this.suffix( - Tag::secondary() - .xsmall() - .child(SharedString::from(count.to_string())), - ) + this.suffix(CountBadge::new(count)) }), ) .into_any_element() } - /// Discussion tab: author, description and comments like the issue - /// panel, with the comment form at the end and a sidebar on the right. fn render_discussion(&mut self, cx: &mut Context) -> AnyElement { if self.loading { return v_flex() @@ -686,111 +397,49 @@ impl PullRequestDetailView { this.child(div().text_sm().child(self.description.clone())) }), ) - .child(self.render_comments(&root_id, cx)) - .child(self.render_form(&root_id, cx)), + .child(comments_section(&self.store, root_id, cx)) + .child(comment_form( + &self.store, + root_id, + pr_roots, + &self.comment_input, + "pr-comment", + cx, + )), ), ) - .child(self.render_sidebar(cx)) + .child(sidebar_section(&self.store, root_id, pr_roots, true, cx)) .into_any_element() } - /// Right sidebar: participants and labels, like the issue panel. - fn render_sidebar(&self, cx: &mut Context) -> AnyElement { - let profile_store = ProfileStore::global(cx); - let store = self.store.read(cx); - - let Some(root) = store - .pull_requests - .iter() - .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) - else { - // `render_discussion` already bails out when the PR is missing. - return div().into_any_element(); - }; - - // Participants: the PR author plus everyone who commented. - let mut participants: Vec = vec![root.pubkey]; - participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey)); - participants.sort_by_key(PublicKey::to_hex); - participants.dedup(); - - // PR labels are NIP-34 `t` hashtag tags on the event. - let labels: Vec = root.tags.hashtags().map(|tag| tag.to_string()).collect(); - - v_flex() - .w(px(240.)) - .h_full() - .flex_none() - .px_4() - .gap_4() - .border_l(px(1.)) - .border_color(cx.theme().sidebar_border) - .child( - v_flex() - .mt_4() - .gap_2() - .child(sidebar_title("Participants", cx)) - .children(participants.iter().map(|pubkey| { - let profile = profile_store.read(cx).get(pubkey); - let name = profile.name(); - let picture = profile.picture(); - - h_flex() - .gap_1() - .items_center() - .child(UserAvatar::new(name.clone()).picture(picture)) - .child(div().text_sm().truncate().text_ellipsis().child(name)) - .into_any_element() - })), - ) - .child( - v_flex() - .gap_2() - .child(sidebar_title("Labels", cx)) - .map(|this| { - if labels.is_empty() { - this.child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("None yet."), - ) - } else { - this.child(h_flex().gap_1().children({ - let mut items = vec![]; - - for label in labels.iter() { - items.push( - Tag::secondary() - .outline() - .xsmall() - .child(SharedString::from(label)), - ); - } - - items - })) - } - }), - ) - .into_any_element() - } - - /// Files tab: the changed-files tree on the left, the diff of the - /// selected file on the right. fn render_files_tab(&self, cx: &mut Context) -> AnyElement { + if self.loading { + return v_flex() + .flex_1() + .w_full() + .min_h_0() + .items_center() + .justify_center() + .child(Spinner::new().small()) + .into_any_element(); + } + + if let Some(error) = self.error.clone() { + return placeholder(&error, cx); + } + h_flex() .flex_1() .w_full() .min_h_0() .overflow_hidden() - .child(self.render_tree_column(cx)) - .child(self.render_detail_column(cx)) + .child(self.pane.clone()) .into_any_element() } - /// Full-height Commits tab: every commit of the patch series, or a - /// status message while loading / when there are none. + /// Full-height Commits tab. + /// + /// Every commit of the patch series, or a status message while loading or empty. fn render_commits_tab(&self, cx: &mut Context) -> AnyElement { if self.loading { return v_flex() @@ -838,8 +487,9 @@ impl PullRequestDetailView { .into_any_element() } - /// One row of the commits tab: id, summary, author and time. Clicking a - /// row opens the commit's diff in the bottom dock. + /// One row of the commits tab, id, summary, author and time. + /// + /// Clicking a row opens the commit's diff in the bottom dock. fn render_commit_row( &self, ix: usize, @@ -852,7 +502,7 @@ impl PullRequestDetailView { h_flex() .id(ix) .px_4() - .h(px(PR_COMMIT_ROW_HEIGHT)) + .h(px(ROW_HEIGHT)) .gap_2() .items_center() .text_sm() @@ -882,8 +532,8 @@ impl PullRequestDetailView { .child(SharedString::from(meta)), ) }) - // Commits parsed from the nostr patch set may not exist in any - // local clone; only git-backed PRs open a diff viewer. + // Commits parsed from the nostr patch set may not exist in any local clone. + // Only git-backed PRs open a diff viewer. .when_some(self.worktree.clone(), |this, worktree| { this.on_click(cx.listener(move |this, _event, window, cx| { this.open_commit_diff(worktree.clone(), &id, window, cx); @@ -892,117 +542,7 @@ impl PullRequestDetailView { .into_any_element() } - /// One comment card, same design as the issue panel: avatar, author, - /// "commented" and age on the header row, content below. - fn render_comments(&mut self, id: &EventId, cx: &mut Context) -> AnyElement { - let store = self.store.read(cx); - let comments: Vec<&Event> = store.comments_of(id).collect(); - let title = SharedString::from(format!("Discussions {}", comments.len())); - - v_flex() - .gap_4() - .child(div().text_xs().font_semibold().child(title)) - .children(comments.iter().map(|comment| { - let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey); - let author = profile.name(); - let picture = profile.picture(); - let age = relative_time(comment.created_at); - // Comment bodies are cloned into shared strings once per - // comment, not on every render. - let content = self - .contents - .entry(comment.id) - .or_insert_with(|| SharedString::from(comment.content.clone())) - .clone(); - - v_flex() - .gap_1() - .p_3() - .border_1() - .border_color(cx.theme().border) - .rounded(cx.theme().radius) - .child( - h_flex() - .gap_2() - .text_sm() - .child( - h_flex() - .gap_1() - .child(UserAvatar::new(author.clone()).picture(picture)) - .child(author), - ) - .child( - div() - .text_color(cx.theme().muted_foreground) - .child("commented"), - ) - .child( - div() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(age)), - ), - ) - .child(div().text_sm().child(content)) - })) - .into_any_element() - } - - fn render_form(&mut self, id: &EventId, cx: &mut Context) -> AnyElement { - let comment_input = self.comment_input.clone(); - let store = self.store.clone(); - let id = id.to_owned(); - - v_flex() - .gap_2() - .child( - Textarea::new(&self.comment_input) - .h_24() - .text_color(cx.theme().muted_foreground) - .bg(cx.theme().muted), - ) - .child( - h_flex() - .justify_between() - .child( - h_flex() - .gap_1() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(Icon::new(CustomIconName::Markdown).small()) - .child("Markdown is supported"), - ) - .child( - Button::new("pr-comment") - .primary() - .label("Comment") - .tooltip("Post comment") - .on_click(move |_event, window, cx| { - let content = comment_input.read(cx).value().trim().to_string(); - if content.is_empty() { - return; - } - let Some(root) = store - .read(cx) - .pull_requests - .iter() - .find(|pr| pr.id == id) - .cloned() - else { - return; - }; - store.update(cx, |store, cx| { - store.comment(&root, content, cx); - }); - comment_input.update(cx, |input, cx| { - input.set_value("", window, cx); - }); - }), - ), - ) - .into_any_element() - } - - /// Always-visible header: status badge and title, like the issue panel. + /// Always-visible header with a status badge and title, like the issue panel. fn render_header(&self, cx: &mut Context) -> AnyElement { let current_commit = self.current_commit.clone(); let (title, status, branch, author) = { @@ -1022,7 +562,7 @@ impl PullRequestDetailView { ) }; - // Only the PR author may publish revisions (kind 1619, NIP-34). + // Only the PR author may publish revisions, NIP-34 kind 1619. let backend = Backend::global(cx); let can_update = backend.read(cx).current_user() == Some(author); @@ -1104,8 +644,7 @@ impl PullRequestDetailView { } } -/// Open the "update pull request" dialog: a patch input that submits a new -/// revision through [`RepoStore::update_pull_request`] when confirmed. +/// Open the update pull request dialog. fn open_update_pull_request_dialog( store: Entity, root: Event, @@ -1115,8 +654,8 @@ fn open_update_pull_request_dialog( let patch = cx.new(|cx| { TextareaState::new(window, cx).placeholder("Paste the updated `git format-patch` output...") }); - // Both the dialog body and the submit button capture the root event; - // share it instead of cloning into each closure. + // Both the dialog body and submit button capture the root event. + // Share it instead of cloning into each closure. let root = Rc::new(root); window.open_dialog(cx, move |dialog, _window, _cx| { @@ -1166,20 +705,9 @@ fn open_update_pull_request_dialog( }); } -/// One sidebar section title. -fn sidebar_title(text: &str, cx: &App) -> AnyElement { - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().muted_foreground) - .child(text.to_string()) - .into_any_element() -} - -/// The `c` tag of a PR event (tip of the proposed branch), as hex. -fn current_commit_of(event: &Event) -> Option { - event - .tags +/// The `c` tag of a PR event, the commit the proposal points at. +fn current_commit_of(root: &Event) -> Option { + root.tags .iter() .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()), @@ -1187,8 +715,9 @@ fn current_commit_of(event: &Event) -> Option { }) } -/// The `merge-base` tag of a PR event (most recent common ancestor with the -/// target branch), as hex. +/// The `merge-base` tag of a PR event, as hex. +/// +/// The most recent common ancestor with the target branch. fn merge_base_of(event: &Event) -> Option { event .tags @@ -1199,8 +728,9 @@ fn merge_base_of(event: &Event) -> Option { }) } -/// The `clone` tag of a PR event (URLs where the proposed branch can be -/// fetched), or `None` if the PR has none. +/// The `clone` tag of a PR event. +/// +/// URLs where the proposed branch can be fetched, or `None` if the PR has none. fn clone_urls_of(event: &Event) -> Option> { event .tags @@ -1222,9 +752,7 @@ fn branch_name_of(event: &Event) -> Option { }) } -/// The latest PR update (kind 1619) revising `root`, found via its NIP-22 -/// `E` tag pointing at the root PR event. Only updates by the PR author -/// count: the tip of a PR is only mutable by its author (NIP-34). +/// The latest PR update, kind 1619, revising `root`. fn latest_update<'a>(events: impl Iterator, root: &Event) -> Option<&'a Event> { let root_hex = root.id.to_hex(); events @@ -1238,8 +766,9 @@ fn latest_update<'a>(events: impl Iterator, root: &Event) -> O .max_by_key(|e| e.created_at) } -/// One-line commit metadata for the commits list: author and relative time, -/// whichever is available. +/// One-line commit metadata for the commits list. +/// +/// Author and relative time, whichever is available. fn commit_meta(commit: &FileCommit) -> String { let author = commit.author.trim(); let time = commit.time > 0; @@ -1281,7 +810,7 @@ impl Focusable for PullRequestDetailView { impl Render for PullRequestDetailView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() - .image_cache(image_cache("pull-request-detail", MAX_IMAGES)) + .image_cache(gpui::retain_all("pull-request-detail")) .id("pull-request-detail") .size_full() .min_h_0() @@ -1357,8 +886,7 @@ mod tests { created_at, ) }; - // An update revising a different PR must be ignored even though it - // is newer. + // An update revising a different PR must be ignored even though it is newer. let unrelated = signed( Kind::GitPullRequestUpdate, vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")], @@ -1386,8 +914,8 @@ mod tests { .finalize(&other) .expect("signed event"); - // The tip of a PR is only mutable by its author: a newer update - // from anyone else must not win. + // The tip of a PR is only mutable by its author. + // A newer update from anyone else must not win. assert!(latest_update([&stranger, &root].into_iter(), &root).is_none()); } diff --git a/crates/workspace/src/views/repo_detail/pull_requests.rs b/crates/workspace/src/views/repo_detail/pull_requests.rs index e7e42ce..f5cab0e 100644 --- a/crates/workspace/src/views/repo_detail/pull_requests.rs +++ b/crates/workspace/src/views/repo_detail/pull_requests.rs @@ -1,7 +1,7 @@ use std::rc::Rc; use assets::CustomIconName; -use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; +use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle}; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, @@ -16,7 +16,6 @@ use gpui_component::{ use nostr::prelude::{EventId, Kind}; use signed_core::{RepoStatus, activity_subject}; use signed_state::{ProfileStore, RepoStore}; -use signed_ui::image_cache::{MAX_IMAGES, image_cache}; use signed_ui::{DropdownButton, SegmentButton, UserAvatar, placeholder, status_badge}; use utils::relative_time; @@ -25,12 +24,10 @@ use super::new_pull_request::open_new_pull_panel; use super::pull_request_detail::PullRequestDetailView; use super::send_patch::open_send_patch_panel; -/// Height of one pull request row in the virtual list; same layout as an -/// issue row. -const PR_ROW_HEIGHT: f32 = 73.; +/// Height of one pull request row in the virtual list. +const ROW_HEIGHT: f32 = 73.; -/// Status filter of the pull request list, chosen via the header's filter -/// buttons. +/// Status filter of the pull request list, chosen via the header's filter buttons. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PullRequestFilter { /// Every pull request, regardless of status. @@ -70,17 +67,11 @@ pub struct PullRequestsView { filter: PullRequestFilter, /// Per-row heights of the virtual list. item_sizes: Rc>>, - /// Number of rows [`Self::item_sizes`] was built for (the filtered - /// pull request count); rebuilt on change. + /// The filtered pull request count [`Self::item_sizes`] was built for. pr_len: usize, - /// Indices into the store's `pull_requests` matching [`Self::filter`] - /// (root PR events only; updates are revisions of the root); the - /// virtual list renders this slice. Rebuilt only when the store - /// version or the filter changes, keyed by [`Self::cache_key`]. + /// Indices into the store's `pull_requests` matching [`Self::filter`]. visible_prs: Vec, - /// Header counts `(total, open, closed, draft, merged)` of the root - /// pull requests only (revisions are not separate PRs), rebuilt with - /// [`Self::visible_prs`]. + /// Header counts `(total, open, closed, draft, merged)`. counts: (usize, usize, usize, usize, usize), /// Store version and filter the cached rows/counts were built from. cache_key: Option<(u64, PullRequestFilter)>, @@ -92,10 +83,11 @@ impl PullRequestsView { pub fn new( dock_area: WeakEntity, store: Entity, - repo_name: SharedString, _window: &mut Window, cx: &mut Context, ) -> Self { + let repo_name = store.read(cx).name(); + Self { focus_handle: cx.focus_handle(), dock_area, @@ -111,7 +103,7 @@ impl PullRequestsView { } } - /// Open the detail panel of `pr_id` at the bottom of the dock area. + /// Open the detail panel of `pr_id` in the dock area. fn open_pull_request_detail( &mut self, pr_id: EventId, @@ -133,12 +125,13 @@ impl PullRequestsView { }); dock_area.update(cx, |dock_area, cx| { - dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx); + add_center_panel(dock_area, panel_handle(panel), window, cx); }); } - /// Render one row of the pull request list; `ix` is the row index and - /// `pr_ix` the index of the pull request in the store's `pull_requests`. + /// Render one row of the pull request list. + /// + /// `ix` is the row index, `pr_ix` the index in the store's `pull_requests`. fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context) -> AnyElement { let pr = &self.store.read(cx).pull_requests[pr_ix]; let pr_id = pr.id; @@ -203,8 +196,7 @@ impl PullRequestsView { } fn render_header(&self, cx: &mut Context) -> AnyElement { - // Counts of the last list rebuild (`render` rebuilds first when the - // store version or filter changed, so this is never stale). + // Counts of the last list rebuild. let (total, open, closed, draft, merged) = self.counts; h_flex() @@ -339,9 +331,9 @@ impl Render for PullRequestsView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let filter = self.filter; - // Rebuild the filtered rows and header counts only when the store - // refreshed or the filter changed; other renders reuse the cache. + // Rows and counts are rebuilt only when the store refreshed or filter changed. let version = self.store.read(cx).version(); + if self.cache_key != Some((version, filter)) { let store = self.store.read(cx); let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize); @@ -350,43 +342,43 @@ impl Render for PullRequestsView { .iter() .enumerate() .filter_map(|(ix, pr)| { - // Kind-30620 patches are revisions of a root PR (NIP-34), - // not separate pull requests: count only root events, or - // the header counts inflate with every revision (which - // also default to `Open` in `status_of`). if pr.kind != Kind::GitPullRequest { return None; } + let status = store.status_of(pr); counts.0 += 1; + match status { RepoStatus::Open => counts.1 += 1, RepoStatus::Closed => counts.2 += 1, RepoStatus::Draft => counts.3 += 1, RepoStatus::Applied => counts.4 += 1, } + filter.matches(status).then_some(ix) }) .collect(); + self.counts = counts; self.cache_key = Some((version, filter)); } let count = self.visible_prs.len(); - // The virtual list's item count comes from `item_sizes`; rebuild it - // whenever the filtered pull request count changes. + // The virtual list's item count comes from `item_sizes`. + // Rebuild it whenever the filtered pull request count changes. if count != self.pr_len { self.pr_len = count; - self.item_sizes = Rc::new(vec![size(px(0.), px(PR_ROW_HEIGHT)); count]); + self.item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); count]); } let sizes = self.item_sizes.clone(); let scroll_handle = self.scroll_handle.clone(); let view = cx.entity().clone(); - // Non-fatal warnings and errors of the last action (e.g. creating - // or updating a PR), shown as dismissible banners above the list. + // Non-fatal warnings and errors of the last action, like creating or updating a PR. + // Shown as dismissible banners above the list. let (last_error, last_warning) = { let store = self.store.read(cx); (store.last_error.clone(), store.last_warning.clone()) @@ -394,7 +386,7 @@ impl Render for PullRequestsView { v_flex() .size_full() - .image_cache(image_cache("pull-requests", MAX_IMAGES)) + .image_cache(gpui::retain_all("pull-requests")) .on_action(cx.listener(|this, action: &RepoAction, window, cx| { if action == &RepoAction::SendPatch { open_send_patch_panel(this.dock_area.clone(), this.store.clone(), window, cx); diff --git a/crates/workspace/src/views/repo_detail/send_patch.rs b/crates/workspace/src/views/repo_detail/send_patch.rs index f9279f4..719d02c 100644 --- a/crates/workspace/src/views/repo_detail/send_patch.rs +++ b/crates/workspace/src/views/repo_detail/send_patch.rs @@ -1,11 +1,11 @@ -use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; +use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle}; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, - Subscription, WeakEntity, Window, div, px, + WeakEntity, Window, div, px, }; use gpui_base::{Button as BaseButton, StyledExt}; -use gpui_component::input::{Input, InputEvent, InputState, Textarea, TextareaState}; +use gpui_component::input::{Input, InputState, Textarea, TextareaState}; use gpui_component::scroll::ScrollableElement; use gpui_component::spinner::Spinner; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex}; @@ -19,17 +19,16 @@ pub struct SendPatchView { store: Entity, /// Display name of the repository, for the panel title. repo_name: SharedString, - /// Title input (required). + /// Title input, required. subject: Entity, - /// Description input (optional). + /// Description input, optional. description: Entity, - /// The pasted `git format-patch` output (required). + /// The pasted `git format-patch` output, required. patch: Entity, /// A submit is in flight. submitting: bool, - /// Error of the last submit attempt (keeps the panel open). + /// Error of the last submit attempt, it keeps the panel open. error: Option, - _subscriptions: Vec, } impl SendPatchView { @@ -41,22 +40,14 @@ impl SendPatchView { ) -> Self { let repo_name = store.read(cx).name(); let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title")); + let description = cx .new(|cx| TextareaState::new(window, cx).placeholder("Describe the change (optional)")); + let patch = cx.new(|cx| { TextareaState::new(window, cx).placeholder("diff --git a/file.txt b/file.txt\nindex 1234567..abcdefg 100644\n--- a/file.txt\n+++ b/file.txt") }); - // Re-evaluate the Send button's enabled state as the inputs change. - let subscriptions = vec![ - cx.subscribe(&subject, |_this, _state, _event: &InputEvent, cx| { - cx.notify(); - }), - cx.subscribe(&patch, |_this, _state, _event: &InputEvent, cx| { - cx.notify(); - }), - ]; - Self { focus_handle: cx.focus_handle(), dock_area, @@ -67,24 +58,23 @@ impl SendPatchView { patch, submitting: false, error: None, - _subscriptions: subscriptions, } } - /// Publish the pull request from the pasted patch. The store validates - /// synchronously (patch shape, per-part size, sign-in); on failure the - /// panel stays open with the error inline, on success it closes — async - /// publish failures surface in the pull request list's banner. + /// Publish the pull request from the pasted patch. fn submit(&mut self, window: &mut Window, cx: &mut Context) { if self.submitting { return; } + let subject = self.subject.read(cx).value().to_string(); let description = self.description.read(cx).value().to_string(); let patch = self.patch.read(cx).value().to_string(); + if patch.is_empty() { return; } + let store = self.store.clone(); let dock_area = self.dock_area.clone(); let entity = cx.entity().clone(); @@ -93,8 +83,7 @@ impl SendPatchView { self.error = None; cx.notify(); - // Errors the store detects before publishing are returned - // synchronously through `last_error`. + // Errors the store detects before publishing. let sync_error = store.update(cx, |store, cx| { store.open_pull_request( (!subject.is_empty()).then_some(subject), @@ -128,6 +117,7 @@ impl SendPatchView { } } }); + cx.notify(); } @@ -212,7 +202,7 @@ pub(super) fn open_send_patch_panel( let panel = cx.new(|cx| SendPatchView::new(dock_area.clone(), store, window, cx)); let _ = dock_area.update(cx, |dock_area, cx| { - dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx); + add_center_panel(dock_area, panel_handle(panel), window, cx); }); } diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 7008656..126669a 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -15,7 +15,6 @@ use gpui_component::{ }; use signed_core::Announcement; use signed_state::{ProfileStore, RepoListStore, Timestamp}; -use signed_ui::image_cache::{MAX_IMAGES, image_cache}; use signed_ui::{SegmentButton, UserAvatar}; use utils::relative_time; @@ -24,13 +23,13 @@ use super::open_repo_panel; const COLUMNS: usize = 2; const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.; -/// How many of the newest repositories the "Recent" sort shows. +/// How many of the newest repositories the `Recent` sort shows. const RECENT_COUNT: usize = 10; /// Sort of the explore list, chosen via the header's filter buttons. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] enum RepoFilter { - /// Every repository, newest first (the store's default order). + /// Every repository in the store's default order, newest first. All, #[default] /// Repositories ranked by total issues + pull requests + commits. @@ -40,15 +39,15 @@ enum RepoFilter { } impl RepoFilter { - /// Indices into the store's `announcements` included by this filter, in - /// display order, narrowed to repositories whose name (or id) contains - /// `query`; an empty query matches everything. + /// Indices into the store's `announcements` this filter includes, in display order. + /// + /// Narrowed to repositories whose name or id contains `query`. fn visible(self, store: &RepoListStore, query: &str) -> Vec { let announcements = &store.announcements; let mut indices: Vec = (0..announcements.len()).collect(); - // Narrow by the search query first, so "Recent" limits the matches - // and "Popular" ranks them. + // Narrow by the search query first. + // Recent then limits the matches and Popular ranks them. let query = query.trim().to_lowercase(); if !query.is_empty() { indices.retain(|&ix| { @@ -93,10 +92,9 @@ pub struct RepoListView { filter: RepoFilter, /// Per-row heights of the virtual list. item_sizes: Rc>>, - /// Number of rows [`Self::item_sizes`] was built for (the filtered repo count). + /// Number of rows [`Self::item_sizes`] was built for, the filtered repo count. repo_len: usize, - /// Indices into the store's `announcements` matching [`Self::filter`], - /// in display order; the virtual list renders this slice. + /// Indices matching [`Self::filter`] into the store's `announcements`. visible: Vec, /// Search box filtering repositories by name. search: Entity, @@ -121,8 +119,8 @@ impl RepoListView { } }); - // Keep the visible slice and row sizes in sync with the store, - // so newly announced repositories appear without waiting for a click. + // Keep the visible slice and row sizes in sync with the store. + // Newly announced repositories appear without waiting for a click. let subscription = cx.observe(&store, |this, _store, cx| { this.rebuild_rows(cx); }); @@ -141,16 +139,17 @@ impl RepoListView { _subscription: subscription, }; - // Seed the rows right away; the store may already hold announcements - // (it loaded before the panel opened), and the first render must not - // depend on a later store update. + // Seed the rows right away. + // The store may already hold announcements from before the panel opened. + // The first render must not depend on a later store update. this.rebuild_rows(cx); this } - /// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the current - /// store contents, [`Self::filter`] and the search query. + /// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the store. + /// + /// Uses the store contents, [`Self::filter`] and the search query. fn rebuild_rows(&mut self, cx: &mut Context) { let filter = self.filter; let query = self.search.read(cx).value(); @@ -188,12 +187,14 @@ impl RepoListView { let name = announcement .name - .clone() - .unwrap_or_else(|| SharedString::from(announcement.id.clone())); + .as_deref() + .map(SharedString::from) + .unwrap_or(SharedString::from(announcement.id.clone())); let description = announcement .description - .clone() + .as_deref() + .map(SharedString::from) .unwrap_or(SharedString::from("No description")); let activity = last_activity @@ -201,8 +202,8 @@ impl RepoListView { .map(|label| SharedString::from(format!("Updated {label}"))) .unwrap_or_default(); - // Fork badge: the upstream's display name when its announcement is - // known locally, otherwise its repository id from the `u` tag. + // The fork badge shows the upstream name when its announcement is known locally. + // Otherwise it shows the repository id from the `u` tag. let fork_label: Option = announcement.upstream.as_ref().and_then(|upstream| { let addr = upstream.addr.as_ref()?; @@ -214,7 +215,8 @@ impl RepoListView { .find(|a| a.addr() == *addr) .map(|a| { a.name - .clone() + .as_deref() + .map(SharedString::from) .unwrap_or_else(|| SharedString::from(a.id.clone())) }) .unwrap_or_else(|| SharedString::from(addr.identifier.clone())); @@ -347,8 +349,7 @@ impl RepoListView { .into_any_element() } - /// One segmented filter button of the header, styled like the issues - /// list's status filter buttons. + /// One segmented header filter button, like the issues list's status filter buttons. fn filter_button( &self, filter: RepoFilter, @@ -398,7 +399,7 @@ impl Render for RepoListView { v_flex() .relative() - .image_cache(image_cache("repos", MAX_IMAGES)) + .image_cache(gpui::retain_all("repos")) .size_full() .child(self.render_header(count, cx)) .when(!has_repos, |this| { diff --git a/crates/workspace/src/views/sidebar/create_repo_dialog.rs b/crates/workspace/src/views/sidebar/create_repo_dialog.rs index bad974f..a4723f0 100644 --- a/crates/workspace/src/views/sidebar/create_repo_dialog.rs +++ b/crates/workspace/src/views/sidebar/create_repo_dialog.rs @@ -1,31 +1,26 @@ +use std::path::PathBuf; + use dock::DockArea; use gpui::prelude::*; -use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px}; +use gpui::{App, Entity, PathPromptOptions, 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::{ActiveTheme, Disableable, IconName, WindowExt, h_flex}; +use gpui_component::{Disableable, IconName, WindowExt, h_flex}; use settings::SettingsStore; use signed_core::Announcement; -use signed_state::Backend; +use signed_state::{Backend, CheckoutsStore}; use super::super::open_repo_panel; use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers}; +use crate::views::dialog_state::{DialogProgress, error_row}; /// Shared state for the Create Repository dialog, so async results can be rendered. -#[derive(Default)] -pub struct CreateRepoState { - pub busy: bool, - pub error: Option, -} +pub type CreateRepoState = DialogProgress; /// Open the Create Repository dialog. -/// -/// The dialog loads the user's default grasp servers (kind `10317` grasp -/// list) and falls back to the shared defaults when none are set. On -/// success the dialog closes and the new repository opens in the dock. pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) { let settings = SettingsStore::global(cx); let default_folder = settings @@ -56,7 +51,7 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) window.open_dialog(cx, move |dialog, _window, _cx| { const DESC: &str = "Publish a new repository to your grasp servers."; - const FOLDER_NOTE: &str = "Where the repository is stored."; + const FOLDER_NOTE: &str = "Where the repository's working copy is created."; let name_input = name_input.clone(); let desc_input = desc_input.clone(); @@ -119,9 +114,7 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) ) .child(grasp_servers_field(&grasp_state, &relay_input, cx)), ) - .children(error.map(|message| { - div().text_sm().text_color(cx.theme().danger).child(message) - })) + .children(error_row(&error, cx)) .child( DialogFooter::new().justify_end().child( Button::new("create") @@ -134,6 +127,7 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) .on_click({ let name_input = name_input.clone(); let desc_input = desc_input.clone(); + let folder_input = folder_input.clone(); let state = state.clone(); let grasp_state = grasp_state.clone(); let dock_area = dock_area.clone(); @@ -142,6 +136,7 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) create_repository( name_input.clone(), desc_input.clone(), + folder_input.clone(), state.clone(), grasp_state.clone(), dock_area.clone(), @@ -156,10 +151,7 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) }); } -/// Prompt the user to pick the folder the repository will be stored in, using -/// the platform's native folder picker, and show the result in the disabled -/// folder input. The picked folder is remembered in the settings so it -/// becomes the default next time. +/// Pick the repository's storage folder with the platform's native folder picker. fn choose_folder(folder_input: &Entity, window: &mut Window, cx: &mut App) { let handle = window.window_handle(); let folder_input = folder_input.clone(); @@ -194,10 +186,14 @@ fn choose_folder(folder_input: &Entity, window: &mut Window, cx: &mu .detach(); } -/// Run the create-repository flow; closes the dialog and opens the new repository on success. +/// Run the create-repository flow. +/// +/// Opens the new working copy and the repository panel on success. +#[allow(clippy::too_many_arguments)] fn create_repository( name_input: Entity, desc_input: Entity, + folder_input: Entity, state: Entity, grasp_state: Entity, dock_area: WeakEntity, @@ -206,48 +202,47 @@ fn create_repository( ) { let name = name_input.read(cx).value().trim().to_owned(); let description = desc_input.read(cx).value().trim().to_owned(); + let folder = PathBuf::from(folder_input.read(cx).value().trim()); let servers = grasp_state.read(cx).grasp_servers.clone(); if name.is_empty() { - state.update(cx, |state, _| { - state.error = Some("Repository name is required".into()); - }); + state.update(cx, |state, _| state.fail("Repository name is required")); return; } if servers.is_empty() { - state.update(cx, |state, _| { - state.error = Some("Add at least one grasp server".into()); - }); + state.update(cx, |state, _| state.fail("Add at least one grasp server")); return; } - state.update(cx, |state, _| { - state.busy = true; - state.error = None; - }); + state.update(cx, |state, _| state.begin()); let backend = Backend::global(cx); let task = backend.update(cx, |backend, cx| { - backend.create_repository(&name, &description, servers, cx) + backend.create_repository(&name, &description, folder, 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) => { + Ok((announcement, local_path)) => { cx.update_window(handle, |_, window, cx| { window.close_dialog(cx); + // Record the new working copy as a checkout of this repository. + // The New PR panel then pre-fills it. + let checkouts = CheckoutsStore::global(cx); + checkouts.update(cx, |store, cx| { + store.record(local_path.clone(), announcement.addr(), cx); + }); + cx.open_with_system(&local_path); 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()); - }); + state.update(cx, |state, _| state.fail(e.to_string())); }) .ok(); } diff --git a/crates/workspace/src/views/sidebar/grasp_servers.rs b/crates/workspace/src/views/sidebar/grasp_servers.rs index fdfe212..cd5a2a2 100644 --- a/crates/workspace/src/views/sidebar/grasp_servers.rs +++ b/crates/workspace/src/views/sidebar/grasp_servers.rs @@ -6,27 +6,24 @@ use gpui_component::input::{Input, InputState}; use gpui_component::{ActiveTheme, IconName, Sizable, h_flex, v_flex}; use nostr::prelude::*; use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings}; -use signed_core::filters; use signed_state::Backend; -/// State of the grasp-server section of a publish dialog, so async -/// results can be rendered. +/// State of the grasp-server section of a publish dialog, so async results can be rendered. #[derive(Default)] pub struct GraspServersState { - /// The user's grasp list (kind `10317`) is being loaded. + /// The user's grasp list of kind `10317` is being loaded. pub loading_servers: bool, pub grasp_servers: Vec, - /// Whether the grasp server section is shown; defaults to shown. + /// Whether the grasp server section is shown. Defaults to shown. pub servers_enabled: bool, - /// Error of the last grasp-server edit (e.g. an invalid relay URL). + /// Error of the last grasp-server edit, an invalid relay URL for example. pub error: Option, } impl GraspServersState { - /// Defaults until the user's grasp list arrives; replaced by it when it lists any servers. + /// Defaults used until the user's grasp list loads, which replaces them when non-empty. /// - /// The servers come from the persisted settings, falling back to the - /// built-in defaults when the configured list is empty. + /// Persisted settings supply the defaults, an empty list falls back to the built-ins. pub fn new_default(settings: &GraspServersSettings) -> Self { let urls: Vec = if settings.default_servers.is_empty() { DEFAULT_GRASP_SERVERS @@ -48,10 +45,7 @@ impl GraspServersState { } } -/// The "Grasp servers" form field shared by the publish dialogs: an -/// expandable toggle, the configured servers (each removable) and an -/// add-relay input, with a loading hint while the user's grasp list -/// (kind `10317`) is being fetched. +/// The Grasp servers form field shared by the publish dialogs. pub fn grasp_servers_field( state: &Entity, relay_input: &Entity, @@ -143,7 +137,7 @@ pub fn grasp_servers_field( })) } -/// A grasp server row: the host as a tag plus a remove button. +/// One grasp server row, the host in a tag plus a remove button. fn render_server_row( ix: usize, relay: &RelayUrl, @@ -182,7 +176,7 @@ fn render_server_row( ) } -/// The bare host of a grasp server (defaults are entered without a scheme). +/// The bare host of a grasp server, defaults are entered without a scheme. fn display_server(relay: &RelayUrl) -> SharedString { relay .domain() @@ -190,7 +184,7 @@ fn display_server(relay: &RelayUrl) -> SharedString { .unwrap_or_else(|| SharedString::from(relay.to_string())) } -/// Parse the relay input (accepting a bare host) and append it to the list. +/// Parse the relay input, accepting a bare host, and append it to the list. fn add_relay( state: &Entity, input: &Entity, @@ -226,8 +220,9 @@ fn add_relay( } } -/// Load the user's grasp list (kind `10317`) from the local database and -/// replace the defaults with it when it lists any servers. +/// Load the user's grasp list of kind `10317` from the local database. +/// +/// It replaces the defaults when it lists any servers. pub fn load_user_grasp_servers( state: Entity, window: &mut Window, @@ -242,30 +237,7 @@ pub fn load_user_grasp_servers( 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 result = signed_state::user_grasp_list_servers(client, user).await; let _ = cx.update_window(handle, |_, _window, cx| { state.update(cx, |state, _| { diff --git a/crates/workspace/src/views/sidebar/import_dialog.rs b/crates/workspace/src/views/sidebar/import_dialog.rs index cfe8c37..992673d 100644 --- a/crates/workspace/src/views/sidebar/import_dialog.rs +++ b/crates/workspace/src/views/sidebar/import_dialog.rs @@ -2,8 +2,6 @@ use gpui::{App, Window, px}; use gpui_component::WindowExt; /// Open the Import Identity dialog. -/// -/// Currently a placeholder — the dialog only shows a title for now. pub fn open(window: &mut Window, cx: &mut App) { window.open_dialog(cx, move |dialog, _window, _cx| { dialog.title("Import identity").width(px(400.)) diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index e9973f9..523bc10 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -1,22 +1,26 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::ops::Range; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use assets::CustomIconName; -use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, TAB_BAR_HEIGHT, panel_handle}; +use dock::{ + BasePanel, DockArea, Panel, PanelEvent, TAB_BAR_HEIGHT, add_center_panel, panel_handle, +}; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ObjectFit, Render, - SharedString, Subscription, WeakEntity, Window, div, img, px, uniform_list, + AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ObjectFit, Render, + SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list, white, }; use gpui_base::Button as BaseButton; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::InputState; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; -use signed_core::{Announcement, identifier_from_name}; -use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore, RepoListStore}; -use signed_ui::image_cache::{MAX_IMAGES, image_cache}; +use signed_core::{Announcement, RepoAddr, identifier_from_name}; +use signed_state::{ + Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore, +}; use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers}; use super::{RepoDetailView, RepoListView, open_repo_panel}; @@ -30,87 +34,176 @@ mod settings_dialog; use self::onboarding_dialog::OnboardingState; -/// Left-dock panel with navigation entries. Entries open content panels in -/// the dock area. pub struct SidebarPanel { focus_handle: FocusHandle, dock_area: WeakEntity, explore: Option>, - logged_in: bool, - /// Repositories announced by the current user, listed under - /// "All Repositories". Recreated when the signer changes. - my_repos: Option>, - /// Observes the current user's repo store so the list re-renders. - my_repos_subscription: Option, - /// Banner artwork shown behind the sign-in screen, - /// picked at random from the bundled `backgrounds/` assets. + /// Artwork for the sign-in screen. banner: SharedString, - /// Observes the local-repository scan so new discoveries re-render. - _local_repos_subscription: Subscription, - _subscription: Subscription, + /// The signed-in user's announced repositories, newest first. + announcements: Arc>, + /// Local repositories found by the scan that are not announced yet. + local_repos: Arc>, + /// A local scan is currently running. + scanning: bool, + /// Unpushed local commits per announced repository, the row badge counts. + unpushed: HashMap, + _subscriptions: Vec, } impl SidebarPanel { pub fn new(dock_area: WeakEntity, cx: &mut Context) -> Self { - let local_repos_store = LocalReposStore::global(cx); let backend = Backend::global(cx); - let logged_in = backend.read(cx).current_user().is_some(); + let repos = RepoListStore::global(cx); + let local = LocalReposStore::global(cx); + let checkouts = CheckoutsStore::global(cx); - let subscription = cx.subscribe(&backend, |this, backend, event, cx| { - 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.banner = pick_banner(); - this.my_repos = None; - this.my_repos_subscription = None; - } - _ => return, + let mut subscriptions = Vec::new(); + + // Identity changes swap the whole sidebar between the sign-in screen and the signed-in content. + subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| { + let signer_changed = matches!(event, BackendEvent::SignerChanged); + let signer_required = matches!(event, BackendEvent::SignerRequired); + + if !signer_changed && !signer_required { + return; } - cx.notify(); - }); - let local_repos_subscription = cx.observe(&local_repos_store, |_, _, cx| { - cx.notify(); - }); + if signer_required { + this.banner = pick_banner(); + } - let mut panel = Self { + if this.refresh(cx) || signer_required { + cx.notify(); + } + })); + + // The merged list re-derives when announcements or the local scan change. + subscriptions.push(cx.observe(&repos, |this, _repos, cx| { + if this.refresh(cx) { + cx.notify(); + } + })); + + // The local scan re-derives when announcements or the local scan change. + subscriptions.push(cx.observe(&local, |this, _local, cx| { + if this.refresh(cx) { + cx.notify(); + } + })); + + // Push statuses are recomputed in the background; only the badge counts change. + subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| { + if this.refresh_unpushed(cx) { + cx.notify(); + } + })); + + let mut this = Self { focus_handle: cx.focus_handle(), dock_area, - logged_in, explore: None, - my_repos: None, - my_repos_subscription: None, banner: pick_banner(), - _local_repos_subscription: local_repos_subscription, - _subscription: subscription, + announcements: Arc::new(Vec::new()), + local_repos: Arc::new(Vec::new()), + scanning: false, + unpushed: HashMap::new(), + _subscriptions: subscriptions, }; - if logged_in { - panel.refresh_my_repos(cx); - } + // Seed the snapshot right away. + // The stores may already hold data from before the panel opened. + // The first render must not depend on a later store update. + this.refresh(cx); - panel + this } - /// (Re)create the store listing the current user's repositories. - fn refresh_my_repos(&mut self, cx: &mut Context) { - self.my_repos_subscription = None; - + /// The sidebar renders only its own derived fields, never the stores + /// directly. Because the panel is a cached view, a store update alone does + /// not re-render it: the observers notify this panel, which re-runs + /// `render` over the fresh snapshot. + /// + /// Returns `true` when a rendered field changed. + fn refresh(&mut self, cx: &mut Context) -> bool { let backend = Backend::global(cx); - let author = backend.read(cx).current_user(); - self.my_repos = author.map(|author| cx.new(|cx| RepoListStore::new(Some(author), cx))); + let user = backend.read(cx).current_user(); - if let Some(store) = self.my_repos.as_ref() { - self.my_repos_subscription = Some(cx.observe(store, |_, _, cx| cx.notify())); + let repo_list = RepoListStore::global(cx); + let announcements = user + .as_ref() + .map(|user| repo_list.read(cx).announcements_of(user)) + .unwrap_or_default(); + + // A scanned repository is dropped from the local list + // once the user announces it, so it is not listed twice. + let local = LocalReposStore::global(cx); + let scanning = local.read(cx).scanning; + + let local_repos = { + let ids: HashSet = announcements.iter().map(|a| a.id.clone()).collect(); + local + .read(cx) + .repos + .iter() + .filter(|path| { + let Some(name) = path.file_name() else { + return true; + }; + !ids.contains(&identifier_from_name(&name.to_string_lossy())) + }) + .cloned() + .collect() + }; + + let announcements_changed = *self.announcements != announcements; + let local_changed = *self.local_repos != local_repos; + let scanning_changed = self.scanning != scanning; + + self.announcements = Arc::new(announcements); + self.local_repos = Arc::new(local_repos); + self.scanning = scanning; + + if announcements_changed { + self.request_push_watches(cx); + self.unpushed.clear(); } + + announcements_changed || local_changed || scanning_changed } - /// Open the Explore (repository list) panel in the center of the dock - /// area. No-op if it's already open. + /// Recompute the badge counts from the global checkouts store's ready-to-push statuses + fn refresh_unpushed(&mut self, cx: &mut Context) -> bool { + let checkouts = CheckoutsStore::global(cx).read(cx); + let mut unpushed = HashMap::with_capacity(self.announcements.len()); + + for announcement in self.announcements.iter() { + let addr = announcement.addr(); + let count = checkouts.unpushed(&addr); + if count > 0 { + unpushed.insert(addr, count); + } + } + + if unpushed == self.unpushed { + return false; + } + + self.unpushed = unpushed; + true + } + + /// Keep the `ready to push` statuses of the announced repositories current. + fn request_push_watches(&self, cx: &mut Context) { + let checkouts = CheckoutsStore::global(cx); + checkouts.update(cx, |checkouts, cx| { + for announcement in self.announcements.iter() { + checkouts.request_push_statuses(&announcement.addr(), cx); + } + }); + } + + /// Open the Explore repository list panel in the dock area's center. pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context) { if self .explore @@ -125,7 +218,7 @@ impl SidebarPanel { self.explore = Some(panel.downgrade()); let _ = self.dock_area.update(cx, |dock_area, cx| { - dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx); + add_center_panel(dock_area, panel_handle(panel), window, cx); }); } @@ -162,32 +255,24 @@ impl SidebarPanel { open_repo_panel(&self.dock_area, announcement, window, &mut *cx); } - /// Open a local repository's detail view in the dock's center; the - /// detail view offers to publish it to NIP-34. + /// Open a local repository's detail view in the dock's center. + /// + /// The detail view offers to publish it to NIP-34. fn open_local_repo(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context) { let detail = cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, window, cx)); - let _ = self.dock_area.update(cx, |dock_area, cx| { - dock_area.add_panel_view( - panel_handle(detail), - DockPlacement::Center, - None, - window, - cx, - ); - }); + self.dock_area + .update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(detail), window, cx); + }) + .ok(); } - /// The "All Repositories" section: header with the create button and - /// the current user's repositories below it, lazily rendered through a - /// [`uniform_list`], followed by the local git repositories discovered - /// by the startup scan. - fn render_my_repos(&self, cx: &mut Context) -> impl IntoElement { - let store = self.my_repos.as_ref(); - let local = LocalReposStore::global(cx); - let local_repos = local.read(cx).repos.clone(); - let scanning = local.read(cx).scanning; + fn render_repos(&self, cx: &mut Context) -> impl IntoElement { + let announcements = self.announcements.clone(); + let local_repos = self.local_repos.clone(); + let scanning = self.scanning; v_flex() .px_2() @@ -234,58 +319,36 @@ impl SidebarPanel { ), ), ) - .when_some(store, |builder, store| { - let announcements = store.read(cx).announcements.clone(); - // Local repositories that have already been published to - // NIP-34 are listed among the user's repositories above; - // hide them from the local section (matched by the - // identifier derived from the directory name, like the - // init dialog's default name). - let announced_ids: HashSet = - announcements.iter().map(|a| a.id.clone()).collect(); - let local_repos: Vec = local_repos - .iter() - .filter(|path| { - let Some(name) = path.file_name() else { - return true; - }; - !announced_ids.contains(&identifier_from_name(&name.to_string_lossy())) - }) - .cloned() - .collect(); - // One merged list: the user's NIP-34 repositories first, - // then the local repositories discovered by the scan. + .map(|this| { + // Merged list, the user's NIP-34 repositories and local repositories discovered. let total = announcements.len() + local_repos.len(); if total == 0 { - builder.child( + this.child( div() .flex_1() .px_2() .py_1() .text_xs() .text_color(cx.theme().muted_foreground) - .child(if scanning { - "Scanning for local repositories…" - } else { - "No repositories yet" + .map(|this| { + if scanning { + this.child("Scanning for local repositories…") + } else { + this.child("No repositories yet") + } }), ) } else { - builder.child( + this.child( uniform_list( "repos", total, - cx.processor(move |this, range: Range, _window, cx| { + cx.processor(move |this, range: Range, _, cx| { range .map(|ix| { - this.render_repo_row_at( - &announcements, - &local_repos, - ix, - cx, - ) - .into_any_element() + this.render_repo_at(&announcements, &local_repos, ix, cx) + .into_any_element() }) .collect() }), @@ -297,9 +360,8 @@ impl SidebarPanel { }) } - /// One row of the merged sidebar list: a NIP-34 repository or a local - /// repository. - fn render_repo_row_at( + /// One row of the merged sidebar list, a NIP-34 or a local repository. + fn render_repo_at( &self, announcements: &[Announcement], local_repos: &[PathBuf], @@ -323,42 +385,60 @@ impl SidebarPanel { announcement: &Announcement, cx: &mut Context, ) -> impl IntoElement { - let name = announcement - .name - .clone() - .unwrap_or_else(|| SharedString::from(announcement.id.clone())); + let name = announcement.name().map(SharedString::from); let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); let announcement = announcement.clone(); - NavItem::new(format!("my-repo:{}", announcement.id), name, avatar).on_click( + // Badge with the unpushed commit count of the repository's local checkouts. + let unpushed = self + .unpushed + .get(&announcement.addr()) + .copied() + .unwrap_or(0); + + let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar); + + if unpushed > 0 { + row = row.suffix( + v_flex() + .flex_shrink_0() + .size_4() + .items_center() + .justify_center() + .rounded_full() + .line_height(relative(1.)) + .bg(cx.theme().red_light) + .text_color(white()) + .text_size(px(8.)) + .child(SharedString::from(unpushed.to_string())), + ); + } + + row.on_click( cx.listener(move |this, _ev, window, cx| this.open_repo(&announcement, window, cx)), ) } - /// One local repository row: a deterministic pixel avatar seeded from - /// the path, the directory name, and a warning suffix marking it as - /// not yet set up for NIP-34. Clicking it opens the repository's - /// detail view, which offers to initialize it. + /// One local repository row. + /// + /// The directory name and a warning suffix, the repo is not yet set up for NIP-34. fn render_local_row(&self, path: &Path, cx: &mut Context) -> impl IntoElement { let name = path .file_name() .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()); + .unwrap_or("Untitled".into()); let path = path.to_path_buf(); + let avatar = PixelAvatar::new(path.to_string_lossy()); - NavItem::new( - format!("local-repo:{}", path.display()), - name, - PixelAvatar::new(path.to_string_lossy()), - ) - .suffix( - Icon::new(IconName::TriangleAlert) - .small() - .text_color(cx.theme().warning), - ) - .on_click(cx.listener(move |this, _ev, window, cx| { - this.open_local_repo(path.clone(), window, cx); - })) + NavItem::new(format!("local-repo:{}", path.display()), name, avatar) + .suffix( + Icon::new(IconName::TriangleAlert) + .small() + .text_color(cx.theme().warning), + ) + .on_click(cx.listener(move |this, _ev, window, cx| { + this.open_local_repo(path.clone(), window, cx); + })) } /// Show the Import Identity dialog. @@ -366,7 +446,7 @@ impl SidebarPanel { import_dialog::open(window, cx); } - /// Render the user avatar and name in the sidebar, wrapped in the window titlebar drag area. + /// Render the user avatar and name in the sidebar, inside the titlebar drag area. fn render_user( &self, profile: &Profile, @@ -396,8 +476,7 @@ impl SidebarPanel { ) } - /// Sign-in placeholder shown while logged out: banner artwork behind a - /// scrim so the CTA buttons stay readable in both themes. + /// Sign-in placeholder shown while logged out. fn render_sign_in(&self, window: &mut Window, cx: &mut Context) -> Div { v_flex() .size_full() @@ -503,10 +582,6 @@ impl Focusable for SidebarPanel { impl Render for SidebarPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - if !self.logged_in { - return self.render_sign_in(window, cx); - } - let backend = Backend::global(cx); let profile_store = ProfileStore::global(cx); @@ -515,10 +590,14 @@ impl Render for SidebarPanel { .current_user() .map(|public_key| profile_store.read(cx).get(&public_key)); + if profile.is_none() { + return self.render_sign_in(window, cx); + } + v_flex() .size_full() .justify_between() - .image_cache(image_cache("sidebar", MAX_IMAGES)) + .image_cache(gpui::retain_all("sidebar")) .bg(cx.theme().sidebar) .text_color(cx.theme().sidebar_foreground) .child( @@ -561,7 +640,7 @@ impl Render for SidebarPanel { )), ), ) - .child(self.render_my_repos(cx)), + .child(self.render_repos(cx)), ) .child( v_flex() diff --git a/crates/workspace/src/views/sidebar/onboarding_dialog.rs b/crates/workspace/src/views/sidebar/onboarding_dialog.rs index 20112f8..8f32e51 100644 --- a/crates/workspace/src/views/sidebar/onboarding_dialog.rs +++ b/crates/workspace/src/views/sidebar/onboarding_dialog.rs @@ -1,24 +1,18 @@ use gpui::prelude::*; -use gpui::{App, Entity, SharedString, Window, div, px}; +use gpui::{App, Entity, Window, px}; 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}; -use gpui_component::{ActiveTheme, Disableable, WindowExt}; +use gpui_component::{Disableable, WindowExt}; use signed_state::Backend; +use crate::views::dialog_state::{DialogProgress, error_row}; + /// Shared state for the Onboarding dialog, so async results can be rendered. -#[derive(Default)] -pub struct OnboardingState { - pub busy: bool, - pub error: Option, -} +pub type OnboardingState = DialogProgress; /// Open the Onboarding dialog for creating a new identity. -/// -/// The caller is responsible for creating the input and state entities and -/// passing them in. This function only builds the dialog UI and wires up -/// the continue-button handler. pub fn open( name_input: Entity, pass_input: Entity, @@ -66,9 +60,7 @@ pub fn open( ) .child(field().required(true).child(Input::new(&repass_input))), ) - .children(error.map(|message| { - div().text_sm().text_color(cx.theme().danger).child(message) - })) + .children(error_row(&error, cx)) .child( DialogFooter::new().justify_end().child( Button::new("continue") @@ -91,17 +83,12 @@ pub fn open( if pass != repass { state.update(cx, |state, _| { - state.busy = false; - state.error = - Some("Passphrases do not match".into()); + state.fail("Passphrases do not match"); }); return; } - state.update(cx, |state, _| { - state.busy = true; - state.error = None; - }); + state.update(cx, |state, _| state.begin()); let task = backend.update(cx, |backend, cx| { backend.create_identity(&name, &pass, cx) @@ -119,8 +106,7 @@ pub fn open( Err(e) => { cx.update_window(handle, |_, _window, cx| { state.update(cx, |state, _| { - state.busy = false; - state.error = Some(e.to_string().into()); + state.fail(e.to_string()); }); }) .ok(); diff --git a/crates/workspace/src/views/sidebar/passphrase_dialog.rs b/crates/workspace/src/views/sidebar/passphrase_dialog.rs index ead19f3..a076352 100644 --- a/crates/workspace/src/views/sidebar/passphrase_dialog.rs +++ b/crates/workspace/src/views/sidebar/passphrase_dialog.rs @@ -1,26 +1,25 @@ use assets::CustomIconName; use gpui::prelude::*; -use gpui::{AnyWindowHandle, App, Entity, SharedString, Subscription, Window, div}; +use gpui::{AnyWindowHandle, App, Entity, Subscription, Window}; 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, InputEvent, InputState}; -use gpui_component::{ActiveTheme, Disableable, WindowExt}; +use gpui_component::{Disableable, WindowExt}; use signed_state::Backend; +use crate::views::dialog_state::{DialogProgress, error_row}; + /// Shared state for the passphrase dialog, so async results can be rendered. #[derive(Default)] pub struct PassphraseState { - pub busy: bool, - pub error: Option, + /// Progress of the unlock flow. + pub progress: DialogProgress, /// Keeps the Enter-to-submit subscription alive while the dialog is open. _enter_subscription: Option, } -/// Open the dialog asking for the passphrase that protects the stored -/// NIP-49 encrypted identity (`ncryptsec1...`). -/// -/// Called when the backend emits [`signed_state::BackendEvent::PassphraseRequired`]. +/// Open the dialog asking for the passphrase that protects the stored identity. pub fn open(window: &mut Window, cx: &mut App) { let pass_input = cx.new(|cx| { InputState::new(window, cx) @@ -53,8 +52,8 @@ pub fn open(window: &mut Window, cx: &mut App) { .overlay_closable(false) .keyboard(false) .content(move |content, _window, cx| { - let busy = state.read(cx).busy; - let error = state.read(cx).error.clone(); + let busy = state.read(cx).progress.busy; + let error = state.read(cx).progress.error.clone(); content .child( @@ -73,9 +72,7 @@ pub fn open(window: &mut Window, cx: &mut App) { .child(Input::new(&pass_input)), ), ) - .children(error.map(|message| { - div().text_sm().text_color(cx.theme().danger).child(message) - })) + .children(error_row(&error, cx)) .child( DialogFooter::new().justify_end().child( Button::new("unlock") @@ -98,8 +95,7 @@ pub fn open(window: &mut Window, cx: &mut App) { }); } -/// Submit the passphrase to the backend. On success the dialog is closed; -/// on failure the error is rendered inline and the dialog stays open. +/// Submit the passphrase to the backend. fn unlock( pass_input: &Entity, state: &Entity, @@ -111,15 +107,12 @@ fn unlock( if pass.is_empty() { state.update(cx, |state, _| { - state.error = Some("Passphrase must not be empty".into()); + state.progress.fail("Passphrase must not be empty"); }); return; } - state.update(cx, |state, _| { - state.busy = true; - state.error = None; - }); + state.update(cx, |state, _| state.progress.begin()); let task = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx)); let handle = *handle; @@ -134,10 +127,7 @@ fn unlock( } Err(e) => { cx.update_window(handle, |_this, _window, cx| { - state.update(cx, |state, _| { - state.busy = false; - state.error = Some(e.to_string().into()); - }); + state.update(cx, |state, _| state.progress.fail(e.to_string())); }) .ok(); } diff --git a/crates/workspace/src/views/sidebar/settings_dialog.rs b/crates/workspace/src/views/sidebar/settings_dialog.rs index 2d58011..b8c13ad 100644 --- a/crates/workspace/src/views/sidebar/settings_dialog.rs +++ b/crates/workspace/src/views/sidebar/settings_dialog.rs @@ -1,10 +1,3 @@ -//! The Settings dialog, opened from the sidebar's Settings entry. -//! -//! A custom settings layout that divides related settings into sections -//! separated by simple horizontal lines — no `GroupBox` boxes and no settings -//! navigation sidebar. Every control edits the persisted [`SettingsStore`] -//! and applies the change to the live theme immediately. - use std::cell::Cell; use std::path::PathBuf; use std::rc::Rc; @@ -55,8 +48,7 @@ fn theme_options(cx: &App) -> (Vec, Vec) { (light, dark) } -/// Stateful controls of the settings dialog, created once when it opens so -/// their values survive re-renders of the dialog content. +/// Stateful controls of the settings dialog, created once when it opens. struct SettingsControls { appearance: Entity>>, light_theme: Entity>>, @@ -66,8 +58,7 @@ struct SettingsControls { radius: Entity, radius_lg: Entity, grasp_server_input: Entity, - /// The effective default create-repository folder, shown in the disabled - /// folder selector. + /// The effective default create-repository folder, shown in the disabled input. default_folder: Entity, /// Keeps the control subscriptions alive for the dialog's lifetime. _subscriptions: Vec, @@ -276,28 +267,19 @@ impl SettingsControls { /// Open the Settings dialog. pub fn open(window: &mut Window, cx: &mut App) { let controls = Rc::new(SettingsControls::new(window, cx)); - let store = SettingsStore::global(cx); - let window_handle = window.window_handle(); - let store_subscription = cx.observe(&store, move |_, cx| { - window_handle - .update(cx, |_, window, _| window.refresh()) - .ok(); - }); - - let dialog_state = Rc::new((controls, store_subscription)); window.open_dialog(cx, move |dialog, _window, cx| { - let dialog_state = dialog_state.clone(); + let controls = controls.clone(); dialog .title("Settings") .width(px(650.)) .h(px(560.)) - .child(settings_view(&dialog_state.0, cx)) + .child(settings_view(&controls, cx)) }); } -/// The settings content: one section per related setting, divided by -/// horizontal separator lines. +/// The settings content, one section per related setting. +/// Sections are divided by horizontal separator lines. fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement { let store = SettingsStore::global(cx); let settings = store.read(cx).settings().clone(); @@ -325,8 +307,7 @@ fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement )) } -/// Theme configuration: the theme names in the registry plus -/// the visual tweaks the application customizes at startup. +/// Theme configuration, the registry theme names plus tweaks the app customizes at startup. fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement { v_flex() .gap_3() @@ -397,7 +378,7 @@ fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> )) } -/// The default grasp servers offered when the user hasn't published a grasp list (kind `10317`) yet. +/// Default grasp servers offered until the user publishes a kind `10317` grasp list. fn grasp_servers_section( settings: &Settings, controls: &SettingsControls, @@ -413,8 +394,8 @@ fn grasp_servers_section( )) } -/// The editable list of default grasp servers plus an add-relay input, -/// styled like the grasp-server section of the publish dialogs. +/// The editable list of default grasp servers plus an add-relay input. +/// Styled like the grasp-server section of the publish dialogs. fn grasp_server_editor( servers: &[String], controls: &SettingsControls, @@ -478,8 +459,8 @@ fn grasp_server_editor( ) } -/// The bare host of a grasp server (defaults are entered without a scheme), -/// matching how the publish dialogs display servers. +/// The bare host of a grasp server, defaults are entered without a scheme. +/// Matches how the publish dialogs display servers. fn display_server(server: &str) -> SharedString { RelayUrl::parse(server) .ok() @@ -513,8 +494,8 @@ fn repositories_section( )) } -/// The editable list of scan directories plus an add-directory button, -/// styled like the grasp-server list. +/// The editable list of scan directories plus an add-directory button. +/// Styled like the grasp-server list. fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement { v_flex() .w_full() @@ -566,8 +547,8 @@ fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement { ) } -/// The default-folder selector: a disabled input showing the effective -/// folder plus a picker button, matching the create-repository dialog. +/// The default-folder selector, a disabled input plus a picker button. +/// Matches the create-repository dialog. fn folder_selector(controls: &SettingsControls) -> impl IntoElement { let default_folder = controls.default_folder.clone(); h_flex() @@ -590,8 +571,8 @@ fn folder_selector(controls: &SettingsControls) -> impl IntoElement { ) } -/// Parse the server input (accepting a bare host) and append it to the -/// default grasp servers. +/// Parse the server input and append it to the default grasp servers. +/// A bare host is accepted. fn add_server(input: &Entity, window: &mut Window, cx: &mut App) { let value = input.read(cx).value().trim().to_owned(); if value.is_empty() { @@ -660,8 +641,8 @@ fn add_scan_path(cx: &mut App) { .detach(); } -/// Prompt for the folder the Create Repository dialog should default to, -/// remembering it in the settings and showing it in the disabled input. +/// Prompt for the Create Repository dialog's default folder. +/// Remember it in the settings and show it in the disabled input. fn choose_default_folder(default_folder: &Entity, window: &mut Window, cx: &mut App) { let handle = window.window_handle(); let default_folder = default_folder.clone(); @@ -695,8 +676,9 @@ fn choose_default_folder(default_folder: &Entity, window: &mut Windo .detach(); } -/// Wire a number input to the settings: steps clamp and persist, typed -/// changes parse, clamp and persist. +/// Wire a number input to the settings. +/// Step actions clamp and persist the value. +/// Typed changes parse, clamp and persist. fn wire_number_input( state: &Entity, subscriptions: &mut Vec, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 3f63701..05df0c0 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -45,10 +45,7 @@ impl Workspace { let mut subscriptions = vec![]; - // A bottom/right dock whose last panel was dragged away is removed - // entirely: base keeps the emptied region, which would otherwise - // linger as a bare strip. Deferred, because the event arrives while - // the area is mid-update. + // A bottom or right dock whose last panel was dragged away is removed entirely. let dock_for_pruning = dock.clone(); subscriptions.push(cx.subscribe_in( &dock, @@ -78,9 +75,7 @@ impl Workspace { let backend = Backend::global(cx); - // Ask for the passphrase when the stored identity is NIP-49 - // encrypted. Subscribed via the window, since opening a dialog - // needs one. + // Ask for the passphrase when the stored identity is NIP-49 encrypted. let passphrase_subscription = window.subscribe(&backend, cx, |_backend, event, window, cx| { if matches!(event, BackendEvent::PassphraseRequired) { @@ -88,9 +83,8 @@ impl Workspace { } }); - // The event may have fired before this window existed (the backend - // is initialized before the first window opens); fall back to the - // backend state in that case. + // The event may have fired before this window existed. + // Fall back to the backend state in that case. if backend.read(cx).passphrase_required() { passphrase_dialog::open(window, cx); } diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index d8e2d7e..ced69db 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -17,12 +17,8 @@ workspace = { path = "../crates/workspace" } gpui.workspace = true gpui_platform.workspace = true -gpui_linux.workspace = true -gpui_windows.workspace = true -gpui_macos.workspace = true dock = { workspace = true } gpui-component.workspace = true reqwest_client.workspace = true -log.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 9f6aa79..74d6b96 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -17,14 +17,14 @@ fn main() { gpui_component::init(cx); theme::init(cx); - // Load the persisted settings before applying the theme, - // so the stored appearance and theme configuration take effect at startup. + // Load the persisted settings before applying the theme. + // Stored appearance and theme settings then take effect at startup. let store = cx.new(|cx| SettingsStore::new(paths::settings_file(), cx)); SettingsStore::set_global(store.clone(), cx); let settings = store.read(cx).settings().clone(); - // Register the built-in "Signed" theme (light + dark variants) - // and make it the active theme, following the stored appearance. + // Register the built-in Signed theme, light and dark variants. + // The stored appearance then selects the active theme. let registry = ThemeRegistry::global_mut(cx); for (name, content) in Assets.themes() { if let Err(err) = registry.load_themes_from_str(&content) { @@ -74,11 +74,13 @@ fn main() { // Connects relays and restores the session. std::fs::create_dir_all(paths::nostr_dir()).ok(); - signed_state::init(paths::nostr_dir(), settings.local_repos.scan_paths, cx); - - // Local git clone cache for browsing repository contents. std::fs::create_dir_all(paths::repos_dir()).ok(); - signed_state::GitStore::set_global(paths::repos_dir().clone(), cx); + signed_state::init( + paths::nostr_dir(), + paths::repos_dir().clone(), + settings.local_repos.scan_paths, + cx, + ); cx.set_app_identity("su.reya.signed", "Signed"); diff --git a/docs/PR_FLOW.md b/docs/PR_FLOW.md index 1ab69f2..10e8e0f 100644 --- a/docs/PR_FLOW.md +++ b/docs/PR_FLOW.md @@ -11,23 +11,22 @@ state. ```mermaid graph TD - A["New pull request dialog"] --> B{"Patch source"} - B -->|"Paste"| C["Paste git format-patch output"] - B -->|"Local checkout"| D["Browse for checkout"] - D --> E["Defaults: source = current branch, target = announced HEAD"] - E --> F["Generate: merge-base plus format-patch base..tip"] - F --> G["Apply check vs mirror clone - non-blocking warning"] - C --> H["Submit"] - F --> H - G --> H + A["New pull request panel"] --> B{"Compare source"} + B -->|"Local checkout"| C["Pick folder (or auto-prefilled from remembered checkouts)"] + B -->|"Announced fork"| D["Pick fork repo + branch"] + D --> D1["Ensure base mirror (GitCache), fetch origin"] + D1 --> D2["Import fork heads as refs/fork/<owner>/<id>/*"] + C --> E["Defaults: target = announced HEAD, source = current branch / fork main"] + E --> F["merge-base + commits + diff of target..source (Files/Commits tabs)"] + F --> H["Submit: format-patch base..tip at publish time"] H --> I["split_patch_series: one part per commit"] I --> J{"Any part over 60 KB?"} J -->|"Yes"| K["Refuse with message"] J -->|"No"| L["tip = last part's From commit"] L --> M["Publish kind-1617 patch series: first has t root, later parts e-reply chained"] - M --> N["Build kind-1618 PR event: c = tip, e = root patch, branch-name, merge-base"] + M --> N["Build kind-1618 PR event: c = tip, e = root patch, branch-name, merge-base, clone"] N --> O["Sign early - learn the event id"] - O --> P["Push tip to refs/nostr/event-id on every announced grasp server"] + O --> P["Push tip to refs/nostr/event-id: author /prs/ grasp servers first, then the announced servers"] P -->|"All rejected"| Q["last_warning banner in PR list"] P --> R["Publish kind-1618 PR event"] Q --> R @@ -50,47 +49,81 @@ graph TD Key points of the write side: -- **Merge base**: only computable in the local-checkout path - (`signed_git::merge_base`); the paste path publishes none. The dialog - reuses it at submit only while the patch textarea is unchanged. +- **Compare sources** (NIP-34 / GRASP-06 native, no fork identity on the + wire): + - *Local checkout*: both branch selectors list a picked folder's + branches; all git ops run in that folder. Checkouts of the target repo + are remembered (folder pick + app clones) and matched implicitly + (origin URL or EUC against the announcement), so the panel prefills the + freshest one - no folder dialog for the common case. + - *Announced fork*: the fork's heads are fetched into the target repo's + GitCache mirror under `refs/fork///*` (private + namespace; the browser never sees them). "Merge Into" lists the + mirror's `refs/remotes/origin/*`, "Pull From" the imported fork + branches, and every git op - merge-base, range diff/commits, + format-patch, tip push - runs in the mirror, which holds both + histories. Fork candidates are announcements related to the target by + `u` tag or shared EUC, own forks first, without `clone` URLs excluded. +- **GRASP-06 hosting**: the tip is pushed under `refs/nostr/` + (nak's convention) to the *author's* grasp servers first - + `https:///prs//.git`, resolved from the + author's kind-10317 grasp list, falling back to the settings defaults - + then to the base repository's announced grasp servers. The `clone` tag + lists those `/prs/` URLs first, then the announced clone URLs (fixed + before signing; dead URLs are inert, the patches stay the source of + truth). Contributing therefore never depends on the other project's + servers accepting a push. - **Patch series**: each commit becomes its own kind-1617 event so no event grows past NIP-34's 60 KB guidance; the PR's `c` tag carries the *last* commit of the series (the tip), and each part carries its own `commit`/`r` tags. -- **Push before publish**: the tip is pushed to every announced grasp - server under `refs/nostr/` (nak's convention) so the announced - `clone` URLs really can serve the commit. Failure is non-fatal — the - patch events remain the source of truth — and surfaces as a - `last_warning` banner. +- **Push before publish**: failure is non-fatal - the patch events remain + the source of truth - and surfaces as a `last_warning` banner. +- **1619 updates are paste-only today** (no repo path holds the new tip's + objects), so updates are not pushed; hosting them is deferred until the + update dialog gains a local-checkout source. ## Creating a pull request - event ordering ```mermaid sequenceDiagram participant User - participant App - participant Checkout as Local checkout - participant Grasp as Grasp servers - participant Relays as Nostr relays + participant P as Base mirror (GitCache) + participant F as Fork grasp server + participant A as Author grasp (GRASP-06 /prs/) + participant B as Base repo grasps + participant R as Nostr relays - User->>App: pick checkout and branches, Generate - App->>Checkout: merge-base(source, target) - Checkout-->>App: base commit - App->>Checkout: format-patch base..tip - Checkout-->>App: patch series - App->>App: split series, check per-part size + User->>P: ensure mirror (fork mode) / pick local checkout + P-->>F: fetch fork heads -> refs/fork/... (fork mode) + User->>P: merge-base, range commits, range diff + User->>P: submit: format-patch base..compare-ref loop each patch of the series - App->>Relays: publish kind-1617 (first: t root, later: e reply) + User->>R: publish kind-1617 (first: t root, later: e reply) end - App->>App: build and sign kind-1618 PR event - App->>Grasp: push tip to refs/nostr/event-id - Grasp-->>App: accepted or rejected (best-effort) - App->>Relays: publish kind-1618 PR event + User->>User: build and sign kind-1618 (clone = /prs/ URLs + announced) + User->>A: push tip to refs/nostr/event-id (author servers, first) + User->>B: push tip to refs/nostr/event-id (best-effort) + A-->>User: accepted or rejected (all rejected -> warning) + User->>R: publish kind-1618 PR event opt draft - App->>Relays: publish kind-1633 draft status + User->>R: publish kind-1633 draft status end ``` +## Ready to contribute (suggestions) + +Local checkouts are matched to announced repositories (remembered records +freshest-first ∪ scanned matches by origin URL or EUC). While a repository's +detail panel is open, each associated checkout is checked off the main +thread: current branch vs its base (announced HEAD, else `main`, else the +first branch), commits ahead, dirty worktrees excluded. A banner in the +repository panel then offers a prefilled New PR panel for the first branch +that is ahead with **no open PR by you** proposing it (`branch-name` tag, +falling back to the `c` tip tag) - NIP-34-native dedupe, refreshed +periodically and whenever the checkouts/announcements change. The panel +never submits anything on its own; suggestions only navigate and prefill. + ## Updating and merging ```mermaid @@ -100,7 +133,7 @@ sequenceDiagram participant Maintainer participant Clone as Mirror clone - Note over Author,Relays: Update - PR author only + Note over Author,Relays: Update - PR author only (paste flow, no push yet) Author->>Relays: publish revision patch series (t root-revision, e reply to original root) Author->>Relays: publish kind-1619 update (E/P tags, c = new tip) @@ -132,8 +165,9 @@ Reader rules that keep the flow consistent: - **Status**: only status events by the root author or a repository maintainer count; the newest wins, `Open` is the default. -- **Tip**: only kind-1619 updates by the PR author move the tip — a +- **Tip**: only kind-1619 updates by the PR author move the tip - a stranger's update is ignored. - **Diff**: the patch set is preferred (NIP-34 `e`-linked chain); PRs from other clients without patch events fall back to diffing - `merge-base..tip` in the local clone. + `merge-base..tip` in the local clone. Fetching tips from `clone` URLs + (ngit `pr checkout` analog) is not implemented yet. diff --git a/docs/TODO.md b/docs/TODO.md deleted file mode 100644 index 05e68b9..0000000 --- a/docs/TODO.md +++ /dev/null @@ -1,47 +0,0 @@ -# TODO - -## Fork support - -- [x] Fork badge on repo list cards (`repo_list.rs::render_card`). -- [x] "Forked from …" text button in the repo detail header (`repo_detail/mod.rs::render_header`) and About dialog. -- [x] Clicking the upstream opens it as a center panel (shared `open_repo_panel` helper). - -## Pull request improvement - -### New pull request panel (replaces the dialog) - -- [x] "New pull request" (PR list header + repo header `New PR`) opens a center panel instead of the paste dialog: - - [x] Base/compare branch selectors fed from a user-chosen local checkout (GitHub-style; defaults: announced HEAD for base, checkout's current branch for compare). - - [x] Files/Commits tabs like the repo panel: diff of `merge-base..compare` (shared `DiffPane` widget, also extracted for the commit diff panel) + virtual commit list with count badge; clicking a commit opens its diff panel. - - [x] Only two inputs: title (required, gates the Create button) and description (optional). - - [x] Patch is generated from the checkout at submit time (`format_patch_between` on the stored merge base); panel closes after publishing, errors surface in the PR list banner. -- [x] Removed with the dialog: paste textarea, draft checkbox, branch-name input and the mirror-clone apply-check hint (store behavior unchanged: `open_pull_request` still publishes the series + `branch-name`/`merge-base`/`r` tags and pushes the tip). - -### Send patch panel (classic paste flow) - -- [x] "Send patch" entry in the repo header PRs dropdown (`RepoAction::SendPatch`) and a "New pull request ▾ Send patch" dropdown replacing the PR list's plain new-PR button. -- [x] `send_patch.rs` center panel: title + optional description + `git format-patch` paste area; submits through `RepoStore::open_pull_request` (no checkout, no `branch-name`/`merge-base`). Synchronous store errors (malformed/oversized patch, sign-in) keep the panel open with an inline error; the panel closes once the publish is underway. - -- [x] P1: `branch-name` tag + `r` EUC tag on PR creation; draft checkbox in the new-PR dialog (dialog since replaced by the panel above). -- [x] P1: `RepoStore::update_pull_request` (kind 1619 + root-revision patch) with an author-only "Update" button on the PR detail header. -- [x] P1: `latest_update` filters by PR author. -- [x] P2: local checkout picker in the new-PR dialog (folder picker + source/target branches + Generate): `signed_git::{merge_base, format_patch_between, patch_applies}`; `merge-base` tag now published; best-effort apply check shown under the patch field (superseded by the panel's live compare view). -- [x] P3: push tip to grasp servers under `refs/nostr/` before publishing (from the local checkout); multi-commit series published as NIP-10-chained 1617 events with a 60 KB per-patch cap; PR list shows dismissible error/warning banners (incl. push failures). -- [x] P4: merge status tags — `merge_pull_request` publishes 1631 with `applied-as-commits` + `r` per applied commit and `q`/`e`-reply tags per applied patch event. - -### Pull request follow-ups - -- [ ] GRASP-06 `/prs//.git` contributor endpoints + kind-10317 user grasp-list fallback. -- [ ] Merge button in the PR detail view (`merge_pull_request` is store-only today), then fetch-and-merge (`merge-commit`) when the push backend is guaranteed. -- [ ] Local-checkout generation for the update-PR dialog (currently paste-only). -- [ ] Fork-aware compare in the New PR panel: today both branch selectors come from the user-picked local checkout, so a cross-fork PR (GitHub's "compare across forks") requires the fork's branch to exist locally. Add picking the fork repository from announced repos (its 30617 may point at this repo via the `u` tag, or share the EUC) + a branch, fetch it into the `GitCache` mirror, and run the `merge-base`/diff/`format-patch` flow against the base repo's mirror — like `choose_checkout` today but repo-driven. - -## Performance: render path - -- [ ] Virtualize issue/PR comment threads (`issue_detail.rs::render_comments`, `pull_request_detail.rs::render_comments`). Harder than the list tabs: comment cards have variable heights and live inside a scrolling page together with the body and the comment form, so this needs either measured item sizes or restructuring the whole discussion tab into one virtual list. (Comment bodies are already cached as `SharedString`, so re-renders are cheap element constructions, not byte copies.) - -## Performance: relay/subscription behavior - -- [ ] Narrow `RepoStore`'s `BackendEvent::NostrUpdate` relevance filter (`crates/signed_state/src/repo.rs:65-98`): any comment/status/label/deletion from anywhere wakes every open repo store; match only events referencing this repo's roots or coordinate. -- [ ] Reconsider `ban_relay_on_mismatch(true)` (`crates/signed_nostr/src/backend.rs:49`): combined with many short-lived auto-close subscriptions, a late event after EOSE can permanently ban a relay for the session. -- [ ] Relays added for a repo stay in the pool forever and grow unboundedly (`crates/signed_state/src/backend.rs`); consider removing repo relays when the last panel for that repo closes.