Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17de4f6376 | ||
|
|
212f35d6bb | ||
|
|
018395d0c5 | ||
|
|
c0f06d095e | ||
|
|
01f0540726 |
Generated
+69
-87
@@ -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",
|
||||
@@ -342,9 +322,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 +658,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 +1218,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 +1243,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 +1255,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 +1684,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 +1846,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 +3464,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 +3527,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"waker-fn",
|
||||
"web-time",
|
||||
"windows 0.61.3",
|
||||
"windows 0.62.2",
|
||||
"zed-font-kit",
|
||||
"zed-scap",
|
||||
"ztracing",
|
||||
@@ -3556,7 +3536,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 +3572,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 +3649,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 +3663,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 +3673,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 +3710,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 +3756,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 +3802,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 +3813,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 +3826,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 +3836,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 +3846,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 +3870,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 +3896,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 +3915,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 +4210,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 +4230,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 +5014,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 +5186,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 +5280,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 +6099,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 +6244,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 +7032,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 +7115,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 +7493,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",
|
||||
@@ -7969,9 +7949,11 @@ dependencies = [
|
||||
"nostr-connect",
|
||||
"nostr-sdk",
|
||||
"rustls",
|
||||
"settings",
|
||||
"signed_core",
|
||||
"signed_git",
|
||||
"signed_nostr",
|
||||
"tempfile",
|
||||
"utils",
|
||||
]
|
||||
|
||||
@@ -8227,7 +8209,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 +8795,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 +9607,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 +10487,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]]
|
||||
@@ -11394,7 +11376,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 +11393,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 +11404,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<DockArea>,
|
||||
toggle_button_visible: Cell<bool>,
|
||||
@@ -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,8 @@ 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 +136,7 @@ impl DockAreaRenderer for SignedDockSkin {
|
||||
}
|
||||
|
||||
fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
// `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 +156,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 +173,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 +186,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 +229,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<SkinShared>,
|
||||
@@ -310,10 +296,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 +316,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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-13
@@ -1,14 +1,3 @@
|
||||
//! 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 gpui::{Pixels, px};
|
||||
|
||||
mod dock_area;
|
||||
@@ -28,8 +17,7 @@ pub use gpui_component::dock::{
|
||||
/// 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",
|
||||
|
||||
+57
-109
@@ -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;
|
||||
@@ -38,12 +28,10 @@ use crate::{
|
||||
ClosePanel, PanelControl, PanelHandle, TAB_BAR_HEIGHT, ToggleZoom, 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<gpui::Pixels> = 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<dyn BasePanelView>,
|
||||
window: &mut Window,
|
||||
@@ -56,9 +44,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<dyn BasePanelView>,
|
||||
}
|
||||
@@ -83,10 +69,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<PanelControl> {
|
||||
let panel = group.active_panel()?;
|
||||
panel
|
||||
@@ -95,8 +79,8 @@ fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
||||
.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<NodeId> {
|
||||
match node.kind() {
|
||||
PaneRef::Tabs { .. } => Some(node.id()),
|
||||
@@ -105,9 +89,8 @@ fn left_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<NodeId> {
|
||||
match node.kind() {
|
||||
PaneRef::Tabs { .. } => Some(node.id()),
|
||||
@@ -120,23 +103,17 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<SkinShared>,
|
||||
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<Option<usize>>,
|
||||
/// 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<Cell<Option<Bounds<Pixels>>>>,
|
||||
/// 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<Cell<Option<Bounds<Pixels>>>>,
|
||||
/// 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<Cell<Option<Bounds<Pixels>>>>,
|
||||
}
|
||||
|
||||
@@ -152,9 +129,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 +145,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<DockPlacement> {
|
||||
let area = self.shared.area().upgrade()?;
|
||||
let area = area.read(cx);
|
||||
@@ -185,10 +158,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<DragPanel> {
|
||||
if group.is_locked() {
|
||||
return None;
|
||||
@@ -199,8 +170,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 +184,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 +233,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 +276,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 +292,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 +365,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 +401,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 +424,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 +475,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 +506,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 +528,30 @@ impl SignedTabGroupSkin {
|
||||
impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
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 +585,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
fn content_frame(&self, group: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
||||
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 +595,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 +614,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 +627,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<usize> = group
|
||||
.panels()
|
||||
@@ -690,10 +643,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 +679,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 +718,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 +802,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
cx: &mut App,
|
||||
) -> Option<AnyElement> {
|
||||
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(
|
||||
|
||||
+14
-37
@@ -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 _;
|
||||
@@ -52,9 +44,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<SkinShared>,
|
||||
scroll_handle: ScrollHandle,
|
||||
@@ -101,11 +91,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 +200,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 +257,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 +291,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 +356,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<Div> {
|
||||
h_flex()
|
||||
.id(("tile-panel", tile.panel_id().as_u64()))
|
||||
@@ -386,9 +364,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<Pixels>,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
+15
-23
@@ -1,18 +1,12 @@
|
||||
//! 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`].
|
||||
@@ -35,26 +29,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.
|
||||
///
|
||||
/// 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 when missing 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.
|
||||
/// Panics when called after [`data_dir`] or [`config_dir`] was initialized.
|
||||
/// Panics when the directory cannot be created or 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");
|
||||
@@ -153,13 +145,13 @@ pub fn logs_dir() -> &'static PathBuf {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<PathBuf> = 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<PathBuf> = OnceLock::new();
|
||||
REPOS_DIR.get_or_init(|| data_dir().join("repos"))
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
}
|
||||
|
||||
@@ -107,12 +101,45 @@ 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)]
|
||||
#[serde(default)]
|
||||
pub struct CheckoutRecord {
|
||||
/// Local folder of the checkout.
|
||||
pub path: PathBuf,
|
||||
/// Repository address as a string, `30617:<pubkey>:<id>`.
|
||||
pub addr: String,
|
||||
/// Unix seconds of the last use, for freshest-first ordering.
|
||||
pub last_used: u64,
|
||||
}
|
||||
|
||||
impl Default for CheckoutRecord {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
path: PathBuf::new(),
|
||||
addr: String::new(),
|
||||
last_used: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<CheckoutRecord>,
|
||||
}
|
||||
|
||||
/// 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<PathBuf>,
|
||||
}
|
||||
|
||||
@@ -128,6 +155,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,
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ struct GlobalSettingsStore(Entity<SettingsStore>);
|
||||
|
||||
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<Self> {
|
||||
cx.global::<GlobalSettingsStore>().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<Path>, _cx: &mut Context<Self>) -> 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)?;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Address of a NIP-34 repository announcement: `30617:<owner-pubkey>:<repo-id>`.
|
||||
/// Address of a NIP-34 repository announcement, `30617:<owner-pubkey>:<repo-id>`.
|
||||
///
|
||||
/// The Rust Nostr SDK's [`Coordinate`] already provides parsing, formatting 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.
|
||||
|
||||
@@ -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", "<value>", "#t"]` label.
|
||||
/// Whether a kind-1985 label event declares the `#t` namespace,
|
||||
/// it must also carry at least one `["l", "<value>", "#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<String> {
|
||||
let mut labels: Vec<String> = 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],
|
||||
|
||||
@@ -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://<naddr1...>` — direct repository address.
|
||||
/// `nostr://<naddr1...>` encodes a direct repository address.
|
||||
Addr(RepoAddr),
|
||||
/// `nostr://<npub|nip05>/[relay-hint/]<identifier>`
|
||||
UserRepo {
|
||||
|
||||
@@ -2,8 +2,8 @@ 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).
|
||||
/// A NIP-22 comment thread, a top-level comment on the root event,
|
||||
/// nested replies are ordered oldest first at every level.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CommentThread {
|
||||
/// The thread's top-level comment.
|
||||
@@ -12,8 +12,8 @@ pub struct CommentThread {
|
||||
pub replies: Vec<CommentThread>,
|
||||
}
|
||||
|
||||
/// The direct parent of a comment (NIP-22 lowercase `e` tag), or `None` for
|
||||
/// comments without one.
|
||||
/// The direct parent id of a comment, from its NIP-22 lowercase `e` tag.
|
||||
/// `None` when no `e` tag is present.
|
||||
fn comment_parent(event: &Event) -> Option<EventId> {
|
||||
event
|
||||
.tags
|
||||
@@ -23,14 +23,16 @@ fn comment_parent(event: &Event) -> Option<EventId> {
|
||||
.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.
|
||||
/// Group the comments on a root issue, patch or PR into NIP-22 threads,
|
||||
/// a comment whose parent is the root starts a thread.
|
||||
///
|
||||
/// Other comments nest under their parent comment.
|
||||
///
|
||||
/// Threads and replies are ordered oldest first,
|
||||
/// replies with a missing parent are made top-level threads, so none are dropped.
|
||||
pub fn comment_threads(root: &Event, comments: &[Event]) -> Vec<CommentThread> {
|
||||
// Index comments by their parent id. Comments without a parent tag are
|
||||
// treated as replying to the root event itself.
|
||||
// Index comments by their parent id.
|
||||
// Comments without a parent tag reply to the root event itself.
|
||||
let mut children: HashMap<EventId, Vec<&Event>> = HashMap::new();
|
||||
for comment in comments {
|
||||
let parent = comment_parent(comment).unwrap_or(root.id);
|
||||
@@ -65,8 +67,8 @@ pub fn comment_threads(root: &Event, comments: &[Event]) -> Vec<CommentThread> {
|
||||
|
||||
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.
|
||||
// Orphan replies have an unknown parent comment, so they never reach the root tree.
|
||||
// Surface them as top-level threads so they are not dropped.
|
||||
let mut orphans: Vec<&Event> = comments
|
||||
.iter()
|
||||
.filter(|event| !visited.contains(&event.id))
|
||||
@@ -148,7 +150,8 @@ mod tests {
|
||||
.expect("signed event");
|
||||
let a = comment(&keys, Some(&root), "a", 100);
|
||||
|
||||
// `missing` is not in the comment set; its reply should still show up.
|
||||
// `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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Item = EventId>) -> Filter {
|
||||
Filter::new()
|
||||
.kinds([
|
||||
@@ -56,33 +56,29 @@ pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> 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<Item = EventId>) -> 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<Item = EventId>) -> Vec<Filter> {
|
||||
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
|
||||
if roots.is_empty() {
|
||||
@@ -105,42 +101,38 @@ pub fn announcements_by(public_key: PublicKey) -> Filter {
|
||||
.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<Filter> {
|
||||
vec![
|
||||
Filter::new()
|
||||
|
||||
+135
-62
@@ -5,16 +5,16 @@ use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
|
||||
/// 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<SharedString>,
|
||||
pub description: Option<SharedString>,
|
||||
@@ -24,36 +24,30 @@ pub struct Announcement {
|
||||
pub clone: Vec<Url>,
|
||||
/// Relays the repository monitors for patches and issues.
|
||||
pub relays: Vec<RelayUrl>,
|
||||
/// Earliest unique commit ID (`r` tag with `euc` marker).
|
||||
/// Earliest unique commit ID, the `r` tag with `euc` marker.
|
||||
pub euc: Option<String>,
|
||||
/// Other recognized maintainers.
|
||||
pub maintainers: Vec<PublicKey>,
|
||||
/// 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<Upstream>,
|
||||
/// Hashtags labelling the repository (`t` tags).
|
||||
/// Hashtags labelling the repository, the `t` tags.
|
||||
pub hashtags: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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:<pubkey>:<id>`) 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:<pubkey>:<id>` 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<RepoAddr>,
|
||||
/// Relay hint for the upstream, if the `u` tag carries one.
|
||||
pub relay_hint: Option<RelayUrl>,
|
||||
}
|
||||
|
||||
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,8 +61,7 @@ impl Upstream {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text for display: the upstream coordinate when it is a NIP-34
|
||||
/// repository, otherwise the raw `u` value (git-URL form).
|
||||
/// Text for display.
|
||||
pub fn display(&self) -> SharedString {
|
||||
match &self.addr {
|
||||
Some(addr) => SharedString::from(addr.to_string()),
|
||||
@@ -77,8 +70,8 @@ impl Upstream {
|
||||
}
|
||||
}
|
||||
|
||||
/// Subject of a NIP-34 issue or pull request event: the `subject` tag,
|
||||
/// falling back to the first non-empty line of the content.
|
||||
/// 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) -> SharedString {
|
||||
let subject = event
|
||||
.tags
|
||||
@@ -101,12 +94,7 @@ pub fn activity_subject(event: &Event) -> SharedString {
|
||||
.unwrap_or(SharedString::from("Untitled"))
|
||||
}
|
||||
|
||||
/// 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 +103,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 +145,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<Item = &'a Event>) -> String {
|
||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||
let series = pull_request_patches(pr, patches.iter().copied());
|
||||
@@ -173,7 +159,7 @@ pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// The chain of patches replying to `root` (NIP-10 `e` tags), oldest first.
|
||||
/// The chain of patches replying to `root` via NIP-10 `e` tags, oldest first.
|
||||
fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
|
||||
let mut series = vec![root];
|
||||
loop {
|
||||
@@ -195,7 +181,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<String> {
|
||||
event
|
||||
.tags
|
||||
@@ -206,8 +192,9 @@ fn current_commit_of(event: &Event) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 +206,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<Self> {
|
||||
if event.kind != Kind::GitRepoAnnouncement {
|
||||
return None;
|
||||
@@ -251,8 +240,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();
|
||||
@@ -284,6 +273,20 @@ impl Announcement {
|
||||
crate::repo_addr(self.owner, self.id.clone())
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
self.description
|
||||
@@ -291,10 +294,10 @@ impl Announcement {
|
||||
.unwrap_or(SharedString::from("No description"))
|
||||
}
|
||||
|
||||
/// 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<PublicKey> {
|
||||
let mut maintainers = self.maintainers.clone();
|
||||
if self.upstream.is_none() && !maintainers.contains(&self.owner) {
|
||||
@@ -303,8 +306,7 @@ impl Announcement {
|
||||
maintainers
|
||||
}
|
||||
|
||||
/// The `git clone` URLs for this repository, deduplicated while
|
||||
/// preserving the announced order (deterministic across calls).
|
||||
/// The `git clone` URLs for this repository, deduplicated.
|
||||
pub fn clone_urls(&self) -> Vec<SharedString> {
|
||||
let mut seen = HashSet::new();
|
||||
self.clone
|
||||
@@ -449,8 +451,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 +476,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 +493,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 +571,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 +589,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 +618,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 +645,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 +698,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(
|
||||
|
||||
@@ -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/<branch>`.
|
||||
///
|
||||
/// `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/<branch>`. 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<Tag> = 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<String>) {
|
||||
let mut refs = Vec::new();
|
||||
let mut head = None;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+713
-261
File diff suppressed because it is too large
Load Diff
@@ -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<Path>) -> Result<(Client, UniversalSigner)> {
|
||||
let signer = UniversalSigner::new(Keys::generate());
|
||||
@@ -26,7 +21,7 @@ pub async fn new_backend(db_path: impl AsRef<Path>) -> 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());
|
||||
|
||||
@@ -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<RwLock<Arc<dyn InnerSigner>>>,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
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<Coordinate>,
|
||||
pub author: PublicKey,
|
||||
pub event_id: EventId,
|
||||
|
||||
@@ -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"
|
||||
|
||||
+365
-213
@@ -1,11 +1,12 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
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 +18,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=<nsec>` 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 +31,27 @@ 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 +73,21 @@ 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<PublicKey>,
|
||||
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<u64, Instant>,
|
||||
/// Repositories with a push in flight, mirror or checkout based.
|
||||
pushing_repos: Arc<Mutex<HashSet<RepoAddr>>>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
@@ -103,6 +95,22 @@ struct GlobalBackend(Entity<Backend>);
|
||||
|
||||
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<Mutex<HashSet<RepoAddr>>>,
|
||||
addr: RepoAddr,
|
||||
}
|
||||
|
||||
impl Drop for PushGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut repos) = self.repos.lock() {
|
||||
repos.remove(&self.addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<BackendEvent> for Backend {}
|
||||
|
||||
impl Backend {
|
||||
@@ -147,6 +155,7 @@ impl Backend {
|
||||
sync_progress: None,
|
||||
passphrase_required: false,
|
||||
recent_fetches: HashMap::new(),
|
||||
pushing_repos: Arc::new(Mutex::new(HashSet::new())),
|
||||
tasks: vec![pump],
|
||||
};
|
||||
|
||||
@@ -154,8 +163,9 @@ impl Backend {
|
||||
this
|
||||
}
|
||||
|
||||
/// Bootstrap the client: connect to the default relays (indexers as
|
||||
/// discovery-only) and restore the saved session, if any.
|
||||
/// Bootstrap the client.
|
||||
///
|
||||
/// Restore the saved session, if any.
|
||||
fn bootstrap(&mut self, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
@@ -192,10 +202,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<Self>) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
cx.emit(BackendEvent::SignerRequired);
|
||||
@@ -229,8 +240,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 +266,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 +301,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 +337,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 +388,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<RelayUrl>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Announcement, Error>> {
|
||||
) -> Task<Result<(Announcement, PathBuf), Error>> {
|
||||
let name = name.trim().to_owned();
|
||||
let description = description.trim().to_owned();
|
||||
|
||||
if name.is_empty() {
|
||||
return Task::ready(Err(anyhow!("Repository name is required")));
|
||||
}
|
||||
|
||||
if grasp_servers.is_empty() {
|
||||
return Task::ready(Err(anyhow!("Add at least one grasp server")));
|
||||
}
|
||||
|
||||
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 +436,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 +457,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 `<folder>/<name>`, 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 +506,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 +552,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 +560,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 +574,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 +605,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 +640,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 +684,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 +708,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 +722,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<Self>,
|
||||
) -> Task<Result<(), Error>> {
|
||||
let addr = announcement.addr();
|
||||
let cache = GitStore::global(cx).cache().clone();
|
||||
let path = cache.repo_path(&addr);
|
||||
let owner = announcement
|
||||
.owner
|
||||
.to_bech32()
|
||||
.unwrap_or_else(|_| announcement.owner.to_hex());
|
||||
let 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<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<(), Error>> {
|
||||
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<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<(), Error>> {
|
||||
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 +832,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 +875,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<Self>) {
|
||||
let credential = credential.trim();
|
||||
|
||||
@@ -817,8 +890,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<Self>) {
|
||||
let nsec = Keys::generate()
|
||||
.secret_key()
|
||||
@@ -827,8 +899,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<Self>) {
|
||||
let keys = match SecretKey::parse(nsec) {
|
||||
Ok(secret) => Keys::new(secret),
|
||||
@@ -852,11 +923,7 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Login with a `bunker://...` URI (NIP-46). A fresh session key is
|
||||
/// generated and embedded into the stored URI as `?master=<nsec>`, 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<Self>) {
|
||||
let uri_string = uri.trim().to_owned();
|
||||
|
||||
@@ -920,8 +987,7 @@ 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<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
@@ -973,8 +1039,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
|
||||
}
|
||||
@@ -989,13 +1054,12 @@ impl Backend {
|
||||
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<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
||||
where
|
||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||
@@ -1056,8 +1120,7 @@ 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.
|
||||
/// Add discovery-only relays, e.g. NIP-65 indexers, and connect to them.
|
||||
pub fn add_discovery_relays(&mut self, urls: Vec<String>, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
@@ -1080,8 +1143,7 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Start a persistent subscription. Matching events are stored in the
|
||||
/// database automatically and surface as [`BackendEvent::NostrUpdate`].
|
||||
/// Start a persistent subscription.
|
||||
pub fn subscribe(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
@@ -1095,9 +1157,9 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// 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 +1170,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<RelayUrl>,
|
||||
@@ -1147,10 +1199,7 @@ 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<Filter>, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
@@ -1165,14 +1214,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<Self>) {
|
||||
let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter));
|
||||
if self.fetch_recently_started(fingerprint) {
|
||||
@@ -1247,12 +1289,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,
|
||||
@@ -1262,8 +1299,8 @@ impl Backend {
|
||||
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.
|
||||
// Sign with the current signer, broadcast and save locally.
|
||||
// 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?;
|
||||
@@ -1302,10 +1339,7 @@ impl Backend {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -1351,9 +1385,7 @@ 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.
|
||||
/// Publish a NIP-34 repository announcement, kind 30617, with the current signer.
|
||||
pub fn publish_announcement(
|
||||
&mut self,
|
||||
announcement: GitRepositoryAnnouncement,
|
||||
@@ -1362,9 +1394,7 @@ impl Backend {
|
||||
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<Self>) {
|
||||
let task = self.send(builder, cx);
|
||||
|
||||
@@ -1379,10 +1409,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<Self>) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
@@ -1406,8 +1433,9 @@ impl Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fingerprint of a relay + filter set, for fetch dedup. Relays and
|
||||
/// filters are sorted first so the fingerprint is order-independent.
|
||||
/// 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 +1448,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<RelayUrl>,
|
||||
@@ -1438,8 +1462,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 +1478,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 +1495,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<Filter>,
|
||||
@@ -1520,17 +1541,15 @@ fn with_master_key(uri: &str, keys: &Keys) -> String {
|
||||
format!("{uri}{separator}master={nsec}")
|
||||
}
|
||||
|
||||
/// A `https://<host>` (or `http://<host>` for `ws://` grasp servers, like
|
||||
/// ngit) base URL for a grasp server. The repository then lives at
|
||||
/// `{base}/{npub}/{repo-id}.git`.
|
||||
/// Base URL of a grasp server, `https://<host>`.
|
||||
///
|
||||
/// `ws://` grasp servers use `http://<host>`, like ngit.
|
||||
pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
|
||||
// `domain()` drops the port; parse the full URL to keep it (local dev
|
||||
// grasp servers commonly run on a custom port).
|
||||
// `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 +1558,66 @@ pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
|
||||
Some(format!("{scheme}://{host}{port}"))
|
||||
}
|
||||
|
||||
/// The GRASP clone URL of a repository on a grasp server, matching the
|
||||
/// format ngit announces: `https://<host>/<npub>/<repo-id>.git`.
|
||||
/// GRASP clone URL of a repository on a grasp server.
|
||||
fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url> {
|
||||
let base = grasp_base_url(relay)?;
|
||||
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
|
||||
}
|
||||
|
||||
/// 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<Url>, base_clone_urls: Vec<Url>) -> Vec<Url> {
|
||||
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<RelayUrl> {
|
||||
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<Event>) -> Vec<RelayUrl> {
|
||||
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.
|
||||
pub(crate) async fn user_grasp_list_servers(
|
||||
client: Client,
|
||||
user: PublicKey,
|
||||
) -> Result<Vec<RelayUrl>, Error> {
|
||||
let events: Vec<Event> = 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 +1654,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 +1694,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<_>>(),
|
||||
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<Tag> = 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<_>>(),
|
||||
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<_>>(),
|
||||
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<_>>(),
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,962 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, 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::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 of open repository panels are refreshed.
|
||||
const STATUS_POLL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Background poll 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<CheckoutsStore>);
|
||||
|
||||
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/<branch>`, 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.
|
||||
pub struct CheckoutsStore {
|
||||
/// Checkout paths per announced repository.
|
||||
by_repo: Arc<HashMap<RepoAddr, Vec<PathBuf>>>,
|
||||
/// Ready-to-contribute statuses of the requested repositories.
|
||||
statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
|
||||
/// Repositories whose statuses are recomputed on every input change.
|
||||
///
|
||||
/// Those are the repository detail panels currently open.
|
||||
status_requested: HashSet<RepoAddr>,
|
||||
/// 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<RepoAddr>,
|
||||
/// Ready-to-push statuses of the requested own repositories.
|
||||
push_statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
|
||||
/// Last announced head branch per requested repository.
|
||||
///
|
||||
/// A recompute defaults the base the same way.
|
||||
requested_head: HashMap<RepoAddr, Option<String>>,
|
||||
refreshing: bool,
|
||||
refresh_dirty: bool,
|
||||
/// A refresh is waiting out [`REFRESH_DEBOUNCE`].
|
||||
debouncing: bool,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
impl CheckoutsStore {
|
||||
/// Retrieve the global checkouts store.
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalCheckoutsStore>().0.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalCheckoutsStore(entity));
|
||||
}
|
||||
|
||||
/// Create the store.
|
||||
pub fn new(cx: &mut Context<Self>) -> 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.
|
||||
// Their polls stop too.
|
||||
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 = Arc::new(HashMap::new());
|
||||
this.push_statuses = Arc::new(HashMap::new());
|
||||
this.refresh(cx);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let mut store = Self {
|
||||
by_repo: Arc::new(HashMap::new()),
|
||||
statuses: Arc::new(HashMap::new()),
|
||||
status_requested: HashSet::new(),
|
||||
push_requested: HashSet::new(),
|
||||
push_statuses: Arc::new(HashMap::new()),
|
||||
requested_head: HashMap::new(),
|
||||
refreshing: false,
|
||||
refresh_dirty: false,
|
||||
debouncing: false,
|
||||
_subscriptions: subscriptions,
|
||||
tasks: Vec::new(),
|
||||
};
|
||||
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
store.refresh(cx);
|
||||
}
|
||||
|
||||
store
|
||||
}
|
||||
|
||||
/// Remember a successful local-checkout use.
|
||||
pub fn record(&mut self, path: PathBuf, addr: RepoAddr, cx: &mut Context<Self>) {
|
||||
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<PathBuf> {
|
||||
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<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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 statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
||||
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>) {
|
||||
self.push_requested.insert(addr.clone());
|
||||
self.refresh(cx);
|
||||
}
|
||||
|
||||
/// The ready-to-push statuses of `addr`.
|
||||
/// Only meaningful for repositories announced by the signed-in user.
|
||||
///
|
||||
/// Empty while none are known or nothing is unpushed.
|
||||
pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
||||
self.push_statuses.get(addr).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 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<Self>) {
|
||||
if self.refreshing {
|
||||
self.refresh_dirty = true;
|
||||
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);
|
||||
})
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// One resolve and apply cycle, the debounced entry point.
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.refreshing = true;
|
||||
|
||||
// Inputs snapshot, all cheap shared reads.
|
||||
let records = {
|
||||
let settings = SettingsStore::global(cx);
|
||||
settings.read(cx).settings().checkouts.records.clone()
|
||||
};
|
||||
|
||||
let remembered: Vec<Remembered> = records
|
||||
.into_iter()
|
||||
.filter_map(|record| {
|
||||
let addr = record.addr.parse::<RepoAddr>().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<String>)> = self
|
||||
.status_requested
|
||||
.iter()
|
||||
.map(|addr| {
|
||||
(
|
||||
addr.clone(),
|
||||
self.requested_head.get(addr).cloned().flatten(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let push_requested: Vec<RepoAddr> = 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<String>, Option<String>)> = 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<RepoAddr, Vec<PathBuf>> = associations
|
||||
.into_iter()
|
||||
.map(|(addr, paths)| (addr, paths.into_iter().filter(|p| p.is_dir()).collect()))
|
||||
.collect();
|
||||
|
||||
let mut statuses: HashMap<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
|
||||
for (addr, announced_head) in &requested {
|
||||
let Some(paths) = associations.get(addr) else {
|
||||
continue;
|
||||
};
|
||||
let list: Vec<CheckoutStatus> = 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<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
|
||||
for addr in &push_requested {
|
||||
let Some(paths) = associations.get(addr) else {
|
||||
continue;
|
||||
};
|
||||
let list: Vec<CheckoutStatus> = paths
|
||||
.iter()
|
||||
.take(MAX_STATUS_CHECKOUTS)
|
||||
.filter_map(|path| checkout_push_status(path))
|
||||
.collect();
|
||||
if !list.is_empty() {
|
||||
push_statuses.insert(addr.clone(), list);
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<_, Error>((associations, statuses, push_statuses))
|
||||
});
|
||||
|
||||
self.tasks.push(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.refreshing = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let again = this.update(cx, |this, cx| {
|
||||
this.by_repo = Arc::new(associations);
|
||||
this.statuses = Arc::new(statuses);
|
||||
this.push_statuses = Arc::new(push_statuses);
|
||||
cx.notify();
|
||||
|
||||
this.refreshing = false;
|
||||
if this.refresh_dirty {
|
||||
this.refresh_dirty = false;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})?;
|
||||
|
||||
if again {
|
||||
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||
}
|
||||
|
||||
// Keep the statuses current while any repository panel is open.
|
||||
this.update(cx, |this, cx| {
|
||||
if poll && !this.debouncing && !this.refreshing {
|
||||
this.debouncing = true;
|
||||
// Open panels get the fast cadence.
|
||||
// Each cycle fetches every watched checkout's remote.
|
||||
let delay = if this.status_requested.is_empty() {
|
||||
PUSH_POLL
|
||||
} else {
|
||||
STATUS_POLL
|
||||
};
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(delay).await;
|
||||
this.update(cx, |this, cx| {
|
||||
this.debouncing = false;
|
||||
this.run_refresh(cx);
|
||||
})
|
||||
});
|
||||
|
||||
this.tasks.push(task);
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Identity of a repository URL.
|
||||
fn url_identity(url: &str) -> Option<(String, Option<u16>, 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<String>, Option<String>)],
|
||||
announcements: impl IntoIterator<Item = &'a Announcement>,
|
||||
) -> HashMap<RepoAddr, Vec<PathBuf>> {
|
||||
let announcements: Vec<&Announcement> = announcements.into_iter().collect();
|
||||
let mut out: HashMap<RepoAddr, Vec<PathBuf>> = 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<String> {
|
||||
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<CheckoutStatus> {
|
||||
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.
|
||||
fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> {
|
||||
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()?;
|
||||
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<String>, Option<String>) {
|
||||
(
|
||||
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), 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).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);
|
||||
|
||||
// After the push the same commit is on the remote, idle again.
|
||||
run(&["push", "origin", "main"]);
|
||||
assert_eq!(checkout_push_status(&checkout), 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), None);
|
||||
}
|
||||
|
||||
fn pr_event(author: &str, tags: &[&[&str]]) -> Event {
|
||||
let keys = Keys::new(SecretKey::from_hex(author).expect("secret"));
|
||||
let tags: Vec<Tag> = 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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,14 @@ 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).
|
||||
///
|
||||
/// Replaces any installed store, [`signed_state::init`] installs an empty one.
|
||||
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
|
||||
let store = Self::new(root);
|
||||
cx.set_global(GlobalGitStore(store.0.clone()));
|
||||
@@ -25,10 +22,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::<GlobalGitStore>().0.clone())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod backend;
|
||||
mod checkouts;
|
||||
mod git_store;
|
||||
mod local_repos;
|
||||
mod profile;
|
||||
@@ -8,6 +9,7 @@ mod repo_list;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use backend::{Backend, BackendEvent};
|
||||
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
|
||||
pub use git_store::GitStore;
|
||||
use gpui::{App, AppContext, Entity};
|
||||
pub use local_repos::LocalReposStore;
|
||||
@@ -22,7 +24,7 @@ pub use utils::shorten_pubkey;
|
||||
/// Call once at startup, before opening any window that uses the stores.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -> Entity<Backend> {
|
||||
// rustls uses the `aws_lc_rs` provider by default; ignore if already installed.
|
||||
// rustls uses the `aws_lc_rs` provider by default.
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.install_default()
|
||||
.ok();
|
||||
@@ -38,15 +40,10 @@ pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -
|
||||
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);
|
||||
|
||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
||||
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
||||
|
||||
entity
|
||||
}
|
||||
@@ -55,13 +52,12 @@ pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn init(cx: &mut App) -> Entity<Backend> {
|
||||
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);
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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>) {
|
||||
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))?;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ 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,
|
||||
@@ -62,18 +62,21 @@ impl Profile {
|
||||
|
||||
/// Message from the fetch task to the main thread.
|
||||
enum Dispatch {
|
||||
/// A batched sync finished; re-read seen profiles from the database.
|
||||
/// 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<PublicKey, Profile>,
|
||||
/// Public keys we've already requested this session (main thread only).
|
||||
/// Public keys requested this session, main thread only.
|
||||
seen: RefCell<HashSet<PublicKey>>,
|
||||
/// Sender for queuing fetch requests, batched by a background task.
|
||||
sender: Sender<PublicKey>,
|
||||
@@ -111,8 +114,7 @@ impl ProfileStore {
|
||||
_ => {}
|
||||
});
|
||||
|
||||
// Fetch requests are queued on a channel and synced in batches by a
|
||||
// background task.
|
||||
// Fetch requests are queued on a channel.
|
||||
let client = backend.read(cx).client();
|
||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||
let (dispatch_tx, dispatch_rx) = flume::unbounded::<Dispatch>();
|
||||
@@ -143,8 +145,9 @@ impl ProfileStore {
|
||||
store
|
||||
}
|
||||
|
||||
/// Get a profile. Returns a placeholder (default metadata) and queues a
|
||||
/// fetch if the profile isn't cached yet.
|
||||
/// 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 +173,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<Profile> = events
|
||||
.into_iter()
|
||||
.map(|event| {
|
||||
@@ -205,7 +209,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)
|
||||
@@ -231,8 +236,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<Self>) {
|
||||
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
||||
|
||||
@@ -286,9 +292,9 @@ 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.
|
||||
///
|
||||
/// Results are dispatched to the main thread, which re-reads the database.
|
||||
async fn handle_requests(
|
||||
client: &Client,
|
||||
dispatch: &Sender<Dispatch>,
|
||||
@@ -316,9 +322,9 @@ impl ProfileStore {
|
||||
.kind(Kind::Metadata)
|
||||
.authors(batch.drain().collect::<Vec<PublicKey>>());
|
||||
|
||||
// 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() {
|
||||
|
||||
+245
-207
@@ -14,20 +14,23 @@ use signed_core::{
|
||||
subject_override,
|
||||
};
|
||||
|
||||
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::git_store::GitStore;
|
||||
|
||||
/// 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<Announcement>,
|
||||
@@ -40,34 +43,35 @@ pub struct RepoStore {
|
||||
pub pull_requests: Vec<Event>,
|
||||
/// Comments on issues / PRs, oldest first.
|
||||
pub comments: Vec<Event>,
|
||||
/// 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<EventId, RepoStatus>,
|
||||
/// 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).
|
||||
/// Kind-1624 cover notes and kind-1985 label events.
|
||||
///
|
||||
/// They reference this repository's roots, used by ngit and GitWorkshop.
|
||||
cover_notes: Vec<Event>,
|
||||
labels: Vec<Event>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// 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.
|
||||
/// 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<RelayUrl>,
|
||||
/// 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.
|
||||
/// Also kind-1624 cover notes and kind-1985 labels.
|
||||
root_fetches: HashSet<EventId>,
|
||||
refreshing: bool,
|
||||
refresh_dirty: bool,
|
||||
@@ -90,12 +94,12 @@ 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;
|
||||
@@ -106,9 +110,8 @@ impl RepoStore {
|
||||
let kind = event.kind == Kind::GitRepoAnnouncement;
|
||||
let author = event.pubkey == this.addr.public_key;
|
||||
let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr);
|
||||
// Locally published deletions may target any event of
|
||||
// this repository; refresh so they take effect
|
||||
// immediately, like relay deletions.
|
||||
// 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;
|
||||
|
||||
@@ -149,9 +152,9 @@ impl RepoStore {
|
||||
};
|
||||
|
||||
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,7 +165,7 @@ 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()
|
||||
@@ -171,35 +174,32 @@ impl RepoStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<Filter> {
|
||||
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<Self>) {
|
||||
let new: Vec<RelayUrl> = relays
|
||||
.iter()
|
||||
.filter(|url| !self.repo_relays.contains(*url))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if new.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -207,12 +207,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<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
let addr = self.addr.clone();
|
||||
@@ -223,9 +224,6 @@ 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<Self>) {
|
||||
if self.refreshing {
|
||||
self.refresh_dirty = true;
|
||||
@@ -270,8 +268,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));
|
||||
@@ -300,9 +298,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<EventId> = comments.iter().map(|e| e.id).collect();
|
||||
let db = client.database();
|
||||
|
||||
@@ -320,8 +317,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<EventId> = statuses.iter().map(|e| e.id).collect();
|
||||
let db = client.database();
|
||||
|
||||
@@ -339,8 +336,8 @@ impl RepoStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Cover notes (1624) and label events (1985) reference
|
||||
// so query them per root like comments and statuses.
|
||||
// Cover notes, 1624, and label events, 1985, carry no `a` tag.
|
||||
// Query them per root like comments and statuses.
|
||||
let mut seen_cover_notes: HashSet<EventId> = cover_notes.iter().map(|e| e.id).collect();
|
||||
let mut seen_labels: HashSet<EventId> = labels.iter().map(|e| e.id).collect();
|
||||
let db = client.database();
|
||||
@@ -371,9 +368,9 @@ impl RepoStore {
|
||||
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)
|
||||
@@ -439,8 +436,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()
|
||||
@@ -464,10 +461,10 @@ impl RepoStore {
|
||||
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, statuses without an `a` tag, cover notes and labels.
|
||||
// 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,10 +481,9 @@ 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 statuses filter and one annotations filter cover all new roots.
|
||||
// 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));
|
||||
@@ -511,7 +507,8 @@ impl RepoStore {
|
||||
}
|
||||
})?;
|
||||
|
||||
// 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 +517,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,8 +528,7 @@ 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.
|
||||
/// The effective cover note of `root`, kind 1624, if any.
|
||||
pub fn cover_note_of(&self, root: &Event) -> Option<&Event> {
|
||||
let maintainers = self
|
||||
.announcement
|
||||
@@ -543,8 +539,7 @@ impl RepoStore {
|
||||
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).
|
||||
/// The effective hashtag labels of `root`.
|
||||
pub fn labels_of(&self, root: &Event) -> Vec<String> {
|
||||
let maintainers = self
|
||||
.announcement
|
||||
@@ -556,8 +551,7 @@ impl RepoStore {
|
||||
labels
|
||||
}
|
||||
|
||||
/// The effective subject/title override of `root` from authorized
|
||||
/// kind-1985 events (`#subject` namespace), if any.
|
||||
/// The effective subject or title override of `root`, if any.
|
||||
pub fn subject_of(&self, root: &Event) -> Option<String> {
|
||||
let maintainers = self
|
||||
.announcement
|
||||
@@ -568,22 +562,26 @@ impl RepoStore {
|
||||
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 +599,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<Item = &Event> {
|
||||
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>) {
|
||||
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 +633,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 <commit>` 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/<event-id>` (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 +667,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 +684,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<RelayUrl> = {
|
||||
let settings = settings::SettingsStore::global(cx).read(cx).settings();
|
||||
let urls: Vec<String> = 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 +744,77 @@ impl RepoStore {
|
||||
}
|
||||
};
|
||||
|
||||
// GRASP-06 pushes the tip to the author's own grasp servers.
|
||||
// The path is `/prs/<author-npub>/<repo-id>.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<Url> = 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 +822,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/<event-id>` (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/<event-id>`.
|
||||
// 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 +847,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 +895,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 +907,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>) {
|
||||
self.last_error = None;
|
||||
self.last_warning = None;
|
||||
@@ -905,9 +958,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 +1006,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 +1028,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>) {
|
||||
self.last_error = None;
|
||||
|
||||
@@ -1015,18 +1067,18 @@ 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.
|
||||
/// Publish a repository state announcement
|
||||
pub fn publish_state(&mut self, cx: &mut Context<Self>) {
|
||||
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();
|
||||
@@ -1067,17 +1119,7 @@ impl RepoStore {
|
||||
}));
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
self.last_error = None;
|
||||
self.last_warning = None;
|
||||
@@ -1093,23 +1135,28 @@ impl RepoStore {
|
||||
|
||||
let cache = GitStore::global(cx).cache().clone();
|
||||
let addr = self.addr.clone();
|
||||
|
||||
let clone_urls: Vec<String> = 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<Event> = 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 +1166,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 +1199,7 @@ 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).
|
||||
/// Publish a kind-1631 Applied status event for `root` after a merge.
|
||||
fn publish_applied_status(
|
||||
&mut self,
|
||||
root: &Event,
|
||||
@@ -1171,14 +1215,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 +1237,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 +1280,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<EventId, RepoStatus>, root: &Event) -> RepoStatus {
|
||||
status_by_root
|
||||
.get(&root.id)
|
||||
@@ -1242,10 +1288,7 @@ fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, 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 +1328,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 <commit>`
|
||||
/// header on its first line.
|
||||
/// The proposed commit of a `git format-patch` output.
|
||||
/// It is the `From <commit>` 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<RepoStore>,
|
||||
@@ -1365,10 +1405,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 +1421,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 +1473,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 +1504,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());
|
||||
|
||||
@@ -9,8 +9,9 @@ use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
|
||||
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
|
||||
/// 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,40 +21,36 @@ struct GlobalRepoListStore(Entity<RepoListStore>);
|
||||
|
||||
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 repository announcements, global discovery or per-author.
|
||||
pub struct RepoListStore {
|
||||
/// Shared so views can clone the list per frame without a deep copy.
|
||||
pub announcements: Arc<Vec<Announcement>>,
|
||||
/// 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<HashMap<RepoAddr, Timestamp>>,
|
||||
/// 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<HashMap<RepoAddr, RepoActivityCounts>>,
|
||||
author: Option<PublicKey>,
|
||||
refreshing: bool,
|
||||
@@ -65,8 +62,7 @@ pub struct RepoListStore {
|
||||
}
|
||||
|
||||
impl RepoListStore {
|
||||
/// Retrieve the global explore store (all announcements, created at
|
||||
/// startup by [`crate::init`]).
|
||||
/// Retrieve the global explore store.
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalRepoListStore>().0.clone()
|
||||
}
|
||||
@@ -82,12 +78,12 @@ impl RepoListStore {
|
||||
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;
|
||||
@@ -97,13 +93,15 @@ impl RepoListStore {
|
||||
}
|
||||
}
|
||||
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 kind_match = event.kind == Kind::GitRepoAnnouncement;
|
||||
let author_match = this.author.is_none_or(|a| a == event.pubkey);
|
||||
let announcement = kind_match && author_match;
|
||||
|
||||
// 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,
|
||||
@@ -128,13 +126,13 @@ impl RepoListStore {
|
||||
};
|
||||
|
||||
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`).
|
||||
/// Scope the list to an author, or clear the scope with `None`.
|
||||
pub fn set_author(&mut self, author: Option<PublicKey>, cx: &mut Context<Self>) {
|
||||
self.author = author;
|
||||
self.subscribe_remote(cx);
|
||||
@@ -152,15 +150,15 @@ impl RepoListStore {
|
||||
None => filters::all_announcements(),
|
||||
};
|
||||
backend.sync_bootstrap(filter, cx);
|
||||
// Deletion requests (NIP-09/62) must be known before any
|
||||
// announcement can be shown.
|
||||
// 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<Self>) {
|
||||
debug_assert!(!self.debouncing);
|
||||
if self.refreshing {
|
||||
@@ -170,12 +168,7 @@ impl RepoListStore {
|
||||
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<Self>) {
|
||||
if self.refreshing {
|
||||
self.refresh_dirty = true;
|
||||
@@ -198,7 +191,7 @@ impl RepoListStore {
|
||||
self.tasks.push(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>) {
|
||||
self.refreshing = true;
|
||||
|
||||
@@ -216,8 +209,8 @@ impl RepoListStore {
|
||||
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<RepoAddr, Announcement> = HashMap::new();
|
||||
|
||||
for event in events {
|
||||
@@ -242,8 +235,9 @@ impl RepoListStore {
|
||||
let mut announcements: Vec<Announcement> = 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<RepoAddr, Timestamp> = announcements
|
||||
.iter()
|
||||
.map(|a| (a.addr(), a.created_at))
|
||||
@@ -264,8 +258,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 +271,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 +280,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<RepoAddr, RepoActivityCounts> = HashMap::new();
|
||||
let count_filter =
|
||||
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
|
||||
@@ -297,8 +290,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;
|
||||
@@ -319,7 +312,7 @@ impl RepoListStore {
|
||||
self.tasks.push(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;
|
||||
@@ -342,8 +335,8 @@ impl RepoListStore {
|
||||
}
|
||||
})?;
|
||||
|
||||
// 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))?;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ 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.
|
||||
/// A muted command row with a copy button.
|
||||
pub fn copy_row<E>(copy_id: E, command: &SharedString, cx: &App) -> Div
|
||||
where
|
||||
E: Into<ElementId>,
|
||||
@@ -34,10 +33,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,
|
||||
|
||||
@@ -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>) -> 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<Anchor>) -> 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<Entity<PopupMenu>>,
|
||||
@@ -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<ElementId>, cx: &App) -> BaseButton {
|
||||
BaseButton::new(id)
|
||||
.h(px(32.))
|
||||
|
||||
@@ -7,8 +7,8 @@ use gpui::{
|
||||
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.
|
||||
/// 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<ElementId>, max_items: usize) -> AppImageCacheProvider {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<gpui::AnyElement>,
|
||||
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||
}
|
||||
|
||||
@@ -9,13 +9,15 @@ 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.
|
||||
/// 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,
|
||||
@@ -24,8 +26,8 @@ pub struct PixelAvatar {
|
||||
}
|
||||
|
||||
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<str>) -> Self {
|
||||
Self {
|
||||
seed: fnv1a(seed.as_ref().as_bytes()),
|
||||
@@ -85,8 +87,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 +105,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 +133,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<SharedString>,
|
||||
@@ -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<SharedString>,
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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<Div>,
|
||||
window: &mut Window,
|
||||
|
||||
@@ -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<F>(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem
|
||||
where
|
||||
F: Fn(&mut Window, &mut App) + 'static,
|
||||
|
||||
@@ -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<SharedString>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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<AnyElement> = Vec::new();
|
||||
|
||||
@@ -116,7 +114,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()
|
||||
@@ -159,8 +157,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 +190,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<Item = String>, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
|
||||
@@ -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<SharedString>,
|
||||
pub(super) state: Entity<TextViewState>,
|
||||
}
|
||||
|
||||
/// 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<TreeState>,
|
||||
view: WeakEntity<Self>,
|
||||
@@ -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,
|
||||
@@ -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<SharedString>,
|
||||
@@ -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<Self>) -> 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<Self>) -> AnyElement {
|
||||
let Some(code) = &self.code else {
|
||||
return preview_spinner();
|
||||
|
||||
@@ -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<Self>) -> 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()
|
||||
|
||||
@@ -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<CommitDiff>,
|
||||
/// Changed-files explorer state.
|
||||
tree_state: Entity<TreeState>,
|
||||
/// Path of the file whose diff is shown in the detail column.
|
||||
selected_file: Option<SharedString>,
|
||||
/// 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<DiffRow>,
|
||||
/// Per-row heights of [`Self::rows`].
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
@@ -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>) {
|
||||
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>) {
|
||||
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<Self>) -> 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<Self>) -> 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<Self>, 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<SharedString>,
|
||||
/// 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<DiffPane>,
|
||||
/// In-flight tasks; pruned on every push (see [`helpers::track`]).
|
||||
/// In-flight tasks, pruned on every push, see [`helpers::track`].
|
||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||
}
|
||||
|
||||
@@ -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>) {
|
||||
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<Self>) -> AnyElement {
|
||||
let commit = &self.commit;
|
||||
let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| {
|
||||
|
||||
@@ -11,9 +11,6 @@ use signed_core::Announcement;
|
||||
use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff};
|
||||
use signed_ui::{menu_copy_row, middle_truncate};
|
||||
|
||||
/// 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 +19,6 @@ pub(super) struct TreeItemSeed {
|
||||
children: Vec<TreeItemSeed>,
|
||||
}
|
||||
|
||||
/// 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<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
|
||||
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
|
||||
let mut item = TreeItem::new(seed.id, seed.label);
|
||||
@@ -48,14 +39,9 @@ pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, 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<TreeItemSeed> {
|
||||
// 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<String, usize> = HashMap::new();
|
||||
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
|
||||
let mut roots: Vec<usize> = Vec::new();
|
||||
@@ -99,9 +85,6 @@ pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
||||
}
|
||||
|
||||
/// 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 +149,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 +178,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 +210,7 @@ impl ShareTargets {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shorten an naddr link to `<url>/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 `<url>/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 +226,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 +241,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<DiffRow> {
|
||||
let mut rows = Vec::new();
|
||||
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
|
||||
@@ -276,7 +259,7 @@ pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
|
||||
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 +286,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 +297,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))
|
||||
@@ -379,7 +363,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 +395,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 +445,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"
|
||||
|
||||
@@ -26,10 +26,6 @@ pub struct InitRepoState {
|
||||
}
|
||||
|
||||
/// Open the Init dialog for the local repository at `local_path`.
|
||||
///
|
||||
/// The dialog loads the user's default grasp servers (kind `10317` grasp
|
||||
/// list) and falls back to the shared defaults when none are set. On
|
||||
/// success the dialog closes and `view` switches into NIP-34 mode.
|
||||
pub fn open(
|
||||
local_path: PathBuf,
|
||||
view: WeakEntity<RepoDetailView>,
|
||||
@@ -40,23 +36,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| {
|
||||
@@ -153,8 +151,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<InputState>, Entity<TextareaState>),
|
||||
|
||||
@@ -25,7 +25,7 @@ pub struct IssueDetailView {
|
||||
store: Entity<RepoStore>,
|
||||
issue_id: EventId,
|
||||
contents: HashMap<EventId, SharedString>,
|
||||
/// Input state of the "leave a comment" textarea.
|
||||
/// Input state of the comment textarea.
|
||||
comment_input: Entity<TextareaState>,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
@@ -58,7 +58,7 @@ impl IssueDetailView {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
// Participants: the issue author plus everyone who commented.
|
||||
// Participants, the issue author plus everyone who commented.
|
||||
let mut participants: Vec<PublicKey> = vec![issue.pubkey];
|
||||
participants.extend(store.comments_of(&issue.id).map(|comment| comment.pubkey));
|
||||
participants.sort_by_key(PublicKey::to_hex);
|
||||
@@ -138,8 +138,7 @@ impl IssueDetailView {
|
||||
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.
|
||||
// Comment bodies become shared strings once per comment, not per render.
|
||||
let content = self
|
||||
.contents
|
||||
.entry(comment.id)
|
||||
|
||||
@@ -24,8 +24,7 @@ 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 +34,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 +61,11 @@ pub struct IssuesView {
|
||||
filter: IssueFilter,
|
||||
/// Per-row heights of the virtual list.
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// 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<usize>,
|
||||
/// 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 +77,11 @@ impl IssuesView {
|
||||
pub fn new(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
repo_name: SharedString,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let repo_name = store.read(cx).name();
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
@@ -101,7 +97,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,
|
||||
@@ -119,8 +115,6 @@ impl IssuesView {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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<Self>) -> AnyElement {
|
||||
let issue = &self.store.read(cx).issues[issue_ix];
|
||||
let title = activity_subject(issue);
|
||||
@@ -183,8 +177,7 @@ impl IssuesView {
|
||||
}
|
||||
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> 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 +235,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<RepoStore>, 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 +324,9 @@ impl Render for IssuesView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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 +351,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]);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,7 @@ 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,
|
||||
ScrollStrategy, SharedString, Size, Task, WeakEntity, Window, div, px, relative, size,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::clipboard::Clipboard;
|
||||
@@ -41,63 +40,56 @@ use super::helpers::{
|
||||
/// 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<DockArea>,
|
||||
/// Repo store holding the PR, its status and comments.
|
||||
store: Entity<RepoStore>,
|
||||
/// 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<TextareaState>,
|
||||
/// 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<PathBuf>,
|
||||
/// 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<SharedString>,
|
||||
/// Commits of the patch series, in patch order (oldest first).
|
||||
/// Commits of the patch series, in patch order, oldest first.
|
||||
commits: Vec<FileCommit>,
|
||||
/// Parsed file changes of the patch; `None` while loading or on failure.
|
||||
/// Parsed file changes of the patch, `None` while loading or on failure.
|
||||
diff: Option<CommitDiff>,
|
||||
/// The patch is being parsed on a background task.
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
/// 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<TreeState>,
|
||||
/// Path of the file whose diff is shown in the detail column.
|
||||
selected_file: Option<SharedString>,
|
||||
/// Rows of the selected file's diff (hunk headers + lines).
|
||||
/// Rows of the selected file's diff, hunk headers and lines.
|
||||
rows: Vec<DiffRow>,
|
||||
/// Per-row heights of [`Self::rows`].
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// 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.
|
||||
/// Per-row heights of the commits tab's virtual list, built when the patch series loads.
|
||||
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// 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).
|
||||
/// Comment bodies as shared strings, keyed by comment event ID.
|
||||
contents: HashMap<EventId, SharedString>,
|
||||
/// 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<Task<Result<(), anyhow::Error>>>,
|
||||
/// Subscriptions keeping the view live as the store refreshes.
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl PullRequestDetailView {
|
||||
@@ -108,26 +100,12 @@ impl PullRequestDetailView {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let repo_name = store.read(cx).name();
|
||||
let tree_state = cx.new(|cx| TreeState::new(cx));
|
||||
|
||||
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);
|
||||
@@ -157,16 +135,10 @@ impl PullRequestDetailView {
|
||||
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>) {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
@@ -176,6 +148,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 +159,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 +187,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 +205,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 +220,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 +244,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,9 +265,9 @@ 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<PathBuf> = diff
|
||||
@@ -312,6 +296,7 @@ impl PullRequestDetailView {
|
||||
this.error = Some(error.to_string().into());
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
@@ -322,15 +307,14 @@ impl PullRequestDetailView {
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
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 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;
|
||||
@@ -370,7 +354,7 @@ impl PullRequestDetailView {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -387,7 +371,6 @@ impl PullRequestDetailView {
|
||||
})
|
||||
}
|
||||
|
||||
/// Left column: the changed-files tree.
|
||||
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let tree_state = self.tree_state.clone();
|
||||
let view = cx.entity().downgrade();
|
||||
@@ -417,7 +400,6 @@ impl PullRequestDetailView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Right column: header of the selected file plus its diff.
|
||||
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return v_flex()
|
||||
@@ -427,12 +409,15 @@ impl PullRequestDetailView {
|
||||
.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)
|
||||
@@ -440,14 +425,13 @@ impl PullRequestDetailView {
|
||||
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<Self>, cx: &App) -> AnyElement {
|
||||
let status_label = match file.status {
|
||||
signed_git::DiffStatus::Added => "A",
|
||||
@@ -456,6 +440,7 @@ impl PullRequestDetailView {
|
||||
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,
|
||||
@@ -464,6 +449,7 @@ impl PullRequestDetailView {
|
||||
cx.theme().muted_foreground
|
||||
}
|
||||
};
|
||||
|
||||
let title = match &file.old_path {
|
||||
Some(old) => format!("{old} → {}", file.path),
|
||||
None => file.path.clone(),
|
||||
@@ -476,6 +462,7 @@ impl PullRequestDetailView {
|
||||
} else {
|
||||
let sizes = self.item_sizes.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.relative()
|
||||
@@ -564,7 +551,6 @@ impl PullRequestDetailView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Underline tab bar: Discussion, Files and Commits.
|
||||
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let active = self.active_tab;
|
||||
let files_count = self.diff.as_ref().map(|diff| diff.files.len());
|
||||
@@ -610,8 +596,6 @@ impl PullRequestDetailView {
|
||||
.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<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return v_flex()
|
||||
@@ -694,7 +678,7 @@ impl PullRequestDetailView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Right sidebar: participants and labels, like the issue panel.
|
||||
/// Right sidebar with participants and labels, like the issue panel.
|
||||
fn render_sidebar(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
let store = self.store.read(cx);
|
||||
@@ -708,7 +692,7 @@ impl PullRequestDetailView {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
// Participants: the PR author plus everyone who commented.
|
||||
// Participants, the PR author plus everyone who commented.
|
||||
let mut participants: Vec<PublicKey> = vec![root.pubkey];
|
||||
participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey));
|
||||
participants.sort_by_key(PublicKey::to_hex);
|
||||
@@ -776,8 +760,6 @@ impl PullRequestDetailView {
|
||||
.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<Self>) -> AnyElement {
|
||||
h_flex()
|
||||
.flex_1()
|
||||
@@ -789,8 +771,9 @@ impl PullRequestDetailView {
|
||||
.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<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return v_flex()
|
||||
@@ -838,8 +821,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 +836,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 +866,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,8 +876,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.
|
||||
/// One comment card, same design as the issue panel.
|
||||
fn render_comments(&mut self, id: &EventId, cx: &mut Context<Self>) -> AnyElement {
|
||||
let store = self.store.read(cx);
|
||||
let comments: Vec<&Event> = store.comments_of(id).collect();
|
||||
@@ -907,8 +890,7 @@ impl PullRequestDetailView {
|
||||
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.
|
||||
// Comment bodies become shared strings once per comment, not per render.
|
||||
let content = self
|
||||
.contents
|
||||
.entry(comment.id)
|
||||
@@ -1002,7 +984,7 @@ impl PullRequestDetailView {
|
||||
.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<Self>) -> AnyElement {
|
||||
let current_commit = self.current_commit.clone();
|
||||
let (title, status, branch, author) = {
|
||||
@@ -1022,7 +1004,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 +1086,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<RepoStore>,
|
||||
root: Event,
|
||||
@@ -1115,8 +1096,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| {
|
||||
@@ -1176,7 +1157,7 @@ fn sidebar_title(text: &str, cx: &App) -> AnyElement {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The `c` tag of a PR event (tip of the proposed branch), as hex.
|
||||
/// The `c` tag of a PR event, the tip of the proposed branch, as hex.
|
||||
fn current_commit_of(event: &Event) -> Option<String> {
|
||||
event
|
||||
.tags
|
||||
@@ -1187,8 +1168,9 @@ fn current_commit_of(event: &Event) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
event
|
||||
.tags
|
||||
@@ -1199,8 +1181,9 @@ fn merge_base_of(event: &Event) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<Vec<String>> {
|
||||
event
|
||||
.tags
|
||||
@@ -1222,9 +1205,7 @@ fn branch_name_of(event: &Event) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<Item = &'a Event>, root: &Event) -> Option<&'a Event> {
|
||||
let root_hex = root.id.to_hex();
|
||||
events
|
||||
@@ -1238,8 +1219,9 @@ fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, 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;
|
||||
@@ -1357,8 +1339,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 +1367,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());
|
||||
}
|
||||
|
||||
|
||||
@@ -25,12 +25,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 +68,11 @@ pub struct PullRequestsView {
|
||||
filter: PullRequestFilter,
|
||||
/// Per-row heights of the virtual list.
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// 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<usize>,
|
||||
/// 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 +84,11 @@ impl PullRequestsView {
|
||||
pub fn new(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
repo_name: SharedString,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let repo_name = store.read(cx).name();
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
@@ -111,7 +104,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,
|
||||
@@ -137,8 +130,9 @@ impl PullRequestsView {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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<Self>) -> AnyElement {
|
||||
let pr = &self.store.read(cx).pull_requests[pr_ix];
|
||||
let pr_id = pr.id;
|
||||
@@ -203,8 +197,7 @@ impl PullRequestsView {
|
||||
}
|
||||
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> 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 +332,9 @@ impl Render for PullRequestsView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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 +343,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())
|
||||
|
||||
@@ -2,10 +2,10 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, 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<RepoStore>,
|
||||
/// Display name of the repository, for the panel title.
|
||||
repo_name: SharedString,
|
||||
/// Title input (required).
|
||||
/// Title input, required.
|
||||
subject: Entity<InputState>,
|
||||
/// Description input (optional).
|
||||
/// Description input, optional.
|
||||
description: Entity<TextareaState>,
|
||||
/// The pasted `git format-patch` output (required).
|
||||
/// The pasted `git format-patch` output, required.
|
||||
patch: Entity<TextareaState>,
|
||||
/// 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<SharedString>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
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<Self>) {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -24,13 +24,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 +40,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<usize> {
|
||||
let announcements = &store.announcements;
|
||||
let mut indices: Vec<usize> = (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 +93,9 @@ pub struct RepoListView {
|
||||
filter: RepoFilter,
|
||||
/// Per-row heights of the virtual list.
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// 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<usize>,
|
||||
/// Search box filtering repositories by name.
|
||||
search: Entity<InputState>,
|
||||
@@ -121,8 +120,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 +140,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<Self>) {
|
||||
let filter = self.filter;
|
||||
let query = self.search.read(cx).value();
|
||||
@@ -201,8 +201,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<SharedString> =
|
||||
announcement.upstream.as_ref().and_then(|upstream| {
|
||||
let addr = upstream.addr.as_ref()?;
|
||||
@@ -347,8 +347,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,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use dock::DockArea;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px};
|
||||
@@ -9,7 +11,7 @@ use gpui_component::input::{Input, InputState, Textarea};
|
||||
use gpui_component::{ActiveTheme, 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};
|
||||
@@ -22,10 +24,6 @@ pub struct CreateRepoState {
|
||||
}
|
||||
|
||||
/// 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<DockArea>, window: &mut Window, cx: &mut App) {
|
||||
let settings = SettingsStore::global(cx);
|
||||
let default_folder = settings
|
||||
@@ -56,7 +54,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, 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();
|
||||
@@ -134,6 +132,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, 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 +141,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, 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 +156,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, 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<InputState>, window: &mut Window, cx: &mut App) {
|
||||
let handle = window.window_handle();
|
||||
let folder_input = folder_input.clone();
|
||||
@@ -194,10 +191,14 @@ fn choose_folder(folder_input: &Entity<InputState>, 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<InputState>,
|
||||
desc_input: Entity<TextareaState>,
|
||||
folder_input: Entity<InputState>,
|
||||
state: Entity<CreateRepoState>,
|
||||
grasp_state: Entity<GraspServersState>,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
@@ -206,6 +207,7 @@ 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() {
|
||||
@@ -228,16 +230,24 @@ fn create_repository(
|
||||
|
||||
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();
|
||||
|
||||
@@ -9,24 +9,22 @@ 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<RelayUrl>,
|
||||
/// 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<SharedString>,
|
||||
}
|
||||
|
||||
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<String> = if settings.default_servers.is_empty() {
|
||||
DEFAULT_GRASP_SERVERS
|
||||
@@ -48,10 +46,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<GraspServersState>,
|
||||
relay_input: &Entity<InputState>,
|
||||
@@ -143,7 +138,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 +177,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 +185,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<GraspServersState>,
|
||||
input: &Entity<InputState>,
|
||||
@@ -226,8 +221,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<GraspServersState>,
|
||||
window: &mut Window,
|
||||
|
||||
@@ -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.))
|
||||
|
||||
@@ -15,9 +15,11 @@ 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_state::{
|
||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore,
|
||||
};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
|
||||
use signed_ui::{CountBadge, NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
|
||||
|
||||
use super::{RepoDetailView, RepoListView, open_repo_panel};
|
||||
|
||||
@@ -30,23 +32,26 @@ mod settings_dialog;
|
||||
|
||||
use self::onboarding_dialog::OnboardingState;
|
||||
|
||||
/// Left-dock panel with navigation entries. Entries open content panels in
|
||||
/// the dock area.
|
||||
/// Left-dock panel with navigation entries.
|
||||
/// Entries open content panels in the dock area.
|
||||
pub struct SidebarPanel {
|
||||
focus_handle: FocusHandle,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
explore: Option<WeakEntity<RepoListView>>,
|
||||
logged_in: bool,
|
||||
/// Repositories announced by the current user, listed under
|
||||
/// "All Repositories". Recreated when the signer changes.
|
||||
/// Repositories the current user announced, listed under the All Repositories heading.
|
||||
/// Recreated when the signer changes.
|
||||
my_repos: Option<Entity<RepoListStore>>,
|
||||
/// Observes the current user's repo store so the list re-renders.
|
||||
my_repos_subscription: Option<Subscription>,
|
||||
/// Banner artwork shown behind the sign-in screen,
|
||||
/// picked at random from the bundled `backgrounds/` assets.
|
||||
/// Banner artwork behind the sign-in screen.
|
||||
/// Picked at random from the bundled `backgrounds/` assets.
|
||||
banner: SharedString,
|
||||
/// Observes the local-repository scan so new discoveries re-render.
|
||||
_local_repos_subscription: Subscription,
|
||||
/// Observes the checkouts store.
|
||||
/// Its ready-to-push statuses feed the badges on the user's repo rows.
|
||||
_checkouts_subscription: Subscription,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
@@ -77,6 +82,11 @@ impl SidebarPanel {
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let checkouts_store = CheckoutsStore::global(cx);
|
||||
let checkouts_subscription = cx.observe(&checkouts_store, |_, _, cx| {
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let mut panel = Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
@@ -86,6 +96,7 @@ impl SidebarPanel {
|
||||
my_repos_subscription: None,
|
||||
banner: pick_banner(),
|
||||
_local_repos_subscription: local_repos_subscription,
|
||||
_checkouts_subscription: checkouts_subscription,
|
||||
_subscription: subscription,
|
||||
};
|
||||
|
||||
@@ -96,7 +107,8 @@ impl SidebarPanel {
|
||||
panel
|
||||
}
|
||||
|
||||
/// (Re)create the store listing the current user's repositories.
|
||||
/// Recreate the store listing the current user's repositories.
|
||||
/// Watch each repository for unpushed local work.
|
||||
fn refresh_my_repos(&mut self, cx: &mut Context<Self>) {
|
||||
self.my_repos_subscription = None;
|
||||
|
||||
@@ -105,12 +117,29 @@ impl SidebarPanel {
|
||||
self.my_repos = author.map(|author| cx.new(|cx| RepoListStore::new(Some(author), cx)));
|
||||
|
||||
if let Some(store) = self.my_repos.as_ref() {
|
||||
self.my_repos_subscription = Some(cx.observe(store, |_, _, cx| cx.notify()));
|
||||
self.my_repos_subscription = Some(cx.observe(store, |_this, store, cx| {
|
||||
cx.notify();
|
||||
// These are the signed-in user's own repositories.
|
||||
// Request their ready-to-push statuses, deduplicated per repository.
|
||||
// The rows carry a badge while local work is unpushed.
|
||||
let addrs: Vec<_> = store
|
||||
.read(cx)
|
||||
.announcements
|
||||
.iter()
|
||||
.map(|a| a.addr())
|
||||
.collect();
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
checkouts.update(cx, |checkouts, cx| {
|
||||
for addr in addrs {
|
||||
checkouts.request_push_statuses(&addr, cx);
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the Explore (repository list) panel in the center of the dock
|
||||
/// area. No-op if it's already open.
|
||||
/// Open the Explore repository list panel in the dock area's center.
|
||||
/// No-op if it is already open.
|
||||
pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self
|
||||
.explore
|
||||
@@ -162,8 +191,8 @@ 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<Self>) {
|
||||
let detail =
|
||||
cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, window, cx));
|
||||
@@ -179,10 +208,10 @@ impl SidebarPanel {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// The All Repositories section of the sidebar.
|
||||
/// A header with the create button above the current user's repositories.
|
||||
/// Rendered lazily through a [`uniform_list`].
|
||||
/// Followed by local git repositories from the startup scan.
|
||||
fn render_my_repos(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let store = self.my_repos.as_ref();
|
||||
let local = LocalReposStore::global(cx);
|
||||
@@ -236,11 +265,10 @@ 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).
|
||||
// Local repositories already published to NIP-34 appear above.
|
||||
// Hide them from the local section here.
|
||||
// Matched by the identifier derived from the directory name.
|
||||
// Same derivation as the init dialog's default name.
|
||||
let announced_ids: HashSet<String> =
|
||||
announcements.iter().map(|a| a.id.clone()).collect();
|
||||
let local_repos: Vec<PathBuf> = local_repos
|
||||
@@ -253,8 +281,8 @@ impl SidebarPanel {
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
// One merged list: the user's NIP-34 repositories first,
|
||||
// then the local repositories discovered by the scan.
|
||||
// One merged list, the user's NIP-34 repositories first.
|
||||
// Local repositories discovered by the scan follow.
|
||||
let total = announcements.len() + local_repos.len();
|
||||
|
||||
if total == 0 {
|
||||
@@ -297,8 +325,7 @@ impl SidebarPanel {
|
||||
})
|
||||
}
|
||||
|
||||
/// One row of the merged sidebar list: a NIP-34 repository or a local
|
||||
/// repository.
|
||||
/// One row of the merged sidebar list, a NIP-34 or a local repository.
|
||||
fn render_repo_row_at(
|
||||
&self,
|
||||
announcements: &[Announcement],
|
||||
@@ -328,17 +355,31 @@ impl SidebarPanel {
|
||||
.clone()
|
||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
|
||||
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.
|
||||
// The commits are ready to push to the grasp servers.
|
||||
let unpushed: usize = CheckoutsStore::global(cx)
|
||||
.read(cx)
|
||||
.push_statuses_of(&announcement.addr())
|
||||
.iter()
|
||||
.map(|status| status.ahead as usize)
|
||||
.sum();
|
||||
|
||||
let announcement = announcement.clone();
|
||||
let mut row = NavItem::new(format!("my-repo:{}", announcement.id), name, avatar);
|
||||
|
||||
if unpushed > 0 {
|
||||
row = row.suffix(CountBadge::new(unpushed));
|
||||
}
|
||||
|
||||
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, a deterministic pixel avatar seeded from the path.
|
||||
/// The directory name and a warning suffix, the repo is not yet set up for NIP-34.
|
||||
/// Clicking opens the detail view, which offers to initialize it.
|
||||
fn render_local_row(&self, path: &Path, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let name = path
|
||||
.file_name()
|
||||
@@ -366,7 +407,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 +437,8 @@ 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.
|
||||
/// Banner artwork behind a scrim keeps the CTA buttons readable in both themes.
|
||||
fn render_sign_in(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
|
||||
v_flex()
|
||||
.size_full()
|
||||
|
||||
@@ -15,10 +15,6 @@ pub struct OnboardingState {
|
||||
}
|
||||
|
||||
/// 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<InputState>,
|
||||
pass_input: Entity<InputState>,
|
||||
|
||||
@@ -17,10 +17,7 @@ pub struct PassphraseState {
|
||||
_enter_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
/// 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)
|
||||
@@ -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<InputState>,
|
||||
state: &Entity<PassphraseState>,
|
||||
|
||||
@@ -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<SelectOption>, Vec<SelectOption>) {
|
||||
(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<SelectState<Vec<SelectOption>>>,
|
||||
light_theme: Entity<SelectState<Vec<SelectOption>>>,
|
||||
@@ -66,8 +58,7 @@ struct SettingsControls {
|
||||
radius: Entity<InputState>,
|
||||
radius_lg: Entity<InputState>,
|
||||
grasp_server_input: Entity<InputState>,
|
||||
/// 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<InputState>,
|
||||
/// Keeps the control subscriptions alive for the dialog's lifetime.
|
||||
_subscriptions: Vec<Subscription>,
|
||||
@@ -296,8 +287,8 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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 +316,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 +387,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 +403,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 +468,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 +503,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 +556,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 +580,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<InputState>, window: &mut Window, cx: &mut App) {
|
||||
let value = input.read(cx).value().trim().to_owned();
|
||||
if value.is_empty() {
|
||||
@@ -660,8 +650,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<InputState>, window: &mut Window, cx: &mut App) {
|
||||
let handle = window.window_handle();
|
||||
let default_folder = default_folder.clone();
|
||||
@@ -695,8 +685,9 @@ fn choose_default_folder(default_folder: &Entity<InputState>, 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<InputState>,
|
||||
subscriptions: &mut Vec<Subscription>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+4
-4
@@ -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) {
|
||||
|
||||
+657
@@ -0,0 +1,657 @@
|
||||
# PLAN — PR contribution flows: fork compare + GRASP-06 hosting + checkout suggestions
|
||||
|
||||
> **Status (2026-09-03): implemented.** Steps 1-9 and 11 are done on
|
||||
> `feat/fork`; step 10 (sidebar "Ready to contribute" group) remains
|
||||
> deferred as planned (v2, optional). See `docs/PR_FLOW.md` for the
|
||||
> resulting flow; the per-step sections below record what shipped and
|
||||
> where the plan was refined during implementation.
|
||||
|
||||
Combined implementation plan for three coordinated improvements to Signed's pull
|
||||
request experience:
|
||||
|
||||
- **A. Fork-aware compare** — the New PR panel's compare side can come from an
|
||||
announced fork repository's branch (fetched into the base repo's GitCache
|
||||
mirror), instead of only from a user-picked local checkout.
|
||||
- **B. GRASP-06 hosting** — PR tips are pushed to the *author's own* grasp
|
||||
servers under `/prs/<author-npub>/<repo-id>.git` and advertised in the PR's
|
||||
`clone` tag, so contributing to someone else's project never depends on their
|
||||
servers accepting anything from you.
|
||||
- **C. Checkout associations & suggestions** — remember/derive which local
|
||||
folders are checkouts of which announced repos, auto-prefill the New PR panel
|
||||
(no folder picker for the common case), and suggest creating a PR when a
|
||||
branch is ahead with no open PR (GitHub-like nudge, NIP-34-native dedupe).
|
||||
|
||||
Guiding principles (agreed): follow nostr + grasp + ngit, not GitHub;
|
||||
NIP-34/GRASP-06 event surface stays untouched (no new tags/kinds, forks never
|
||||
appear in events); every flow keeps the patch series as the source of truth;
|
||||
no over-engineering — reuse existing stores, patterns and git helpers.
|
||||
|
||||
---
|
||||
|
||||
## 1. Protocol grounding (what we may and may not do)
|
||||
|
||||
### 1.1 NIP-34 facts used by this plan
|
||||
|
||||
- A PR (kind-1618) is addressed to the **base** repo coordinate (`a` tag) and
|
||||
carries `c` (tip), `merge-base` (common ancestor with the target branch),
|
||||
`branch-name`, `clone` (≥1 URL where the tip commit can be downloaded),
|
||||
`e` → root patch event, `r` (EUC), `p` (base owner). Patches are
|
||||
NIP-10-chained kind-1617 events, ≤60 KB each. Statuses 1630–1633 resolve the
|
||||
PR.
|
||||
- Repository announcements (30617): `u` tag marks a subordinate fork
|
||||
(`30617:<pubkey>:<id>` coordinate or git URL); the `r`/`euc` tag identifies
|
||||
the earliest unique commit, shared by every repo of the same project family
|
||||
(forks, mirrors). Both are **read-only inputs** for discovery.
|
||||
- Kind-10317 is the user grasp list (`g` tags, in preference order) — read-only
|
||||
input for hosting.
|
||||
- Anybody may open a PR on any announced repo; only the author may update it
|
||||
(1619); only the author or a maintainer may set status; merge is the
|
||||
maintainer's action.
|
||||
- "Patches and PRs to a repository SHOULD be sent to the relays specified in
|
||||
that repository's announcement" — i.e. the **base** repo's relays, always.
|
||||
|
||||
### 1.2 GRASP-06 facts (as ngit implements it — verified against ngit-cli)
|
||||
|
||||
- GRASP-06 servers expose a contributor namespace:
|
||||
`http(s)://<host>/prs/<contributor-npub>/<repo-id>.git` (input
|
||||
`ws://`/`wss://` base URLs normalize to `http(s)://`; npub in the URL, hex
|
||||
on the server's disk — a server detail). Anyone can push there; no
|
||||
announcement, no maintainer rights, no fork repo required.
|
||||
- The author's server is tried **first**; the base repo's announcement grasps
|
||||
still receive the same `refs/nostr/<event-id>` push as redundancy.
|
||||
- The PR event shape is unchanged; only *which URLs the `clone` tag lists*
|
||||
differs.
|
||||
|
||||
### 1.3 Consequences (locked decisions)
|
||||
|
||||
- Publishing keeps today's event set and tag semantics. The fork changes only
|
||||
where the patch series is generated from; GRASP-06 changes only where the tip
|
||||
is pushed and advertised; suggestions change nothing on the wire.
|
||||
- We never publish a reference to the fork or to `/prs/` hosting beyond legal
|
||||
`clone` URLs.
|
||||
- The PR `clone` tag is fixed before signing (the `refs/nostr/<event-id>` ref
|
||||
name embeds the event id), so it carries the full candidate URL set
|
||||
(author `/prs/` URLs + base announcement clone URLs). Dead URLs are inert —
|
||||
patch events remain the truth — and ngit readers fail over across URLs.
|
||||
(ngit instead rebuilds the event per server to keep a single clone URL; we
|
||||
deliberately do not copy that.)
|
||||
|
||||
---
|
||||
|
||||
## 2. Workstream A — Fork-aware compare
|
||||
|
||||
### 2.1 Model
|
||||
|
||||
The panel keeps today's behavior as the default source and adds a second:
|
||||
|
||||
- **Checkout** (existing): both selectors list a user-picked local checkout's
|
||||
branches; git ops + tip push run in the checkout.
|
||||
- **Fork** (new): the flow runs against the **base repo's GitCache mirror**
|
||||
`P_base = GitStore::global(cx).cache().repo_path(&base_addr)` (ensured via
|
||||
`GitCache::ensure_clone(&base_addr, &base_clone_urls)` + `fetch_all`):
|
||||
- "Merge Into" lists `P_base` branches (`refs/remotes/origin/*`);
|
||||
- "Pull From" lists the chosen fork's branches, imported into `P_base`;
|
||||
- `merge-base`, range commits/diff, `format-patch`, and the tip push all run
|
||||
against `P_base` — both histories share one object store, and commit-diff
|
||||
rows work because fork commits live there.
|
||||
|
||||
### 2.2 Git mechanics (import namespace)
|
||||
|
||||
Fork heads are fetched into `P_base` under a private namespace:
|
||||
|
||||
```
|
||||
git -C P_base fetch <fork-clone-url> '+refs/heads/*:refs/fork/<owner-hex>/<sanitized-id>/*'
|
||||
```
|
||||
|
||||
- `refs/fork/…` keeps imported refs away from `refs/remotes/*` and
|
||||
`refs/heads/*`, so the repo browser, `repo_branches` and DWIM checkout never
|
||||
see them.
|
||||
- Fetch tries each announced `clone` URL until one works (`grasp://` →
|
||||
`https://` rewrite, `GIT_TERMINAL_PROMPT=0`), like `clone_repo` /
|
||||
`push_commit_ref`.
|
||||
- Switching fork or refreshing: prune the old `refs/fork/<owner>/<id>/*`
|
||||
prefix first (`git update-ref --stdin` fed by `for-each-ref`), then
|
||||
re-import. All-heads import in one fetch; subsequent branch switches within
|
||||
the same fork are offline.
|
||||
- Range work uses full refs: `merge_base(P_base,
|
||||
"refs/remotes/origin/<base>", "refs/fork/…/<compare>")`, then the existing
|
||||
`worktree_commit_range_commits`/`worktree_commit_range_diff` /
|
||||
`format_patch_between`. None of these touch the checkout state.
|
||||
- Base `main` and fork `main` are different refs: the "choose different
|
||||
branches" guard compares full refs, display names stay short.
|
||||
|
||||
### 2.3 Fork discovery
|
||||
|
||||
Candidates = `RepoListStore::global(cx).read(cx).announcements` (already
|
||||
deletion-filtered, latest-wins) where
|
||||
`Announcement::is_fork_of(base_addr, base_euc)`:
|
||||
|
||||
- `upstream.addr == Some(base_addr)` (the `u` tag — also covers permanent
|
||||
forks whose EUC changed), **or**
|
||||
- `euc == base announcement's euc` (shared earliest-unique-commit family),
|
||||
excluding the base repo itself.
|
||||
|
||||
Ordering (identity-coherent, ngit-style): **your own forks first** (30617
|
||||
owner == signed-in user), then other authors' related repos (same mechanics,
|
||||
marked, niche). Announcements without `clone` URLs are excluded (unfetchable).
|
||||
Restricting to your own forks only later is a one-line ownership filter.
|
||||
|
||||
### 2.4 Panel behavior
|
||||
|
||||
- Defaults mirror `apply_checkout`: base = announced `store.head` if present in
|
||||
mirror branches, else `main`, else first; compare = fork's `main`, else
|
||||
first fork branch.
|
||||
- `submit`: `format_patch_between(P_base, merge_base, compare_ref)`; published
|
||||
`branch-name` = compare short name; `push_from = Some(P_base)` (fork objects
|
||||
are there after import). Publishing itself is workstream B.
|
||||
- `open_commit_diff` uses `P_base` in fork mode.
|
||||
- Errors: no common ancestor → existing message; unreachable base mirror or
|
||||
fork → inline error; empty range → existing "no commits to propose".
|
||||
|
||||
---
|
||||
|
||||
## 3. Workstream B — GRASP-06 author hosting
|
||||
|
||||
Applies to **every** PR publish from a repo path that has the objects —
|
||||
checkout mode and fork mode alike. `RepoStore::open_pull_request` keeps its
|
||||
signature; internals change:
|
||||
|
||||
1. **Resolve author grasp servers** (new shared helper): latest kind-10317
|
||||
grasp list of the signed-in user from the local DB (`filters::grasp_list`,
|
||||
`g` tags in order) → **fallback to settings defaults**
|
||||
(`GraspServersSettings.default_servers` / `DEFAULT_GRASP_SERVERS`, the same
|
||||
source the create-repo dialogs use) when no list is published.
|
||||
2. **Build `/prs/` URLs**: `grasp_base_url(server) + "/prs/" + user_npub +
|
||||
"/" + base_repo_id + ".git"` (npub form, like ngit; `grasp_base_url` maps
|
||||
wss→https, ws→http).
|
||||
3. **`clone` tag** = dedup of `/prs/` URLs plus the current base-announcement
|
||||
clone URLs (order: `/prs/` first — the author's servers are the most likely
|
||||
to be alive and author-controlled).
|
||||
4. **Push loop** = author `/prs/` servers first (guaranteed writable — the
|
||||
point of GRASP-06), then the base announcement grasp servers (existing
|
||||
behavior), all `refs/nostr/<event-id>` from `push_from`. Best-effort;
|
||||
zero successes → existing `last_warning` banner; publishing always
|
||||
proceeds.
|
||||
|
||||
Effect: a repo announced with relays but no reachable grasp hosting still gets
|
||||
a downloadable tip (on the author's own hosting), and git-native clients
|
||||
(ngit, `git-remote-nostr`) can fetch Signed PR tips from the `clone` URL.
|
||||
|
||||
**1619 updates are out of scope for v1**: the update dialog is paste-only, so
|
||||
no repo path holds the new tip's objects. Deferred until the existing
|
||||
"local-checkout generation for the update-PR dialog" TODO lands; then push the
|
||||
new tip to the same `/prs/` set under the PR's stable ref
|
||||
(`refs/nostr/<root-pr-event-id>`, advanced per revision — convention to verify
|
||||
against ngit-grasp first).
|
||||
|
||||
---
|
||||
|
||||
## 4. Workstream C — Checkout associations & suggestions
|
||||
|
||||
Three tiers: **Remember → Auto-pick → Suggest**.
|
||||
|
||||
### 4.1 Remember (associations)
|
||||
|
||||
A local folder ↔ announced repo association comes from two sources:
|
||||
|
||||
- **Explicit** (persistent settings records `{path, addr, last_used}`):
|
||||
recorded when the repo header **Clone** action succeeds (addr known) and
|
||||
when a folder pick succeeds in the New PR panel (store addr known).
|
||||
- **Implicit** (derived, no persistence): among `LocalReposStore` scan results
|
||||
(settings `local_repos.scan_paths`), a repo whose
|
||||
- `origin` URL matches an announcement `clone` URL (compare host+path,
|
||||
ignoring scheme: ws/wss/http/https/grasp are equivalent transports of the
|
||||
same grasp URL), or
|
||||
- root commit equals the announcement EUC
|
||||
is a checkout of that announced repo.
|
||||
|
||||
Resolution order per repo: remembered (freshest first) ∪ scanned-matched,
|
||||
deduplicated by path, skipping missing directories.
|
||||
|
||||
### 4.2 Auto-pick (New PR panel prefill)
|
||||
|
||||
`open_new_pull_panel(…)` gains a suggested-checkout parameter, resolved by the
|
||||
caller from the association store:
|
||||
|
||||
- **Exactly one** checkout → auto-apply it: selectors populate, base =
|
||||
announced HEAD, compare = current branch, diff loads. The folder button
|
||||
becomes "Change…".
|
||||
- **Several** → a small folder combobox instead of the modal folder picker.
|
||||
- **None** → today's flow unchanged.
|
||||
- Successful manual folder picks are recorded back (learning).
|
||||
|
||||
### 4.3 Suggest (status + surfaces)
|
||||
|
||||
A small checkout-status computation (part of the association store), scoped to
|
||||
the bounded set of associated checkouts, on background threads:
|
||||
|
||||
- Triggers: app open, window focus (debounced ~5 s), `LocalReposStore` rescan,
|
||||
`BackendEvent::Synced`.
|
||||
- Per checkout: current branch; commits ahead of the base branch (announced
|
||||
HEAD name if present locally, else `main`, else first local branch — the
|
||||
same rule as `apply_checkout`), via `rev-list --count`; whether the user has
|
||||
an **open** PR from that branch on the target repo (author == me,
|
||||
`branch-name` tag == branch, fallback: tip `c` tag == local HEAD).
|
||||
- Result states: `ReadyToCreate { target, branch, ahead, base }` /
|
||||
`HasOpenPr { … }` / `Idle`.
|
||||
- Noise rules: only when ahead > 0 and branch ≠ base; nothing for dirty
|
||||
worktrees; one entry per target repo.
|
||||
|
||||
Surfaces:
|
||||
|
||||
| Surface | Shows | Dedupe data source | Scope |
|
||||
|---|---|---|---|
|
||||
| Repo **PR list** banner (`PullRequestsView`) | "branch `feature` is 3 commits ahead of `main` — Create pull request →" (opens prefilled New PR) | live open `RepoStore` (precise) | v1 |
|
||||
| **Sidebar** "Ready to contribute" group | row per `ReadyToCreate`: target repo, branch ↑N → opens target repo + prefilled New PR | v1: only targets with a live open store, else a local-DB query refreshed after a lazy per-target bootstrap activity sync; if the repo has no data yet, the group omits it (no false "ready") | v2 (after v1 proves out) |
|
||||
| Repo detail header chip | tiny `feature ↑3` on the repo whose checkout is ahead | as PR-list banner | optional |
|
||||
|
||||
The PullRequestsView banner reuses the existing dismissible `Alert` banner
|
||||
pattern already used for store errors/warnings.
|
||||
|
||||
---
|
||||
|
||||
## 5. Combined flow (fork mode, end to end)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User (New PR panel)
|
||||
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
|
||||
|
||||
U->>U: pick fork repo (u/EUC relation, yours first) + branch
|
||||
U->>P: ensure_clone(base) + fetch_all
|
||||
P-->>F: fetch +refs/heads/*:refs/fork/<owner>/<id>/*
|
||||
P-->>U: base branches (origin/*) + fork branches (refs/fork/…)
|
||||
U->>P: merge-base, range commits, range diff (Files/Commits tabs)
|
||||
U->>P: submit: format-patch merge-base..fork-ref
|
||||
U->>R: publish kind-1617 series (root + NIP-10 chain, ≤60 KB each)
|
||||
U->>R: sign kind-1618 (a=base, c=fork tip, merge-base, branch-name, clone=[/prs/…, base clone URLs], e=root patch, r=EUC)
|
||||
U->>A: push tip → refs/nostr/<event-id> (author servers, first)
|
||||
U->>B: push tip → refs/nostr/<event-id> (best-effort redundancy)
|
||||
U->>R: publish kind-1618
|
||||
Note over R: zero successful pushes → last_warning banner only
|
||||
```
|
||||
|
||||
Checkout mode is identical except the fork-import step; suggestions (workstream
|
||||
C) only add entry-point shortcuts into this flow.
|
||||
|
||||
---
|
||||
|
||||
## 6. Step-by-step implementation
|
||||
|
||||
Phases are ordered so each step lands on green: foundations first, then the
|
||||
publish-side change (benefits the existing checkout flow immediately), then
|
||||
the fork UI, then the UX layer. Every step compiles, passes its tests, and
|
||||
keeps existing behavior unchanged.
|
||||
|
||||
### Phase 0 — Foundations
|
||||
|
||||
#### Step 1 — `signed_core`: fork relation predicate
|
||||
|
||||
- File: `crates/signed_core/src/model.rs`.
|
||||
- Add `Announcement::is_fork_of(&self, base: &RepoAddr, base_euc:
|
||||
Option<&str>) -> bool`:
|
||||
`upstream.addr == Some(base)` OR (`base_euc` present AND `self.euc ==
|
||||
base_euc`), excluding self (same owner + id).
|
||||
- Tests: u-tag coordinate match; shared EUC match; permanent fork with
|
||||
different EUC matched via `u`; no-match; base-self exclusion.
|
||||
- Done when: predicate + tests green; used by Step 5.
|
||||
|
||||
#### Step 2 — `signed_git`: mirror/import primitives
|
||||
|
||||
- File: `crates/signed_git/src/lib.rs`.
|
||||
- `fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) ->
|
||||
Result<()>` — CLI `git fetch <url> <refspec>`, grasp:// → https rewrite,
|
||||
`GIT_TERMINAL_PROMPT=0`, try each URL until one works (last-error on all
|
||||
failing, like `clone_repo`).
|
||||
- `refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>>` —
|
||||
full refnames under `prefix` (`git for-each-ref --format=%(refname)`),
|
||||
sorted.
|
||||
- `delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()>` —
|
||||
collect via `for-each-ref`, delete via `git update-ref --stdin` lines.
|
||||
- `origin_url(workdir: &Path) -> Result<Option<String>>` (used by Step 7).
|
||||
- Tests (existing `file://` fixture infra): import under a target prefix;
|
||||
URL fallback to the working server; prefix listing; prefix deletion;
|
||||
origin URL read.
|
||||
- Done when: helpers + tests green.
|
||||
|
||||
### Phase 1 — GRASP-06 hosting (publish side)
|
||||
|
||||
#### Step 3 — author grasp-server resolution
|
||||
|
||||
- File: `crates/signed_state/src/backend.rs` (or a small new module).
|
||||
- `resolve_user_grasp_servers(cx, user) -> Vec<RelayUrl>`: latest kind-10317
|
||||
of `user` from the local DB (`filters::grasp_list`; latest event wins; `g`
|
||||
tags in order) → fallback to settings `GraspServersSettings`
|
||||
defaults/`DEFAULT_GRASP_SERVERS` when the user has no grasp list.
|
||||
- Refactor the create-repo/init dialogs to share it (optional, keeps one
|
||||
resolution path).
|
||||
- Tests: latest-wins selection; missing list falls back; `g` order preserved.
|
||||
- Done when: helper + tests green.
|
||||
|
||||
#### Step 4 — `open_pull_request` hosting
|
||||
|
||||
- File: `crates/signed_state/src/repo.rs` (`open_pull_request`, ~L657).
|
||||
- Pure helpers (unit-testable): `grasp06_prs_url(base_url: &str, npub:
|
||||
&str, repo_id: &str) -> String`; push-target assembly (author `/prs/`
|
||||
URLs first, then announcement grasp URLs; dedup).
|
||||
- `clone` tag = `/prs/` URLs (author npub from `Backend::current_user()`,
|
||||
repo id from `self.addr().identifier`) + base announcement clone URLs.
|
||||
- Push loop extended: existing per-relay loop stays; author servers push to
|
||||
the `/prs/` URL instead of the repo URL. Warning semantics unchanged.
|
||||
- Tests: URL building; target order; dedup. Behavioral coverage of the full
|
||||
publish is manual/e2e (see §8) until a harness exists.
|
||||
- Done when: checkout-mode PRs push to the author's grasp list
|
||||
(`/prs/<npub>/<repo-id>.git`) first and the `clone` tag carries those URLs;
|
||||
all-servers-fail still publishes with a warning.
|
||||
|
||||
### Phase 2 — Fork-aware compare
|
||||
|
||||
#### Step 5 — fork candidates
|
||||
|
||||
- File: `crates/workspace/src/views/repo_detail/new_pull_request.rs` (helper)
|
||||
or `crates/signed_state`.
|
||||
- `fork_candidates(cx) -> Vec<Announcement>`: filter
|
||||
`RepoListStore::global().announcements` with `is_fork_of` (Step 1); exclude
|
||||
empty `clone`; sort own forks (owner == current user) first, then others,
|
||||
each group by recency/name. Re-read each time the picker opens.
|
||||
- Done when: helper returns the expected ordering for a mixed list.
|
||||
|
||||
#### Step 6 — New PR panel fork mode
|
||||
|
||||
- File: `crates/workspace/src/views/repo_detail/new_pull_request.rs`.
|
||||
- State: `CompareSource { Checkout, Fork { announcement } }`; per-mode item
|
||||
sets for both selectors; `mirror_path`; fork branch list; full-ref
|
||||
base/compare tracking (display keeps short names).
|
||||
- `choose_fork(announcement)` + `prepare_fork` (mirror `choose_checkout` /
|
||||
`apply_checkout` async shape, `compare_generation` guard): ensure base
|
||||
mirror (`ensure_clone` + `fetch_all`), prune previous `refs/fork/…`
|
||||
prefix, import fork heads (`fetch_repo_refs`), list both ref sets
|
||||
(`refs_with_prefix`), populate selectors with defaults, `reload_compare`.
|
||||
- `reload_compare` / `submit` / `open_commit_diff` become mode-aware (path +
|
||||
base ref + compare ref resolution; `format_patch_between` and
|
||||
`push_from` on `P_base`; published `branch-name` = short name).
|
||||
- UI (compare bar): source control next to "Pull From" (local checkout /
|
||||
announced fork), fork-repo combobox (grouped, own forks first), refresh
|
||||
affordance, "Change…" back to checkout; loading spinner; inline errors.
|
||||
- Done when: checkout mode is byte-identical in behavior; fork mode shows
|
||||
base/fork selectors, Files/Commits tabs, commit diffs, and publishes with
|
||||
the correct tags (manual §8).
|
||||
|
||||
### Phase 3 — Checkout associations & suggestions
|
||||
|
||||
#### Step 7 — association store
|
||||
|
||||
- Files: `crates/settings` (extend the settings model like
|
||||
`local_repos.scan_paths` with remembered checkouts
|
||||
`{path, addr, last_used}`); new `crates/signed_state/src/checkouts.rs`
|
||||
(global store, `Arc` + debounce pattern from `LocalReposStore`/
|
||||
`RepoListStore`).
|
||||
- API: `associations_for(addr) -> Vec<PathBuf>` (remembered freshest-first ∪
|
||||
scanned-matched by origin URL/EUC via Step 2's `origin_url` +
|
||||
`signed_git::root_commit`; scheme-insensitive URL compare; dedup; skip
|
||||
missing dirs); `record(path, addr)`.
|
||||
- Recording hooks: repo header `clone_to_folder` success
|
||||
(`repo_detail/mod.rs`) and `choose_checkout` success (panel).
|
||||
- Tests: matching by origin URL (scheme variants), by EUC, no match; dedup
|
||||
and ordering.
|
||||
- Done when: associations resolve correctly and persist.
|
||||
|
||||
#### Step 8 — New PR prefill
|
||||
|
||||
- Files: `new_pull_request.rs` (`open_new_pull_panel` + `new`); callers
|
||||
`repo_detail/mod.rs` header and `pull_requests.rs`.
|
||||
- Entry param `suggested_checkout: Option<PathBuf>` (default `None`);
|
||||
panel applies it on construction when the folder still exists, else falls
|
||||
back to the empty state. When several candidates exist the caller passes
|
||||
the freshest and the panel offers the others through a folder combobox
|
||||
(new small control next to the source button).
|
||||
- Done when: opening New PR on a repo with a remembered/matched checkout
|
||||
never shows the folder dialog; manual picks get remembered.
|
||||
|
||||
#### Step 9 — status computation + PR-list banner
|
||||
|
||||
- Files: `checkouts.rs` (status states + triggers + debounce), workspace
|
||||
`pull_requests.rs` (banner), `new_pull_request.rs` (accepts the banner's
|
||||
"create" click by opening prefilled).
|
||||
- Status rules from §4.3; banner dedupe against the live open `RepoStore`
|
||||
(author + `branch-name`, fallback tip match, open status only).
|
||||
- Done when: after committing on an associated checkout and opening the
|
||||
target repo's PR list, the banner appears exactly when ahead > 0 and no
|
||||
open PR exists, and disappears after creating/merging/evening.
|
||||
|
||||
#### Step 10 — sidebar "Ready to contribute" group (v2, optional)
|
||||
|
||||
- File: `crates/workspace/src/views/sidebar/mod.rs` (+ `checkouts.rs`
|
||||
support).
|
||||
- Only list targets with reliable dedupe data (live open store, else a
|
||||
local-DB activity query refreshed after a lazy per-target bootstrap
|
||||
activity sync); omit everything uncertain. Clicking a row opens the target
|
||||
repo (`open_repo_panel`) + prefilled New PR.
|
||||
- Done when: rows appear without false "ready" entries (dedupe-uncertain
|
||||
targets omitted).
|
||||
|
||||
### Phase 4 — Docs & validation
|
||||
|
||||
#### Step 11 — documentation and final validation
|
||||
|
||||
- Update `docs/PR_FLOW.md`: fork compare path, GRASP-06 server set + clone
|
||||
tag, suggestion surfaces; the mermaid sequence in §5.
|
||||
- Update `docs/TODO.md`: tick "Fork-aware compare…", "GRASP-06 …"; add the
|
||||
checkout-suggestions item; keep deferred items (1619 update push, sidebar
|
||||
group, reading-side clone-URL fetch) explicit.
|
||||
- Run the manual validation checklist (§8) end to end.
|
||||
|
||||
---
|
||||
|
||||
## 7. Error handling & edge cases (all inline or warnings, as today)
|
||||
|
||||
- Base mirror unreachable / no base `clone` URLs → panel error in fork mode;
|
||||
checkout mode unaffected.
|
||||
- Fork unreachable / without `clone` URLs (excluded from candidates) → panel
|
||||
error.
|
||||
- No common ancestor → existing error (range flow needs shared history; Send
|
||||
Patch remains the fallback).
|
||||
- Author has no 10317 list and no default servers → GRASP-06 adds nothing;
|
||||
today's warning stands.
|
||||
- Author's grasp server does not implement `/prs/` → its push fails silently
|
||||
in the loop; its URL in `clone` is inert; base grasps still tried.
|
||||
- Fork branch deleted upstream / fork switched → prune prefix + re-import;
|
||||
generation guard discards stale compares.
|
||||
- Concurrency on `P_base` with the repo browser: we never checkout; git ref
|
||||
locks make overlapping fetches safe (same class as today's browser refresh).
|
||||
- Multiple checkouts of one repo → freshest first, "Change…"/combobox for the
|
||||
rest.
|
||||
- Branch renamed after a PR → dedupe falls back to tip-commit matching;
|
||||
otherwise a duplicate suggestion may appear once (accepted v1 tradeoff).
|
||||
- Suggestions never block UI; results arrive as `Arc` swaps.
|
||||
|
||||
## 8. Non-goals / deferred (explicitly out of scope)
|
||||
|
||||
- 1619 update hosting (depends on the local-checkout update-dialog TODO).
|
||||
- Paste/Send-Patch flow keeps no git push (no object store; patches = truth;
|
||||
no scratch-apply resurrection).
|
||||
- Reading side: fetching other clients' PR tips from `clone` URLs into the
|
||||
mirror (`ngit pr checkout` analog) — only needed for patch-less PRs.
|
||||
- Fork creation UI (Signed still cannot announce forks; they come from ngit or
|
||||
by publishing a clone) — fork candidates simply won't include non-existent
|
||||
ones.
|
||||
- GitHub-isms rejected: no fork-network browser, no per-fork PR pages, no
|
||||
fork identity in events, no "compare across forks" for strangers' branches
|
||||
beyond what is listed above.
|
||||
- The panel never auto-submits anything; suggestions only navigate and
|
||||
prefill.
|
||||
|
||||
## 9. Validation checklist (manual e2e)
|
||||
|
||||
1. Checkout mode regression: clone a repo to disk, branch + commit (external
|
||||
git), New PR → choose folder → diff/commits → Create → PR appears on the
|
||||
target repo's PR list; tip pushed to the author's `/prs/` server(s) from
|
||||
the 10317 list (fallback: defaults); `clone` tag lists `/prs/` URLs first.
|
||||
2. All grasp servers down/absent → PR still publishes; warning banner shows.
|
||||
3. Fork mode: with a fork announcement related to the base (own fork first,
|
||||
other author's fork listed), pick repo + branch → selectors, Files/Commits
|
||||
tabs, commit-diff rows correct; published 1618 carries `a` = base
|
||||
coordinate, `c` = fork tip, `merge-base` = fork point, `branch-name` =
|
||||
fork branch; tip fetchable from the advertised `/prs/` URL via a plain
|
||||
`git fetch`.
|
||||
4. Prefill: reopen New PR for the same repo → folder auto-chosen, selectors
|
||||
populated; "Change…" works.
|
||||
5. Banner: with the repo's PR list open and an associated checkout ahead with
|
||||
no open PR → banner appears; disappears after publishing a PR, after
|
||||
merging, and when the branch is even.
|
||||
6. Interop: an ngit/git client fetches a Signed PR's tip from the `/prs/`
|
||||
clone URL (requires a GRASP-06-enabled server).
|
||||
|
||||
## 10. Implementation log (2026-09-03, branch `feat/fork`)
|
||||
|
||||
All steps below landed with unit tests; `cargo test` across `signed_core`
|
||||
(44), `signed_git` (61), `signed_state` (15), `workspace` (14) and
|
||||
`settings` (9) is green, and `cargo check` on the whole workspace passes.
|
||||
The manual e2e checklist above still needs a real GRASP-06 server run.
|
||||
|
||||
- **Step 1** — `Announcement::is_fork_of` (`signed_core::model`) + 4 tests.
|
||||
- **Step 2** — `signed_git`: `fetch_repo_refs`, `refs_with_prefix`,
|
||||
`delete_refs_with_prefix`, `origin_url`, `GitCache::root()` + 4 tests
|
||||
(import/list/prune against `file://` fixtures incl. URL fallback).
|
||||
- **Step 3** — backend grasp-list resolution: `grasp_list_servers`,
|
||||
`latest_grasp_list_servers`, `user_grasp_list_servers` (DB query, latest
|
||||
wins) + `grasp06_prs_url` and `pr_clone_urls` (author-first, dedup) + 4
|
||||
tests. `signed_state` gained a `settings` dependency for the defaults
|
||||
fallback.
|
||||
- **Step 4** — `RepoStore::open_pull_request` (signature unchanged):
|
||||
resolves the author's grasp servers (10317 → settings defaults) inside
|
||||
the publish task, builds the `clone` tag from `/prs/` URLs first, pushes
|
||||
author `/prs/` targets before the base announcement's servers, deduped;
|
||||
all-fail keeps the `last_warning` banner. 1619 updates untouched
|
||||
(deferred, as planned).
|
||||
- **Step 5** — `fork_candidates` ordering helper + 2 tests (own forks
|
||||
first; base/unrelated/no-clone excluded; EUC-less base still matches via
|
||||
`u`).
|
||||
- **Step 6** — New PR panel fork mode: `ForkCompare` state, `choose_fork`/
|
||||
`apply_fork` (mirror `ensure_clone` → prune `refs/fork` → import → list
|
||||
both ref sets), mode-aware `base_ref`/`compare_ref`/`work_path` used by
|
||||
`reload_compare`/`submit`/`open_commit_diff`, stale-result guard,
|
||||
refresh-by-re-picking + refresh button, and a "Source" picker menu
|
||||
(checkout rows + forks) replacing the folder button. Checkout mode stays
|
||||
byte-identical in behavior. Deviations from the plan: selectors and the
|
||||
source picker keep one shared layout (no separate fork-repo combobox —
|
||||
the source menu lists forks grouped own-first, matching the ordering
|
||||
requirement); `IconName::GitBranch` does not exist upstream so fork rows
|
||||
use the project's `CustomIconName::GitBranch`.
|
||||
- **Step 7** — settings `CheckoutRecord`/`CheckoutsSettings` group + new
|
||||
`signed_state::checkouts::CheckoutsStore` global (observe settings /
|
||||
local scan / announcements; debounced, coalesced, Arc-swapped):
|
||||
scheme-insensitive `same_repo_url`, `resolve_associations` (remembered
|
||||
freshest-first ∪ scanned origin/EUC matches, dedup, mirror-cache paths
|
||||
excluded), `record()`, per-repo `request_statuses`/`statuses_of` with
|
||||
`CheckoutStatus` (branch/head/base/ahead; dirty and detached checkouts
|
||||
never suggested; 15 s poll while any PR list is open). 7 tests.
|
||||
Deviations: settings records store the address as a string (the settings
|
||||
crate stays free of nostr types); mirror exclusion uses the cache root
|
||||
(new `GitCache::root()`); statuses are computed per requested repo with
|
||||
the announced HEAD supplied by the open list panel rather than from a
|
||||
30618 DB query.
|
||||
- **Step 8** — New PR panel prefills the freshest associated checkout on
|
||||
construction (no folder dialog); `apply_folder_path` applies a given
|
||||
path; successful folder picks and header clones are recorded back; the
|
||||
Source menu lists associated checkouts (checked when applied) plus
|
||||
"Choose another folder…". Deviations: instead of a separate folder
|
||||
combobox, the alternatives live in the Source menu (fewer controls, same
|
||||
outcome); `open_new_pull_panel` needed no signature change because the
|
||||
panel reads the association store itself.
|
||||
- **Step 9** — "ready to contribute" banner: `RepoDetailView` requests
|
||||
the statuses while the repository panel is open (re-requested when the
|
||||
announced HEAD lands or changes) and renders the banner under the repo
|
||||
header; the first ready checkout not covered by an open PR of the
|
||||
signed-in user (`branch-name`, fallback `c`-tag tip) and not dismissed
|
||||
(per-panel dismissal set) is offered with a Create button opening the
|
||||
prefilled panel. The dedupe predicate is the tested
|
||||
`pr_proposes_checkout` in `signed_state::checkouts`. Deviation from the
|
||||
plan: the surface is the repository panel (not the PR-list panel, as the
|
||||
user requested after v1; the PR list keeps only its error/warning
|
||||
banners), and there is no window-focus trigger (no precedent in the
|
||||
codebase; the 15 s poll plus open/rescan/settings triggers cover the
|
||||
plan's "done when" cases).
|
||||
- **Step 10** — deferred (v2, optional), per plan; the banner is the v1
|
||||
surface.
|
||||
- **Step 11** — `docs/PR_FLOW.md` rewritten for the current panel flow
|
||||
(fork import, GRASP-06 hosting, suggestions, deferred items explicit);
|
||||
`docs/TODO.md` updated; this log added. Manual e2e (§9) not yet run
|
||||
against a live GRASP-06 server.
|
||||
- **Fix (after e2e, user report)** — creating a repository left the
|
||||
project only inside the app's GitCache mirror: the announcement, state
|
||||
event and push happened, but the folder chosen in the Create Repository
|
||||
dialog was just remembered as a settings default. `Backend::
|
||||
create_repository` now also materializes a working copy at
|
||||
`<folder>/<sanitized-name>` (cloned from the mirror via a `file://` URL
|
||||
so it shares the announced history exactly, then `origin` re-pointed at
|
||||
the first grasp server through the new `signed_git::set_origin`), and
|
||||
the dialog records it as a checkout (`CheckoutsStore`, so the New PR
|
||||
panel pre-fills it), opens it in the system file manager and opens the
|
||||
repository panel. Materialization runs before any event is published,
|
||||
so a failure aborts creation cleanly with nothing announced. Two new
|
||||
`signed_git` tests (`set_origin_creates_or_replaces_the_remote`,
|
||||
`working_copy_cloned_from_the_mirror_matches_head_and_origin`).
|
||||
- **Add (after e2e, user request)** — "ready to push" watch for the
|
||||
user's own repositories: local commits made in a checkout (external
|
||||
git) surface as a **sidebar badge** on the repository row (a
|
||||
`CountBadge` with the unpushed commit count) and, when the repository
|
||||
panel is open, as an info **banner with a Push button**. The
|
||||
`CheckoutsStore` gains a second status family (`request_push_statuses`/
|
||||
`push_statuses_of`): per checked-out branch it refreshes the remote
|
||||
view (`git fetch` of the checkout's origin, offline-tolerant) and
|
||||
counts `origin/<branch>..<branch>` (`origin/HEAD` for branches the
|
||||
remote does not have yet); dirty/detached checkouts are skipped like
|
||||
the PR suggestions. Poll cadence: 15 s while a repository panel is
|
||||
open (`status_requested`), 60 s for the sidebar-only background watch;
|
||||
request sets are cleared on signer change. The repo panel's
|
||||
ready-to-contribute banner now applies only to repositories of other
|
||||
authors — owned repositories get the push banner instead, whose Push
|
||||
action calls the new `Backend::push_checkout` (shared body with the
|
||||
existing mirror-based `push_repository`): publishes a fresh 30618
|
||||
state event (keeping the announced `HEAD` branch when the checkout is
|
||||
on a side branch) then pushes every branch and tag to the announced
|
||||
grasp servers. New test
|
||||
`checkout_push_status_counts_unpushed_commits_only`. The mirror's file
|
||||
browser stays a snapshot (new commits appear after a branch switch),
|
||||
like the rest of the browser.
|
||||
- **Fix (after e2e, user report)** — a push warning "cannot lock ref
|
||||
'refs/heads/main': is at X but expected Y" (server-side compare-and-
|
||||
swap rejection, `incorrect old value provided`). Reproduced locally:
|
||||
two concurrent plain pushes of the *same* ref from the same base make
|
||||
the loser fail exactly this way — the app can race itself when two
|
||||
push sources for one repository run at once (two panels of the same
|
||||
repo, or the banner Push racing the header's Republish; each guard was
|
||||
per-view only). Fix: pushes are now single-flight per repository in
|
||||
`Backend::push_repo_from` via an `Arc<Mutex<HashSet<RepoAddr>>>` guard
|
||||
(`PushGuard`, RAII: the lock is released on completion, on error and on
|
||||
task cancellation alike); a second concurrent push fails fast with
|
||||
"A push to this repository is already in progress" instead of racing.
|
||||
Racing an external `git push` against the same server remains possible
|
||||
(benign: the ref converges; the loser logs a warning only).
|
||||
- **Fix (after e2e, user report)** — after a successful push the
|
||||
repository panel's commit list stayed on the old commit (even across
|
||||
restarts): the browser reads the GitCache mirror, and a fetch never
|
||||
moves a mirror's *local* branches — `origin/main` advanced while local
|
||||
`main` (what the commit list walks) stayed behind. ngit/nak never hit
|
||||
this because they operate on real clones the user `git pull`s; nak also
|
||||
publishes the updated 30618 state *before* each push, which Signed
|
||||
already did. Fixes, mirroring a `git pull --ff-only` on the browser
|
||||
clone: new `signed_git::fast_forward_branches(workdir)` (per local
|
||||
branch, when it is an ancestor of its `refs/remotes/origin/*`
|
||||
counterpart: the checked-out branch is merged so its worktree follows,
|
||||
dirty worktrees and local-only commits are never touched; returns
|
||||
whether anything moved); `RepoDetailView::load_repo`'s background
|
||||
refresh fast-forwards after `fetch_all` and rebuilds the explorer,
|
||||
previews and commit list (`reload_worktree`) when anything moved;
|
||||
`push_unpushed_checkout` reloads the mirror on success so an owned
|
||||
repo's pushed commit appears immediately; `ensure_origin` now also
|
||||
configures the standard `remote.origin.fetch` refspec (create-flow
|
||||
mirrors otherwise never map heads on fetch). New test
|
||||
`fast_forward_branches_moves_the_mirror_and_keeps_local_work`; the
|
||||
remote-only-branch limitation stays (a branch the mirror has never
|
||||
checked out is not listed), as documented.
|
||||
+71
-37
@@ -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/<owner-hex>/<id>/*` (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/<event-id>`
|
||||
(nak's convention) to the *author's* grasp servers first —
|
||||
`https://<host>/prs/<author-npub>/<repo-id>.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/<event-id>` (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)
|
||||
|
||||
@@ -136,4 +169,5 @@ Reader rules that keep the flow consistent:
|
||||
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.
|
||||
|
||||
+4
-2
@@ -33,8 +33,10 @@
|
||||
|
||||
- [ ] GRASP-06 `/prs/<npub>/<id>.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.
|
||||
- [ ] Local-checkout generation for the update-PR dialog (currently paste-only); once it lands, push update tips to the same `/prs/` set under the PR's stable ref.
|
||||
- [x] Fork-aware compare in the New PR panel: the compare side can come from an announced fork repository (u-tag/EUC-related, own forks first) whose branches are imported into the base repo's GitCache mirror under `refs/fork/<owner>/<id>/*`; `merge-base`/diff/`format-patch`/push all run in the mirror against full refs. The panel's source picker switches between local checkouts and announced forks.
|
||||
- [x] GRASP-06 author hosting: PR tips are pushed to the author's own grasp servers under `/prs/<author-npub>/<repo-id>.git` (kind-10317 grasp list, settings defaults as fallback) before the base announcement's servers; the `clone` tag carries the `/prs/` URLs first.
|
||||
- [ ] Checkout suggestions ("ready to contribute"): remembered/matched local checkouts prefill the New PR panel; a repo-panel banner suggests creating a PR when a branch is ahead with no open PR. Sidebar "Ready to contribute" group is v2 (deferred).
|
||||
|
||||
## Performance: render path
|
||||
|
||||
|
||||
Reference in New Issue
Block a user