5 Commits
Author SHA1 Message Date
reya 17de4f6376 review 2026-09-04 08:16:05 +07:00
reya 212f35d6bb update 2026-09-03 16:38:52 +07:00
reya 018395d0c5 add push 2026-09-03 15:22:37 +07:00
reya c0f06d095e create local repo 2026-09-03 13:59:54 +07:00
reya 01f0540726 update pull request 2026-09-03 10:44:38 +07:00
75 changed files with 5461 additions and 2221 deletions
Generated
+69 -87
View File
@@ -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"
+3 -3
View File
@@ -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
+18 -34
View File
@@ -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));
+2 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>,
+1 -2
View File
@@ -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)
})
+2 -9
View File
@@ -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
View File
@@ -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"))
-6
View File
@@ -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;
+46 -17
View File
@@ -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,
}
+10 -7
View File
@@ -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)?;
+3 -3
View File
@@ -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.
+25 -21
View File
@@ -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 -2
View File
@@ -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 {
+17 -14
View File
@@ -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");
+10 -9
View File
@@ -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
+33 -41
View File
@@ -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
View File
@@ -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(
+5 -6
View File
@@ -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;
+6 -6
View File
@@ -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,
File diff suppressed because it is too large Load Diff
+1 -6
View File
@@ -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());
+1 -2
View File
@@ -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>>>,
+2 -4
View File
@@ -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,
+4
View File
@@ -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
View File
@@ -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());
}
}
+962
View File
@@ -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
));
}
}
+3 -10
View File
@@ -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())
}
+5 -9
View File
@@ -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
}
+2 -5
View File
@@ -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))?;
}
+25 -19
View File
@@ -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
View File
@@ -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());
+51 -58
View File
@@ -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))?;
}
+2 -6
View File
@@ -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,
+20 -20
View File
@@ -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.))
+2 -2
View File
@@ -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 {
-30
View File
@@ -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;
+5 -4
View File
@@ -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>>,
}
+15 -12
View File
@@ -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 {
+9 -10
View File
@@ -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
+5 -9
View File
@@ -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>,
+2 -2
View File
@@ -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 => (
+6 -6
View File
@@ -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,
+3 -2
View File
@@ -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,
+4 -4
View File
@@ -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(),
+4 -3
View File
@@ -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");
}
}
+2 -2
View File
@@ -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()
+18 -28
View File
@@ -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();
}
+20 -21
View File
@@ -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.))
+77 -36
View File
@@ -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>,
+4 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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/&lt;owner&gt;/&lt;id&gt;/*"]
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
View File
@@ -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