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]] [[package]]
name = "accesskit_atspi_common" name = "accesskit_atspi_common"
version = "0.18.1" version = "0.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"accesskit_consumer 0.36.0", "accesskit_consumer",
"atspi-common", "atspi-common",
"phf 0.13.1", "phf 0.13.1",
"serde", "serde",
"zvariant", "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]] [[package]]
name = "accesskit_consumer" name = "accesskit_consumer"
version = "0.38.0" version = "0.38.0"
@@ -63,7 +43,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"accesskit_consumer 0.38.0", "accesskit_consumer",
"hashbrown 0.16.1", "hashbrown 0.16.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-app-kit 0.2.2", "objc2-app-kit 0.2.2",
@@ -72,9 +52,9 @@ dependencies = [
[[package]] [[package]]
name = "accesskit_unix" name = "accesskit_unix"
version = "0.21.1" version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"accesskit_atspi_common", "accesskit_atspi_common",
@@ -90,12 +70,12 @@ dependencies = [
[[package]] [[package]]
name = "accesskit_windows" name = "accesskit_windows"
version = "0.33.1" version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36e93ac7bf50b964f1cbb75f741629a4e950571baa1ef1274457ab5a80d9bcc2" checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"accesskit_consumer 0.37.0", "accesskit_consumer",
"hashbrown 0.16.1", "hashbrown 0.16.1",
"static_assertions", "static_assertions",
"windows 0.62.2", "windows 0.62.2",
@@ -342,9 +322,9 @@ dependencies = [
[[package]] [[package]]
name = "async-compression" name = "async-compression"
version = "0.4.43" version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" checksum = "515a1f282e33d55983c499d7e9e87082e81cbc32974825bf9032f928392d5844"
dependencies = [ dependencies = [
"compression-codecs", "compression-codecs",
"compression-core", "compression-core",
@@ -678,9 +658,9 @@ dependencies = [
[[package]] [[package]]
name = "bindgen" name = "bindgen"
version = "0.71.1" version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"cexpr", "cexpr",
@@ -1238,7 +1218,7 @@ dependencies = [
[[package]] [[package]]
name = "collections" name = "collections"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"gpui_util", "gpui_util",
"indexmap", "indexmap",
@@ -1263,9 +1243,9 @@ dependencies = [
[[package]] [[package]]
name = "compression-codecs" name = "compression-codecs"
version = "0.4.38" version = "0.4.39"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" checksum = "2fe67f2944eef52fc7b106b8c9450d243a88701a0c065f7f57235e76abaed7df"
dependencies = [ dependencies = [
"bzip2", "bzip2",
"compression-core", "compression-core",
@@ -1275,9 +1255,9 @@ dependencies = [
[[package]] [[package]]
name = "compression-core" name = "compression-core"
version = "0.4.32" version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
[[package]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
@@ -1704,7 +1684,7 @@ dependencies = [
[[package]] [[package]]
name = "derive_refineable" name = "derive_refineable"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -1866,7 +1846,7 @@ dependencies = [
"cc", "cc",
"memchr", "memchr",
"rustc_version", "rustc_version",
"toml 1.1.4+spec-1.1.0", "toml 1.1.5+spec-1.1.0",
"vswhom", "vswhom",
"winreg", "winreg",
] ]
@@ -3484,7 +3464,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui" name = "gpui"
version = "0.2.2" version = "0.2.2"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"anyhow", "anyhow",
@@ -3547,7 +3527,7 @@ dependencies = [
"uuid", "uuid",
"waker-fn", "waker-fn",
"web-time", "web-time",
"windows 0.61.3", "windows 0.62.2",
"zed-font-kit", "zed-font-kit",
"zed-scap", "zed-scap",
"ztracing", "ztracing",
@@ -3556,7 +3536,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui-base" name = "gpui-base"
version = "0.5.2" version = "0.5.2"
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5" source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"anyhow", "anyhow",
@@ -3592,7 +3572,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui-component" name = "gpui-component"
version = "0.5.2" version = "0.5.2"
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5" source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -3669,7 +3649,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui-component-assets" name = "gpui-component-assets"
version = "0.5.1" version = "0.5.1"
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5" source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"gpui", "gpui",
@@ -3683,7 +3663,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui-component-macros" name = "gpui-component-macros"
version = "0.5.1" version = "0.5.1"
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5" source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -3693,21 +3673,21 @@ dependencies = [
[[package]] [[package]]
name = "gpui-fps" name = "gpui-fps"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5" source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
dependencies = [ dependencies = [
"gpui", "gpui",
"instant",
"libc", "libc",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-io-kit", "objc2-io-kit",
"sysinfo 0.37.2", "sysinfo 0.37.2",
"web-time",
"windows 0.58.0", "windows 0.58.0",
] ]
[[package]] [[package]]
name = "gpui_apple" name = "gpui_apple"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"block", "block",
@@ -3730,7 +3710,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_linux" name = "gpui_linux"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"accesskit_unix", "accesskit_unix",
@@ -3776,7 +3756,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_macos" name = "gpui_macos"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"accesskit_macos", "accesskit_macos",
@@ -3822,7 +3802,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_macros" name = "gpui_macros"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
@@ -3833,7 +3813,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_platform" name = "gpui_platform"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"console_error_panic_hook", "console_error_panic_hook",
"gpui", "gpui",
@@ -3846,7 +3826,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_shared_string" name = "gpui_shared_string"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"schemars", "schemars",
"serde", "serde",
@@ -3856,7 +3836,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_util" name = "gpui_util"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"log", "log",
@@ -3866,7 +3846,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_web" name = "gpui_web"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"console_error_panic_hook", "console_error_panic_hook",
@@ -3890,7 +3870,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_wgpu" name = "gpui_wgpu"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytemuck", "bytemuck",
@@ -3916,7 +3896,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_windows" name = "gpui_windows"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"accesskit_windows", "accesskit_windows",
@@ -3935,10 +3915,10 @@ dependencies = [
"raw-window-handle", "raw-window-handle",
"smallvec", "smallvec",
"uuid", "uuid",
"windows 0.61.3", "windows 0.62.2",
"windows-core 0.61.2", "windows-core 0.62.2",
"windows-numerics 0.2.0", "windows-numerics 0.3.1",
"windows-registry 0.5.3", "windows-registry 0.6.1",
] ]
[[package]] [[package]]
@@ -4230,7 +4210,7 @@ dependencies = [
[[package]] [[package]]
name = "http_client" name = "http_client"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-compression", "async-compression",
@@ -4250,7 +4230,7 @@ dependencies = [
[[package]] [[package]]
name = "http_client_tls" name = "http_client_tls"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"log", "log",
"rustls", "rustls",
@@ -5034,9 +5014,9 @@ dependencies = [
[[package]] [[package]]
name = "lru" name = "lru"
version = "0.18.3" version = "0.18.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c" checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25"
[[package]] [[package]]
name = "lru-slab" name = "lru-slab"
@@ -5206,7 +5186,7 @@ dependencies = [
[[package]] [[package]]
name = "media" name = "media"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bindgen", "bindgen",
@@ -5300,9 +5280,9 @@ dependencies = [
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.2" version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [ dependencies = [
"libc", "libc",
"log", "log",
@@ -6119,9 +6099,9 @@ checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9"
[[package]] [[package]]
name = "open" name = "open"
version = "5.4.2" version = "5.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ade3be4664bc1ef537ce133015f04c176b737815c2ba9fd60edf212d6e90dd55" checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634"
dependencies = [ dependencies = [
"is-wsl", "is-wsl",
"libc", "libc",
@@ -6264,7 +6244,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "perf" name = "perf"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"collections", "collections",
"serde", "serde",
@@ -7052,7 +7032,7 @@ dependencies = [
[[package]] [[package]]
name = "refineable" name = "refineable"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"derive_refineable", "derive_refineable",
] ]
@@ -7135,7 +7115,7 @@ dependencies = [
[[package]] [[package]]
name = "reqwest_client" name = "reqwest_client"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -7513,7 +7493,7 @@ dependencies = [
[[package]] [[package]]
name = "scheduler" name = "scheduler"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"async-task", "async-task",
"backtrace", "backtrace",
@@ -7969,9 +7949,11 @@ dependencies = [
"nostr-connect", "nostr-connect",
"nostr-sdk", "nostr-sdk",
"rustls", "rustls",
"settings",
"signed_core", "signed_core",
"signed_git", "signed_git",
"signed_nostr", "signed_nostr",
"tempfile",
"utils", "utils",
] ]
@@ -8227,7 +8209,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "sum_tree" name = "sum_tree"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"heapless 0.9.3", "heapless 0.9.3",
"log", "log",
@@ -8813,9 +8795,9 @@ dependencies = [
[[package]] [[package]]
name = "toml" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"serde_core", "serde_core",
@@ -9625,7 +9607,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "util_macros" name = "util_macros"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"perf", "perf",
"quote", "quote",
@@ -10505,13 +10487,13 @@ dependencies = [
[[package]] [[package]]
name = "windows-registry" name = "windows-registry"
version = "0.5.3" version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [ dependencies = [
"windows-link 0.1.3", "windows-link 0.2.1",
"windows-result 0.3.4", "windows-result 0.4.1",
"windows-strings 0.4.2", "windows-strings 0.5.1",
] ]
[[package]] [[package]]
@@ -11394,7 +11376,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]] [[package]]
name = "zlog" name = "zlog"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -11411,7 +11393,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]] [[package]]
name = "ztracing" name = "ztracing"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
dependencies = [ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@@ -11422,7 +11404,7 @@ dependencies = [
[[package]] [[package]]
name = "ztracing_macro" name = "ztracing_macro"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397" source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
[[package]] [[package]]
name = "zune-core" name = "zune-core"
+3 -3
View File
@@ -151,14 +151,14 @@ mod tests {
assert_eq!(theme.border, parse("#27272A")); // neutral-800 assert_eq!(theme.border, parse("#27272A")); // neutral-800
assert_eq!(theme.green, parse("#22C55E")); // green-500 assert_eq!(theme.green, parse("#22C55E")); // green-500
} else { } 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.background, parse("#FFFFFF"));
assert_eq!(theme.foreground, parse("#18181B")); assert_eq!(theme.foreground, parse("#18181B"));
assert_eq!(theme.border, parse("#E4E4E7")); assert_eq!(theme.border, parse("#E4E4E7"));
assert_eq!(theme.green, parse("#16A34A")); assert_eq!(theme.green, parse("#16A34A"));
} }
// Active tab: a paler lime on light, a dim moss on dark — each // Active tab, a paler lime on light and a dim moss on dark.
// paired with readable, contrasting text. // Each is paired with readable contrasting text.
if config.mode.is_dark() { if config.mode.is_dark() {
assert_eq!(theme.tab_active, parse("#19200A")); // dim lime assert_eq!(theme.tab_active, parse("#19200A")); // dim lime
assert_eq!(theme.tab_active_foreground, parse("#C6FF4D")); // nostr-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::cell::Cell;
use std::ops::Deref as _; use std::ops::Deref as _;
use std::rc::Rc; use std::rc::Rc;
@@ -26,8 +22,7 @@ use crate::tab_panel::SignedTabGroupSkin;
use crate::tiles::SignedTilesSkin; use crate::tiles::SignedTilesSkin;
use crate::{TAB_BAR_HEIGHT, panel_handle}; use crate::{TAB_BAR_HEIGHT, panel_handle};
/// What every part of the skin reads, and the dock area it belongs to. /// State the skin shares with its per-container renderers.
/// Shared by reference with the per-container renderers.
pub(crate) struct SkinShared { pub(crate) struct SkinShared {
area: WeakEntity<DockArea>, area: WeakEntity<DockArea>,
toggle_button_visible: Cell<bool>, toggle_button_visible: Cell<bool>,
@@ -53,16 +48,14 @@ impl SkinShared {
&self.resizing_dock &self.resizing_dock
} }
/// Redraw the area after a setting changed. The skin is not an entity, so /// Redraw the area after a setting changed. The skin is not an entity, so nothing else would.
/// nothing else would notice.
pub(crate) fn notify(&self, cx: &mut App) { pub(crate) fn notify(&self, cx: &mut App) {
_ = self.area.update(cx, |_, cx| cx.notify()); _ = self.area.update(cx, |_, cx| cx.notify());
} }
} }
/// The Signed appearance for a [`DockArea`]. /// The Signed appearance for a [`DockArea`].
/// /// Install it in the constructor, the only place the area's weak handle is available.
/// Install it at construction, where the area's own weak handle is available:
/// ///
/// ```ignore /// ```ignore
/// let dock = cx.new(|cx| { /// let dock = cx.new(|cx| {
@@ -90,8 +83,7 @@ impl SignedDockSkin {
&self.shared &self.shared
} }
/// Whether tab bars offer the affordance that collapses a neighbouring /// Whether tab bars offer the affordance that collapses a neighbouring dock.
/// dock.
pub fn is_toggle_button_visible(&self) -> bool { pub fn is_toggle_button_visible(&self) -> bool {
self.shared.is_toggle_button_visible() 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 /// Payload a dock's resize handle drags.
/// itself is the affordance. /// It draws nothing, the handle element is the visible affordance.
#[derive(Clone)] #[derive(Clone)]
struct ResizePanel; struct ResizePanel;
@@ -144,9 +136,7 @@ impl DockAreaRenderer for SignedDockSkin {
} }
fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, cx: &mut App) -> Stateful<Div> { 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` // `size_full` and `flex_1` stop the frame collapsing in an unsizing parent.
// is belt and braces so the frame never collapses to zero height in
// an unsizing parent.
div() div()
.id(("dock-split-frame", node.as_u64())) .id(("dock-split-frame", node.as_u64()))
.size_full() .size_full()
@@ -166,8 +156,8 @@ impl DockAreaRenderer for SignedDockSkin {
let placement = dock.placement(); let placement = dock.placement();
let open = dock.is_open(); let open = dock.is_open();
// A closed left or right dock takes no space at all; a closed bottom // A closed left or right dock takes no space.
// dock keeps a strip so its tab bar stays clickable. // A closed bottom dock keeps a strip so its tab bar stays clickable.
if !open && !placement.is_bottom() { if !open && !placement.is_bottom() {
return div().into_any_element(); return div().into_any_element();
} }
@@ -183,8 +173,7 @@ impl DockAreaRenderer for SignedDockSkin {
// Base never builds a dock for the centre. // Base never builds a dock for the centre.
DockPlacement::Center => this, DockPlacement::Center => this,
}) })
// The closed bottom dock's strip is the tab bar itself, which is // The closed bottom dock's strip is the tab bar itself, a full tab bar tall.
// a full tab bar tall.
.when(!open && placement.is_bottom(), |this| { .when(!open && placement.is_bottom(), |this| {
this.h(TAB_BAR_HEIGHT) this.h(TAB_BAR_HEIGHT)
}) })
@@ -197,9 +186,8 @@ impl DockAreaRenderer for SignedDockSkin {
.into_any_element() .into_any_element()
} }
/// The "unknown panel" message the old `InvalidPanel` drew. It answers /// Placeholder for a panel this build cannot construct.
/// `dump` with the state it was handed, so a layout written by a build /// It dumps the state it was handed, so the layout survives a load and save.
/// that knows the panel survives a load and save here.
fn build_placeholder( fn build_placeholder(
&self, &self,
state: &PanelState, state: &PanelState,
@@ -241,10 +229,8 @@ impl SignedDockSkin {
} }
} }
/// Turns the window's mouse stream into dock resizing. A resize is driven /// Turns the window's mouse stream into dock resizing.
/// by pointer moves anywhere in the window, so this paints nothing and /// It draws nothing, the `paint` hook is the only window listener registration point.
/// exists for its `paint` hook — the only place a window-level mouse
/// listener can be registered.
struct DockResizeTracker { struct DockResizeTracker {
dock: DockContext, dock: DockContext,
shared: Rc<SkinShared>, shared: Rc<SkinShared>,
@@ -310,10 +296,8 @@ impl Element for DockResizeTracker {
if !phase.bubble() || shared.resizing_dock().get() != Some(placement) { if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
return; return;
} }
// Dragging a closed dock's handle reopens it. The live // Dragging a closed dock's handle reopens it.
// state is read rather than the render-time snapshot in // Read the live state, the snapshot in `dock` would toggle it shut again.
// `dock`, which would still say closed for the rest of the
// frame and toggle it shut again on the next move.
let open = shared let open = shared
.area() .area()
.upgrade() .upgrade()
@@ -332,8 +316,8 @@ impl Element for DockResizeTracker {
return; return;
} }
shared.resizing_dock().set(None); shared.resizing_dock().set(None);
// The size lives on the dock, not in the layout tree, so // The size lives on the dock, not the layout tree.
// nothing else tells a subscriber to persist it. // Nothing else tells a subscriber to persist it.
_ = shared _ = shared
.area() .area()
.update(cx, |_, cx| cx.emit(DockEvent::LayoutChanged)); .update(cx, |_, cx| cx.emit(DockEvent::LayoutChanged));
+2 -4
View File
@@ -7,10 +7,8 @@ use gpui_component::ActiveTheme as _;
use crate::Panel; use crate::Panel;
/// Stands in for a panel this build cannot construct. It reports the /// Stands in for a panel this build cannot construct.
/// original [`PanelState`] from [`dump`](gpui_base::dock::Panel::dump), so /// It returns the state it was handed, so the layout survives a load and save.
/// a layout written by a build that knows the panel survives a load and
/// save here.
pub(crate) struct InvalidPanel { pub(crate) struct InvalidPanel {
name: SharedString, name: SharedString,
focus_handle: FocusHandle, 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}; use gpui::{Pixels, px};
mod dock_area; 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. /// The fixed height of the tab bar, which doubles as the window title bar.
pub const TAB_BAR_HEIGHT: Pixels = px(44.); pub const TAB_BAR_HEIGHT: Pixels = px(44.);
/// Minimal i18n shim replacing gpui-component's `rust_i18n::t!()`, keeping the /// i18n shim resolving `Dock.*` keys to English, so the crate has no i18n dependency.
/// same `Dock.*` keys resolved to English so the crate has no i18n dependency.
pub(crate) fn t(key: &'static str) -> &'static str { pub(crate) fn t(key: &'static str) -> &'static str {
match key { match key {
"Dock.Unnamed" => "Unnamed", "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::cell::Cell;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
@@ -38,12 +28,10 @@ use crate::{
ClosePanel, PanelControl, PanelHandle, TAB_BAR_HEIGHT, ToggleZoom, t, window_controls, ClosePanel, PanelControl, PanelHandle, TAB_BAR_HEIGHT, ToggleZoom, t, window_controls,
}; };
/// The size the styled drag preview occupies, reported to base so a drop /// The drag preview's size, reported to base for the drop placeholder.
/// placeholder knows where to fly in from.
const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = size(px(96.), px(30.)); 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 /// A panel's title, or its registered name when the panel has no handle.
/// crate's handle and so carries no presentation. See [`PanelHandle::of`].
pub(crate) fn panel_title( pub(crate) fn panel_title(
panel: &Arc<dyn BasePanelView>, panel: &Arc<dyn BasePanelView>,
window: &mut Window, window: &mut Window,
@@ -56,9 +44,7 @@ pub(crate) fn panel_title(
} }
/// The preview that follows the cursor while a panel is dragged. /// The preview that follows the cursor while a panel is dragged.
/// /// Base's `DragPanel` is the payload and draws nothing, this is the appearance half.
/// `gpui_base::dock::DragPanel` is the payload and draws nothing; this is the
/// appearance half, reintroduced here.
struct DragPanelPreview { struct DragPanelPreview {
panel: Arc<dyn BasePanelView>, 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` /// The zoom affordance for the group's displayed panel, if it offers one.
/// when there is none to offer. Both [`Panel::zoom_control`] (where) and /// The panel must offer a control and be zoomable, base refuses a zoom otherwise.
/// [`gpui_base::dock::Panel::zoomable`] (whether) must pass; base refuses a
/// zoom that fails the latter.
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> { fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
let panel = group.active_panel()?; let panel = group.active_panel()?;
panel panel
@@ -95,8 +79,8 @@ fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
.flatten() .flatten()
} }
/// The left-most, top-most tab group in a container — where a left dock's /// The left-most, top-most tab group in a container.
/// collapse affordance goes. Mirrors the old `StackPanel::left_top_tab_panel`. /// A left dock's collapse button lives in this group.
fn left_top_group(node: &PaneNode) -> Option<NodeId> { fn left_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() { match node.kind() {
PaneRef::Tabs { .. } => Some(node.id()), 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, /// The right-most, top-most tab group.
/// so its *first* child is the top one; a horizontal split's last child is /// A vertical split picks its first child, a horizontal split picks its last.
/// the right-most. Mirrors the old `StackPanel::right_top_tab_panel`.
fn right_top_group(node: &PaneNode) -> Option<NodeId> { fn right_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() { match node.kind() {
PaneRef::Tabs { .. } => Some(node.id()), 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 /// One tab group's appearance, built once per container so its geometry is its own.
/// scroll position and measured title-bar geometry belong to the group.
pub(crate) struct SignedTabGroupSkin { pub(crate) struct SignedTabGroupSkin {
shared: Rc<SkinShared>, shared: Rc<SkinShared>,
scroll_handle: ScrollHandle, scroll_handle: ScrollHandle,
/// The displayed tab the last frame drew, so a change scrolls the new tab /// The tab shown last frame, so a change scrolls the new one into view.
/// into view.
last_active_ix: Cell<Option<usize>>, last_active_ix: Cell<Option<usize>>,
/// Bounds of the title bar row (the wrapper around the tab bar), in /// Bounds of the title bar row, measured to place the title-bar drag overlay.
/// window coordinates. Measured via `on_prepaint` to position the
/// title-bar drag overlay.
title_bar_bounds: Rc<Cell<Option<Bounds<Pixels>>>>, title_bar_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
/// Bounds of the tab bar's trailing empty space (right after the last /// Bounds of the empty strip after the last tab, where the drag region starts.
/// tab), which marks where the draggable region starts.
title_bar_strip_bounds: Rc<Cell<Option<Bounds<Pixels>>>>, title_bar_strip_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
/// Bounds of the tab bar's suffix (toolbar) area, which marks where the /// Bounds of the suffix area, where the drag region ends.
/// draggable region ends.
title_bar_suffix_bounds: Rc<Cell<Option<Bounds<Pixels>>>>, 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 /// A group that is the left dock's only group, with one panel, draws no chrome.
/// draws no chrome at all — the vendored dock rendered such a panel bare, /// The vendored dock rendered such a panel bare and the sidebar is one.
/// and the sidebar is one.
fn is_plain_sidebar_group(&self, group: &TabGroupContext, cx: &mut App) -> bool { fn is_plain_sidebar_group(&self, group: &TabGroupContext, cx: &mut App) -> bool {
let Some(area) = self.shared.area().upgrade() else { let Some(area) = self.shared.area().upgrade() else {
return false; return false;
@@ -169,11 +145,8 @@ impl SignedTabGroupSkin {
left == group.node() && group.panels().len() == 1 left == group.node() && group.panels().len() == 1
} }
/// The bottom or right dock whose root tab group this group is, if any. /// 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.
/// 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.
fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option<DockPlacement> { fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option<DockPlacement> {
let area = self.shared.area().upgrade()?; let area = self.shared.area().upgrade()?;
let area = area.read(cx); 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 /// The tab's drag payload, or `None` when the group must not be rearranged.
/// not be rearranged. A locked group is never draggable; a group that is /// A locked group never is, a bottom or right dock root always is.
/// a bottom/right dock's only content still is, because the center is
/// always there to land in.
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> { fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
if group.is_locked() { if group.is_locked() {
return None; return None;
@@ -199,8 +170,8 @@ impl SignedTabGroupSkin {
group.drag_panel(ix, cx) group.drag_panel(ix, cx)
} }
/// Whether a dock's collapse affordance belongs in *this* group's tab /// A dock's collapse button for this group's bar, or `None` when it does not belong.
/// bar, and which way it points. `None` means this group draws none. /// The icon direction depends on whether the dock is open.
fn dock_toggle_button( fn dock_toggle_button(
&self, &self,
placement: DockPlacement, placement: DockPlacement,
@@ -213,8 +184,7 @@ impl SignedTabGroupSkin {
let area = self.shared.area().upgrade()?; let area = self.shared.area().upgrade()?;
let area = area.read(cx); let area = area.read(cx);
// A dock that does not exist is not collapsible, so this covers the // A missing dock is not collapsible, this also covers the old `left_dock.is_some()` test.
// old `left_dock.is_some()` test too.
if !area.is_dock_collapsible(placement) { if !area.is_dock_collapsible(placement) {
return None; return None;
} }
@@ -263,8 +233,8 @@ impl SignedTabGroupSkin {
) )
} }
/// The previous/next tab buttons shown in the tab bar's leading prefix. /// The previous and next tab buttons in the tab bar's leading prefix.
/// Always rendered, disabled at the ends of the strip (or collapsed). /// Always rendered, disabled at the strip ends or when collapsed.
fn render_prev_next_tab_buttons( fn render_prev_next_tab_buttons(
&self, &self,
group: &TabGroupContext, group: &TabGroupContext,
@@ -306,8 +276,7 @@ impl SignedTabGroupSkin {
) )
} }
/// The trailing controls: the panel's own buttons, the zoom affordance, /// The trailing controls, the panel's own buttons, zoom and the ellipsis menu.
/// and the ellipsis menu.
fn render_toolbar( fn render_toolbar(
&self, &self,
group: &TabGroupContext, group: &TabGroupContext,
@@ -323,9 +292,8 @@ impl SignedTabGroupSkin {
let control = zoom_control(group, cx); let control = zoom_control(group, cx);
let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible()); let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible());
let menu_zoom = control.is_some_and(|control| control.menu_visible()); let menu_zoom = control.is_some_and(|control| control.menu_visible());
// A bottom/right dock's only panel cannot be closed through the // A bottom or right dock's only panel cannot close through the group.
// group (base keeps a dock's last group), but the skin handles that // The close item is offered, the skin removes the whole dock instead.
// close by removing the whole dock, so the item is offered.
let closable = group.is_closable() let closable = group.is_closable()
|| (self.is_dock_root_group(group, cx).is_some() || (self.is_dock_root_group(group, cx).is_some()
&& group.active_panel().is_some_and(|panel| panel.closable(cx))); && 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 /// One tab of the pill strip.
/// style and all interactions, and the strip becomes the way a closed /// While collapsed, tabs lose the active style and all interactions.
/// bottom dock is opened again. /// The strip is also how a closed bottom dock is opened again.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn render_tab( fn render_tab(
&self, &self,
@@ -433,8 +401,7 @@ impl SignedTabGroupSkin {
Some(tab_name) => this.child(tab_name), Some(tab_name) => this.child(tab_name),
None => this.child(panel_title(&panel, window, cx)), None => this.child(panel_title(&panel, window, cx)),
}) })
// Pill presentation: the selected tab is the filled pill, the // Pill style, the selected tab is the filled pill, others show only on hover.
// rest are transparent until hovered.
.styles(|styles| { .styles(|styles| {
styles.selected(|style| { styles.selected(|style| {
style style
@@ -457,8 +424,7 @@ impl SignedTabGroupSkin {
move |_, window, cx| { move |_, window, cx| {
group.select_tab(ix, window, cx); group.select_tab(ix, window, cx);
// Clicking the strip of a collapsed bottom dock is how it // Clicking the strip of a collapsed bottom dock reopens it.
// is opened again.
if is_bottom_dock && collapsed { if is_bottom_dock && collapsed {
_ = area.update(cx, |area, cx| { _ = area.update(cx, |area, cx| {
area.toggle_dock(DockPlacement::Bottom, window, 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 /// The strip after the last tab, a drop target for panels and other drag items.
/// drag items. Its left edge (right after the last tab) marks the start /// Its left edge marks where the title-bar drag overlay starts.
/// of the title-bar drag overlay.
fn render_empty_space( fn render_empty_space(
&self, &self,
group: &TabGroupContext, group: &TabGroupContext,
@@ -541,9 +506,8 @@ impl SignedTabGroupSkin {
let group = group.clone(); let group = group.clone();
let node = group.node(); let node = group.node();
move |drag: &DragPanel, window, cx| { move |drag: &DragPanel, window, cx| {
// A panel dropped past its own last tab lands in the // A panel dropped past its own last tab lands in the final slot.
// final slot; one from elsewhere is appended in the // A panel from elsewhere is appended in the background.
// background.
let ix = (drag.source() == node).then(|| tabs_count - 1); let ix = (drag.source() == node).then(|| tabs_count - 1);
group.drop_panel(drag.clone(), ix, false, window, cx); group.drop_panel(drag.clone(), ix, false, window, cx);
} }
@@ -564,37 +528,30 @@ impl SignedTabGroupSkin {
impl TabGroupRenderer for SignedTabGroupSkin { impl TabGroupRenderer for SignedTabGroupSkin {
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> { fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
let control = zoom_control(group, cx); let control = zoom_control(group, cx);
// An emptied group — its last panel was dragged away — draws nothing, // An emptied group draws nothing, so no bare tab bar is left behind.
// so an emptied dock does not leave a bare tab bar behind.
if group.panels().is_empty() { if group.panels().is_empty() {
return div().id("tab-panel"); return div().id("tab-panel");
} }
// Closing the only panel of a bottom/right dock would leave an // Base refuses an empty dock, so closing its only panel removes the dock.
// empty dock, which base refuses; the skin removes the dock instead.
let dock_to_remove = (group.panels().len() <= 1) let dock_to_remove = (group.panels().len() <= 1)
.then(|| self.is_dock_root_group(group, cx)) .then(|| self.is_dock_root_group(group, cx))
.flatten(); .flatten();
let shared = self.shared.clone(); let shared = self.shared.clone();
// `v_flex`, not `div`: gpui's default display is Block, and in block // `v_flex`, a plain `div` ignores `flex_grow` and the content would collapse.
// layout a child's `flex_grow` is ignored — the content region below
// the tab bar would resolve to zero height.
v_flex() v_flex()
.id("tab-panel") .id("tab-panel")
.size_full() .size_full()
.overflow_hidden() .overflow_hidden()
.bg(cx.theme().tokens.background) .bg(cx.theme().tokens.background)
// A collapsed group is a strip of tabs with no content, and the // A collapsed group has no content, so these actions are not registered.
// actions act on content.
.when(!group.is_collapsed(), |this| { .when(!group.is_collapsed(), |this| {
this.on_action({ this.on_action({
let group = group.clone(); let group = group.clone();
move |_: &ToggleZoom, window, cx| { move |_: &ToggleZoom, window, cx| {
// The affordance decides the control, so a panel // A panel with no zoom control is not zoomed in by the keybinding.
// offering none is not zoomed *in* by the keybinding // Zooming out is never refused.
// either. Zooming out is never refused: a panel that // Otherwise a zoomed panel that lost its control would strand the user.
// stopped offering the control while zoomed would
// otherwise strand the user with no way back.
if !group.is_zoomed() && control.is_none() { if !group.is_zoomed() && control.is_none() {
return; return;
} }
@@ -628,8 +585,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
fn content_frame(&self, group: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful<Div> { fn content_frame(&self, group: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
v_flex() v_flex()
.id("active-panel") .id("active-panel")
// A collapsed group draws its tab strip and nothing else, so the // A collapsed group draws its tab strip only, so the content claims no space.
// content region must not claim any space.
.when(!group.is_collapsed(), |this| this.flex_1()) .when(!group.is_collapsed(), |this| this.flex_1())
} }
@@ -639,14 +595,12 @@ impl TabGroupRenderer for SignedTabGroupSkin {
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> AnyElement { ) -> AnyElement {
// An emptied group draws no tab bar; the app prunes the emptied // An emptied group draws no tab bar, the app prunes the emptied dock later.
// bottom/right dock a moment later.
if group.panels().is_empty() { if group.panels().is_empty() {
return Empty.into_any_element(); return Empty.into_any_element();
} }
// The sidebar group draws no chrome at all, like the vendored dock's // The sidebar group draws no chrome, like the vendored `DockItem::Panel`.
// bare `DockItem::Panel`.
if self.is_plain_sidebar_group(group, cx) { if self.is_plain_sidebar_group(group, cx) {
return Empty.into_any_element(); 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 right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let is_bottom_dock = bottom_dock_button.is_some(); let is_bottom_dock = bottom_dock_button.is_some();
// macOS: the traffic lights overlay the window's top-left corner. // On macOS the traffic lights overlay the window's top-left corner.
// Only the group whose tab bar actually sits under them reserves the // Only the tab bar that sits under them reserves the space.
// space — the center's left-most, top-most group when the left dock // That is the center's top-left group when the left dock is closed or absent.
// is closed or absent.
let needs_traffic_light_padding = cfg!(target_os = "macos") let needs_traffic_light_padding = cfg!(target_os = "macos")
&& self.shared.area().upgrade().is_some_and(|area| { && self.shared.area().upgrade().is_some_and(|area| {
let area = area.read(cx); let area = area.read(cx);
@@ -674,8 +627,8 @@ impl TabGroupRenderer for SignedTabGroupSkin {
== Some(group.node()) == Some(group.node())
}); });
// Bring a newly displayed tab into view. The group owns selection // Bring a newly displayed tab into view.
// now, so the skin notices the change rather than being told about it. // The group owns selection, so the skin watches for the change itself.
let displayed = group.active_panel().map(|panel| panel.panel_id(cx)); let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
let visible: Vec<usize> = group let visible: Vec<usize> = group
.panels() .panels()
@@ -690,10 +643,8 @@ impl TabGroupRenderer for SignedTabGroupSkin {
self.scroll_handle.scroll_to_item(visible_ix); self.scroll_handle.scroll_to_item(visible_ix);
} }
// The tab strip lays out at content width, so the area after the // The tab strip ends at the last tab, the bar has no element after it.
// last tab has no element. Cover that dead zone (last tab's right // Cover that dead zone with an overlay so it can drag the window.
// edge to suffix's left edge) with a measured overlay so the whole
// non-interactive area can drag the window.
let drag_overlay = match ( let drag_overlay = match (
self.title_bar_bounds.get(), self.title_bar_bounds.get(),
self.title_bar_strip_bounds.get(), self.title_bar_strip_bounds.get(),
@@ -728,8 +679,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
if !panel.visible(cx) { if !panel.visible(cx) {
return None; return None;
} }
// A collapsed group shows no tab as active: the strip is a // Collapsed tabs never show as active, the strip only reopens the dock.
// way back in, not a selection.
if collapsed { if collapsed {
active = false; active = false;
} }
@@ -768,7 +718,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
h_flex() h_flex()
.items_center() .items_center()
.top_0() .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.)) .right(-px(1.))
.h_full() .h_full()
.gap_2() .gap_2()
@@ -852,9 +802,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
cx: &mut App, cx: &mut App,
) -> Option<AnyElement> { ) -> Option<AnyElement> {
let (from, to) = (indicator.from(), indicator.to()); let (from, to) = (indicator.from(), indicator.to());
// The placeholder animates from wherever it was to where the drop // The element sits at the drop target, the animation walks back from the source.
// would land, so its own element is positioned at the destination and
// the animation only has to walk the difference back to zero.
let offset = from.origin() - to.origin(); let offset = from.origin() - to.origin();
Some( 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 std::rc::Rc;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
@@ -52,9 +44,7 @@ impl Render for DragResizing {
} }
/// One tiles canvas's appearance. /// One tiles canvas's appearance.
/// /// Built once per container, so its scroll position belongs to the canvas it scrolls.
/// Built per canvas — `DockAreaRenderer::tiles_renderer` is called once per
/// container — so the scroll position belongs to the canvas it scrolls.
pub(crate) struct SignedTilesSkin { pub(crate) struct SignedTilesSkin {
shared: Rc<SkinShared>, shared: Rc<SkinShared>,
scroll_handle: ScrollHandle, scroll_handle: ScrollHandle,
@@ -101,11 +91,8 @@ impl SignedTilesSkin {
}) })
} }
/// The trailing controls of a tile's title bar: zoom, close and the /// The trailing controls of a tile's title bar, zoom, close and the ellipsis menu.
/// ellipsis menu. They use click handlers rather than the /// They use click handlers, the zoom and close actions target a focused tab group.
/// [`ToggleZoom`](crate::ToggleZoom)/[`ClosePanel`](crate::ClosePanel)
/// actions, which are dispatched to a focused tab group — a tile is
/// not one.
fn render_tile_controls( fn render_tile_controls(
&self, &self,
tile: &TileContext, tile: &TileContext,
@@ -213,21 +200,17 @@ impl TilesRenderer for SignedTilesSkin {
.border_1() .border_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
.rounded(cx.theme().tile_radius) .rounded(cx.theme().tile_radius)
// Room for the title bar, which is positioned over the padding so // Room for the title bar, which overlays the top padding.
// the panel below it is never covered. Base draws the panel view // Base draws the panel as a plain child, this keeps them apart.
// as a plain child, so this is the only way to keep the two from
// overlapping.
.pt(DRAG_BAR_HEIGHT) .pt(DRAG_BAR_HEIGHT)
// Base installs the stored bounds on an ordinary tile and nothing // Base stores no bounds on a zoomed tile, the skin decides how it fills the dock.
// at all on a zoomed one — how a zoomed tile fills the dock is
// this skin's decision.
.when(tile.is_zoomed(), |this| this.size_full()) .when(tile.is_zoomed(), |this| this.size_full())
.on_mouse_down(MouseButton::Left, { .on_mouse_down(MouseButton::Left, {
let tile = tile.clone(); let tile = tile.clone();
move |_, window, cx| tile.bring_to_front(window, cx) move |_, window, cx| tile.bring_to_front(window, cx)
}) })
// A gesture can end with the pointer anywhere, so both halves are // A gesture can end anywhere, so both mouse-up hooks run.
// needed; each is a no-op unless this tile is the one moving. // Each is a no-op unless this tile is the one that moved.
.on_mouse_up(MouseButton::Left, { .on_mouse_up(MouseButton::Left, {
let tile = tile.clone(); let tile = tile.clone();
move |_, window, cx| { move |_, window, cx| {
@@ -274,8 +257,7 @@ impl TilesRenderer for SignedTilesSkin {
) )
.children(handle.and_then(|handle| handle.title_suffix(window, cx))) .children(handle.and_then(|handle| handle.title_suffix(window, cx)))
.child(self.render_tile_controls(tile, window, cx)) .child(self.render_tile_controls(tile, window, cx))
// A zoomed tile is not at its stored bounds, so there is nothing // A zoomed tile is not at its stored bounds, so moving it would mean nothing.
// for a move to mean; base refuses the gesture too.
.when(!tile.is_zoomed(), |this| { .when(!tile.is_zoomed(), |this| {
this.cursor_grab() this.cursor_grab()
.on_mouse_down(MouseButton::Left, { .on_mouse_down(MouseButton::Left, {
@@ -309,10 +291,8 @@ impl TilesRenderer for SignedTilesSkin {
) -> AnyElement { ) -> AnyElement {
let bounds = tile.bounds(); let bounds = tile.bounds();
// A passive full-tile box so each handle is positioned against the // A passive full-tile box, so handles sit against the tile, not its flow neighbours.
// tile rather than against whatever the flow put it next to. It // It registers no interaction, so it does not shadow the panel underneath.
// registers no interaction of its own, so it does not shadow the panel
// underneath.
div() div()
.absolute() .absolute()
.top_0() .top_0()
@@ -376,9 +356,7 @@ impl TilesRenderer for SignedTilesSkin {
.into_any_element() .into_any_element()
} }
/// The panel of a tile gets `size_full` here; base draws the panel as a /// Gives the tile's panel `size_full`, base draws it as a plain child otherwise.
/// plain child, so without it a panel that does not size itself has no
/// size.
fn panel_frame(&self, tile: &TileContext, _: &mut Window, _: &mut App) -> Stateful<Div> { fn panel_frame(&self, tile: &TileContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
h_flex() h_flex()
.id(("tile-panel", tile.panel_id().as_u64())) .id(("tile-panel", tile.panel_id().as_u64()))
@@ -386,9 +364,8 @@ impl TilesRenderer for SignedTilesSkin {
.size_full() .size_full()
} }
/// The canvas scrollbar. It must be an overlay: the frame is the scroll /// The canvas scrollbar, as an overlay.
/// container and base appends the tiles after it, so a scrollbar placed /// Placed inside the frame it would end up underneath every tile.
/// inside would paint and hit-test underneath every tile.
fn render_overlay( fn render_overlay(
&self, &self,
content: Size<Pixels>, 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() .items_center()
.flex_shrink_0() .flex_shrink_0()
.h_full() .h_full()
// Like native windows apps, the controls span the title bar but never // The controls span the title bar but never grow past the tab bar height.
// grow past the tab bar height.
.when(cfg!(target_os = "windows"), |this| { .when(cfg!(target_os = "windows"), |this| {
this.max_h(TAB_BAR_HEIGHT) 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 dock::{BasePanel, Panel, SignedDockSkin, panel_handle};
use gpui::{ use gpui::{
App, AppContext, Context, Empty, EventEmitter, FocusHandle, Focusable, IntoElement, Render, 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 // The first frame walks every render hook, all of which read the dock area.
// tab bar, the toolbar — all of which read the dock area.
cx.update(|window, cx| window.draw(cx).clear(cx)); cx.update(|window, cx| window.draw(cx).clear(cx));
// Emptying a dock leaves an empty group behind; its render must also be // Emptying a dock leaves an empty group, its render must also be safe.
// safe (and draw nothing).
cx.update(|window, cx| { cx.update(|window, cx| {
area.update(cx, |area, cx| { area.update(cx, |area, cx| {
area.remove_panel(bottom, window, 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::path::PathBuf;
use std::sync::OnceLock; use std::sync::OnceLock;
/// The application name, used to derive platform-specific data, config and /// The application name.
/// cache directory paths. /// It derives the platform-specific data, config and cache directory paths.
pub const APP_NAME: &str = "Signed"; pub const APP_NAME: &str = "Signed";
/// Lowercased form of [`APP_NAME`], for use in XDG-style paths on /// Lowercased form of [`APP_NAME`].
/// Linux/FreeBSD and the macOS `~/.config` fallback. /// Used in XDG-style paths on Linux and FreeBSD, and the macOS `~/.config` fallback.
pub const APP_NAME_LOWERCASE: &str = "signed"; pub const APP_NAME_LOWERCASE: &str = "signed";
/// A custom data directory override, set only by [`set_custom_data_dir`]. /// 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") dirs::home_dir().expect("failed to determine home directory")
} }
/// Returns the current user's Desktop folder, falling back to the home /// Returns the current user's Desktop folder.
/// directory (or an empty path) when it can't be determined. /// Falls back to the home directory or an empty path when it cannot be determined.
pub fn desktop_dir() -> PathBuf { pub fn desktop_dir() -> PathBuf {
dirs::desktop_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default()) dirs::desktop_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
} }
/// Returns the current user's Documents folder, falling back to the home /// Returns the current user's Documents folder.
/// directory (or an empty path) when it can't be determined. /// Falls back to the home directory or an empty path when it cannot be determined.
pub fn documents_dir() -> PathBuf { pub fn documents_dir() -> PathBuf {
dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default()) dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
} }
/// Sets a custom directory for all user data, overriding the default data /// Sets a custom directory for all user data, overriding the default data directory.
/// directory. Must be called before any other path operation. The directory /// Must be called before any other path operation.
/// is created if it doesn't exist and canonicalized to an absolute path. /// The directory is created when missing and canonicalized to an absolute path.
///
/// # Panics /// # Panics
/// /// Panics when called after [`data_dir`] or [`config_dir`] was initialized.
/// Panics if called after [`data_dir`] or [`config_dir`] was initialized, or /// Panics when the directory cannot be created or canonicalized.
/// if the directory cannot be created/canonicalized.
pub fn set_custom_data_dir(dir: &str) -> &'static PathBuf { pub fn set_custom_data_dir(dir: &str) -> &'static PathBuf {
if CURRENT_DATA_DIR.get().is_some() || CONFIG_DIR.get().is_some() { 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"); 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 { pub fn nostr_dir() -> &'static PathBuf {
static NOSTR_DIR: OnceLock<PathBuf> = OnceLock::new(); static NOSTR_DIR: OnceLock<PathBuf> = OnceLock::new();
NOSTR_DIR.get_or_init(|| data_dir().join("nostr")) 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 { pub fn repos_dir() -> &'static PathBuf {
static REPOS_DIR: OnceLock<PathBuf> = OnceLock::new(); static REPOS_DIR: OnceLock<PathBuf> = OnceLock::new();
REPOS_DIR.get_or_init(|| data_dir().join("repos")) 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 settings;
mod store; mod store;
+46 -17
View File
@@ -2,8 +2,8 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// The default grasp servers offered when the user hasn't published a /// The default grasp servers,
/// grasp list (kind `10317`) yet. /// offered while the user has not published a grasp list.
pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [ pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [
"wss://relay.ngit.dev", "wss://relay.ngit.dev",
"wss://gitnostr.com", "wss://gitnostr.com",
@@ -14,7 +14,7 @@ pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum AppearanceMode { pub enum AppearanceMode {
/// Follow the system appearance (light/dark) at runtime. /// Follow the system appearance, light or dark, at runtime.
#[default] #[default]
System, System,
/// Always use the light theme. /// Always use the light theme.
@@ -23,12 +23,8 @@ pub enum AppearanceMode {
Dark, Dark,
} }
/// Theme configuration. /// Theme configuration,
/// /// fields mirror the gpui-component `Theme` surface customized at startup.
/// 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.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct ThemeSettings { pub struct ThemeSettings {
@@ -42,7 +38,7 @@ pub struct ThemeSettings {
pub mono_font_size: f32, pub mono_font_size: f32,
/// Corner radius for general elements in pixels. /// Corner radius for general elements in pixels.
pub radius: f32, 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, pub radius_lg: f32,
/// Whether focused controls draw a ring outside their border. /// Whether focused controls draw a ring outside their border.
pub focus_ring: bool, pub focus_ring: bool,
@@ -69,8 +65,7 @@ impl Default for ThemeSettings {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct GraspServersSettings { pub struct GraspServersSettings {
/// The servers offered when the user hasn't published a grasp list /// Servers offered while the user has not published a grasp list, kind `10317`.
/// (kind `10317`) yet.
pub default_servers: Vec<String>, pub default_servers: Vec<String>,
} }
@@ -89,9 +84,8 @@ impl Default for GraspServersSettings {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct LocalReposSettings { pub struct LocalReposSettings {
/// The directories scanned for local git repositories. /// The directories scanned for local git repositories,
/// /// defaults to the user's Desktop and Documents folders.
/// Defaults to the user's Desktop and Documents folders.
pub scan_paths: Vec<PathBuf>, 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. /// The create-repository dialog.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct CreateRepositorySettings { pub struct CreateRepositorySettings {
/// The folder the create-repository dialog defaults to; the user's /// The folder the create-repository dialog defaults to, the user's Desktop when unset.
/// Desktop when unset.
pub default_folder: Option<PathBuf>, pub default_folder: Option<PathBuf>,
} }
@@ -128,6 +155,8 @@ pub struct Settings {
pub grasp_servers: GraspServersSettings, pub grasp_servers: GraspServersSettings,
/// Local repository scanning. /// Local repository scanning.
pub local_repos: LocalReposSettings, pub local_repos: LocalReposSettings,
/// Remembered local checkouts.
pub checkouts: CheckoutsSettings,
/// The create-repository dialog. /// The create-repository dialog.
pub create_repository: CreateRepositorySettings, pub create_repository: CreateRepositorySettings,
} }
+10 -7
View File
@@ -9,7 +9,9 @@ struct GlobalSettingsStore(Entity<SettingsStore>);
impl Global for GlobalSettingsStore {} 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. /// Installed as a global by the app so any part of the UI can read and edit them.
pub struct SettingsStore { pub struct SettingsStore {
path: PathBuf, path: PathBuf,
@@ -17,7 +19,7 @@ pub struct SettingsStore {
} }
impl 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> { pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalSettingsStore>().0.clone() cx.global::<GlobalSettingsStore>().0.clone()
} }
@@ -27,9 +29,11 @@ impl SettingsStore {
cx.set_global(GlobalSettingsStore(entity)); cx.set_global(GlobalSettingsStore(entity));
} }
/// Load the settings from `path`, falling back to the defaults when the /// Load the settings from `path`,
/// file is missing or unreadable. Missing keys merge with the defaults, /// falls back to defaults when the file is missing or unreadable.
/// so older settings files keep working as new settings are added. ///
/// 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 { pub fn new(path: impl AsRef<Path>, _cx: &mut Context<Self>) -> Self {
Self { Self {
path: path.as_ref().to_path_buf(), path: path.as_ref().to_path_buf(),
@@ -78,8 +82,7 @@ impl SettingsStore {
} }
} }
/// Write the settings to disk, replacing the file atomically /// Write the settings to disk, replacing the file atomically.
/// so a crash mid-write cannot corrupt the settings.
fn save(&self) -> Result<()> { fn save(&self) -> Result<()> {
if let Some(parent) = self.path.parent() { if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
+3 -3
View File
@@ -1,9 +1,9 @@
use nostr::prelude::*; 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 Rust Nostr SDK's [`Coordinate`] parses, formats and hashes this,
/// The alias keeps the repository-specific vocabulary while reusing the SDK type. /// the alias reuses the SDK type while keeping repository-specific vocabulary.
pub type RepoAddr = Coordinate; pub type RepoAddr = Coordinate;
/// Build the address of a NIP-34 repository announcement. /// Build the address of a NIP-34 repository announcement.
+25 -21
View File
@@ -1,13 +1,15 @@
use nostr::prelude::*; use nostr::prelude::*;
/// ngit / GitWorkshop cover-note extension (kind 1624): a markdown note /// GitWorkshop and `ngit` cover-note extension, kind 1624.
/// 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. /// 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); pub const COVER_NOTE_KIND: Kind = Kind::Custom(1624);
/// Whether a kind-1985 label event is a valid annotation of `root`: it /// Whether a kind-1985 label event is a valid annotation of `root`.
/// references the root via a lowercase `e` tag and was authored by the root ///
/// author or a maintainer. /// 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 { fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) -> bool {
if event.kind != Kind::Label { if event.kind != Kind::Label {
return false; 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)) .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 /// Whether a kind-1985 label event declares the `#t` namespace,
/// least one `["l", "<value>", "#t"]` label. /// it must also carry at least one `["l", "<value>", "#t"]` label.
fn has_hashtag_labels(event: &Event) -> bool { fn has_hashtag_labels(event: &Event) -> bool {
event.tags.iter().any(|tag| tag.as_slice() == ["L", "#t"]) event.tags.iter().any(|tag| tag.as_slice() == ["L", "#t"])
&& event.tags.iter().any(|tag| { && 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 /// Effective hashtag labels of `root`,
/// (self-reported by its author) plus all labels attached via authorized /// the `t` tags on the event itself, self-reported by its author,
/// NIP-32 kind-1985 events in the `#t` namespace. Labels are additive — all /// authorized NIP-32 kind-1985 events in the `#t` namespace add more.
/// valid label events contribute (no latest-wins semantics). ///
/// 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> { pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -> Vec<String> {
let mut labels: Vec<String> = root let mut labels: Vec<String> = root
.tags .tags
@@ -61,10 +65,10 @@ pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -
labels labels
} }
/// The effective subject/title override of `root`, from authorized kind-1985 /// Subject or title override of `root` from authorized kind-1985 label events,
/// label events in the `#subject` namespace. Only the latest event wins /// only label events in the `#subject` namespace count.
/// (tiebreak: lexicographically larger event id, per NIP-01 replaceable ///
/// semantics). Returns `None` when no valid override exists. /// Returns `None` when no valid override exists.
pub fn subject_override( pub fn subject_override(
root: &Event, root: &Event,
label_events: &[Event], label_events: &[Event],
@@ -103,8 +107,8 @@ pub fn subject_override(
}) })
} }
/// The effective hashtag labels and subject override of `root` in one pass /// Effective hashtag labels and subject override of `root` in one pass,
/// (mirrors ngit's `get_labels_and_subject`). /// mirrors ngit's `get_labels_and_subject`.
pub fn labels_and_subject( pub fn labels_and_subject(
root: &Event, root: &Event,
label_events: &[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 /// Effective cover note of `root`.
/// (tiebreak: lexicographically larger event id, per NIP-01 replaceable ///
/// semantics). Returns `None` when no valid cover note exists. /// Returns `None` when no valid cover note exists.
pub fn cover_note<'a>( pub fn cover_note<'a>(
root: &Event, root: &Event,
cover_notes: &'a [Event], cover_notes: &'a [Event],
+2 -2
View File
@@ -2,10 +2,10 @@ use nostr::prelude::*;
use crate::RepoAddr; 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum CloneTarget { pub enum CloneTarget {
/// `nostr://<naddr1...>` — direct repository address. /// `nostr://<naddr1...>` encodes a direct repository address.
Addr(RepoAddr), Addr(RepoAddr),
/// `nostr://<npub|nip05>/[relay-hint/]<identifier>` /// `nostr://<npub|nip05>/[relay-hint/]<identifier>`
UserRepo { UserRepo {
+17 -14
View File
@@ -2,8 +2,8 @@ use std::collections::{HashMap, HashSet};
use nostr::prelude::*; use nostr::prelude::*;
/// A NIP-22 comment thread: a top-level comment on the root event and its /// A NIP-22 comment thread, a top-level comment on the root event,
/// nested replies (oldest first at every level). /// nested replies are ordered oldest first at every level.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommentThread { pub struct CommentThread {
/// The thread's top-level comment. /// The thread's top-level comment.
@@ -12,8 +12,8 @@ pub struct CommentThread {
pub replies: Vec<CommentThread>, pub replies: Vec<CommentThread>,
} }
/// The direct parent of a comment (NIP-22 lowercase `e` tag), or `None` for /// The direct parent id of a comment, from its NIP-22 lowercase `e` tag.
/// comments without one. /// `None` when no `e` tag is present.
fn comment_parent(event: &Event) -> Option<EventId> { fn comment_parent(event: &Event) -> Option<EventId> {
event event
.tags .tags
@@ -23,14 +23,16 @@ fn comment_parent(event: &Event) -> Option<EventId> {
.and_then(|id| EventId::parse(id).ok()) .and_then(|id| EventId::parse(id).ok())
} }
/// Group the comments on a root event (issue / patch / PR) into NIP-22 /// Group the comments on a root issue, patch or PR into NIP-22 threads,
/// threads. A comment whose parent is the root itself starts a thread; other /// a comment whose parent is the root starts a thread.
/// comments nest under their parent comment. Threads and replies are ordered ///
/// oldest-first. Replies whose parent comment is missing (e.g. not fetched) /// Other comments nest under their parent comment.
/// are placed as top-level threads so they are not dropped. ///
/// 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> { pub fn comment_threads(root: &Event, comments: &[Event]) -> Vec<CommentThread> {
// Index comments by their parent id. Comments without a parent tag are // Index comments by their parent id.
// treated as replying to the root event itself. // Comments without a parent tag reply to the root event itself.
let mut children: HashMap<EventId, Vec<&Event>> = HashMap::new(); let mut children: HashMap<EventId, Vec<&Event>> = HashMap::new();
for comment in comments { for comment in comments {
let parent = comment_parent(comment).unwrap_or(root.id); 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); let mut threads = build(root.id, &children, &mut visited);
// Orphan replies: their parent comment is unknown, so they never appear // Orphan replies have an unknown parent comment, so they never reach the root tree.
// in the tree rooted at the root event; surface them as top-level threads. // Surface them as top-level threads so they are not dropped.
let mut orphans: Vec<&Event> = comments let mut orphans: Vec<&Event> = comments
.iter() .iter()
.filter(|event| !visited.contains(&event.id)) .filter(|event| !visited.contains(&event.id))
@@ -148,7 +150,8 @@ mod tests {
.expect("signed event"); .expect("signed event");
let a = comment(&keys, Some(&root), "a", 100); 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") let missing = EventBuilder::new(Kind::Comment, "missing")
.finalize(&keys) .finalize(&keys)
.expect("signed event"); .expect("signed event");
+10 -9
View File
@@ -2,15 +2,17 @@ use std::collections::HashSet;
use nostr::prelude::*; use nostr::prelude::*;
/// NIP-09 deletion requests and NIP-62 vanish requests, used to hide /// NIP-09 deletion requests and NIP-62 vanish requests,
/// deleted events before they reach the UI. /// 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; /// Deleted events are hidden before they reach the UI.
/// pass any event through [`Deletions::is_deleted`] before displaying it. ///
/// Pass any event through [`Deletions::is_deleted`] before showing it.
pub struct Deletions { pub struct Deletions {
/// `(deleted event id, expected author)` from `e` tags of kind-5 events. /// `(deleted event id, expected author)` from `e` tags of kind-5 events.
ids: HashSet<(EventId, PublicKey)>, ids: HashSet<(EventId, PublicKey)>,
/// `(coordinate, expected author, cutoff)` from `a` tags of kind-5 events. /// `(coordinate, expected author, cutoff)` from `a` tags of kind-5 events.
///
/// All versions of the addressable event up to `cutoff` are deleted. /// All versions of the addressable event up to `cutoff` are deleted.
coords: Vec<(Coordinate, PublicKey, Timestamp)>, coords: Vec<(Coordinate, PublicKey, Timestamp)>,
/// `(author, cutoff)` from kind-62 vanish requests. /// `(author, cutoff)` from kind-62 vanish requests.
@@ -34,8 +36,8 @@ impl Deletions {
.map(|c| (c, event.pubkey, event.created_at)), .map(|c| (c, event.pubkey, event.created_at)),
); );
} else if event.kind == Kind::RequestToVanish { } else if event.kind == Kind::RequestToVanish {
// Client-side we can't verify which relay the request targeted, // Client-side we can't verify which relay the request targeted.
// so any vanish request is honored for the author's events. // Any vanish request is then honored for the author's events.
vanished.push((event.pubkey, event.created_at)); 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. /// 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 /// Addressable events are deleted up to the request's `created_at`.
/// author (NIP-09); addressable events are deleted up to the request's
/// `created_at`.
pub fn is_deleted(&self, event: &Event) -> bool { pub fn is_deleted(&self, event: &Event) -> bool {
if self if self
.vanished .vanished
+33 -41
View File
@@ -25,7 +25,7 @@ pub fn announcement(addr: &RepoAddr) -> Filter {
.identifier(addr.identifier.clone()) .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 { pub fn state(addr: &RepoAddr) -> Filter {
Filter::new() Filter::new()
.kind(Kind::RepoState) .kind(Kind::RepoState)
@@ -33,18 +33,18 @@ pub fn state(addr: &RepoAddr) -> Filter {
.identifier(addr.identifier.clone()) .identifier(addr.identifier.clone())
} }
/// All NIP-34 activity addressed to a repository (`#a` tag): issues, PRs, /// All NIP-34 activity addressed to a repository via its `#a` tag.
/// patches, statuses and comments (kind 1111). /// Covers issues, PRs, patches, statuses and kind-1111 comments.
/// /// The `a` tag is optional on status events per NIP-34.
/// Note: the `a` tag on status events is optional per NIP-34, so statuses /// Statuses published without it are not matched here.
/// published without it won't be matched here.
pub fn activity(addr: &RepoAddr) -> Filter { pub fn activity(addr: &RepoAddr) -> Filter {
Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr) Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr)
} }
/// Status events (`1630..=1633`) referencing any of the given root events /// Status events, kinds `1630..=1633`, referencing any of the given root events.
/// (`#e` tag). Batched: one filter covers all roots, so a negentropy sync /// They are matched via the `#e` tag. One filter covers all roots.
/// reconciles them in a single session instead of one per root. ///
/// A negentropy sync reconciles them in a single session, not one per root.
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter { pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
Filter::new() Filter::new()
.kinds([ .kinds([
@@ -56,33 +56,29 @@ pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
.events(roots) .events(roots)
} }
/// Cover notes (kind 1624) and NIP-32 label events (kind 1985) referencing /// Cover notes and NIP-32 label events referencing any of the given root events.
/// any of the given root events (`#e` tag), fetched per root like comments /// These are kinds 1624 and 1985, matched via the `#e` tag.
/// and statuses because they carry no repository `a` tag. Batched, like ///
/// [`statuses_for`]. /// 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 { pub fn annotations_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
Filter::new() Filter::new()
.kinds([crate::COVER_NOTE_KIND, Kind::Label]) .kinds([crate::COVER_NOTE_KIND, Kind::Label])
.events(roots) .events(roots)
} }
/// A user's grasp list (kind `10317`). /// A user's grasp list, kind `10317`.
pub fn grasp_list(public_key: PublicKey) -> Filter { pub fn grasp_list(public_key: PublicKey) -> Filter {
Filter::new() Filter::new()
.kind(Kind::GitUserGraspList) .kind(Kind::GitUserGraspList)
.author(public_key) .author(public_key)
} }
/// NIP-22 comments (kind `1111`) referencing any of the given root events /// NIP-22 comments, kind `1111`, referencing any of the given root events.
/// (issues, patches, PRs). /// The roots are issues, patches and PRs.
/// ///
/// Comments carry no repository `a` tag, so they must be fetched by their /// Returns two filters, since combining `#E` and `#e` would AND the conditions.
/// 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.
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> { pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect(); let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
if roots.is_empty() { if roots.is_empty() {
@@ -105,42 +101,38 @@ pub fn announcements_by(public_key: PublicKey) -> Filter {
.author(public_key) .author(public_key)
} }
/// All repository announcements (for global discovery). /// 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.
pub fn all_announcements() -> Filter { pub fn all_announcements() -> Filter {
Filter::new().kind(Kind::GitRepoAnnouncement) Filter::new().kind(Kind::GitRepoAnnouncement)
} }
/// How far back deletion requests are fetched and stored. /// 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); const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
/// `now` minus [`DELETIONS_LOOKBACK`], quantized to whole days so identical /// `now` minus [`DELETIONS_LOOKBACK`].
/// filters hash the same and the backend's sync dedup can match them. /// Quantized to whole days so identical filters hash the same.
///
/// This lets the backend's sync dedup match identical filters.
fn deletions_since() -> Timestamp { fn deletions_since() -> Timestamp {
let now = Timestamp::now().as_secs(); let now = Timestamp::now().as_secs();
Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK
} }
/// All deletion-related events (NIP-09 kind `5`, NIP-62 kind `62`) within /// All deletion-related events within [`DELETIONS_LOOKBACK`].
/// [`DELETIONS_LOOKBACK`]. Deletion requests must be known before any other /// These are NIP-09 kind `5` and NIP-62 kind `62`.
/// event can be shown. ///
/// Deletion requests must be known before any other event is shown.
pub fn deletions() -> Filter { pub fn deletions() -> Filter {
Filter::new() Filter::new()
.kinds([Kind::EventDeletion, Kind::RequestToVanish]) .kinds([Kind::EventDeletion, Kind::RequestToVanish])
.since(deletions_since()) .since(deletions_since())
} }
/// Deletion events relevant to a single repository: requests authored by /// Deletion events relevant to a single repository.
/// the repository owner and requests addressed to the repository ///
/// coordinate (`#a` tag). /// 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> { pub fn deletions_for_repo(addr: &RepoAddr) -> Vec<Filter> {
vec![ vec![
Filter::new() Filter::new()
+135 -62
View File
@@ -5,16 +5,16 @@ use nostr::prelude::*;
use crate::RepoAddr; 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Announcement { pub struct Announcement {
/// ID of the announcement event itself. /// ID of the announcement event itself.
pub event_id: EventId, pub event_id: EventId,
/// Repository ID (`d` tag). /// Repository ID, the `d` tag.
pub id: String, pub id: String,
/// Author of the announcement event. /// Author of the announcement event.
pub owner: PublicKey, 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 created_at: Timestamp,
pub name: Option<SharedString>, pub name: Option<SharedString>,
pub description: Option<SharedString>, pub description: Option<SharedString>,
@@ -24,36 +24,30 @@ pub struct Announcement {
pub clone: Vec<Url>, pub clone: Vec<Url>,
/// Relays the repository monitors for patches and issues. /// Relays the repository monitors for patches and issues.
pub relays: Vec<RelayUrl>, 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>, pub euc: Option<String>,
/// Other recognized maintainers. /// Other recognized maintainers.
pub maintainers: Vec<PublicKey>, pub maintainers: Vec<PublicKey>,
/// Value of a `u` tag, if any: this repository is a subordinate fork of /// Marks the repository as a subordinate fork of the upstream, per NIP-34.
/// the referenced upstream (NIP-34).
pub upstream: Option<Upstream>, pub upstream: Option<Upstream>,
/// Hashtags labelling the repository (`t` tags). /// Hashtags labelling the repository, the `t` tags.
pub hashtags: Vec<String>, pub hashtags: Vec<String>,
} }
/// The `u` tag of a fork announcement (NIP-34) /// The `u` tag of a fork announcement, per 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.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Upstream { 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, pub raw: String,
/// The upstream `30617:<pubkey>:<id>` coordinate, when the `u` tag /// Upstream repository coordinate when the `u` tag names a NIP-34 repository.
/// references a NIP-34 repository; `None` for the git-URL form. /// `None` for the git-URL form.
pub addr: Option<RepoAddr>, pub addr: Option<RepoAddr>,
/// Relay hint for the upstream, if the `u` tag carries one. /// Relay hint for the upstream, if the `u` tag carries one.
pub relay_hint: Option<RelayUrl>, pub relay_hint: Option<RelayUrl>,
} }
impl Upstream { impl Upstream {
/// Parse the `u` tag values. The first is the upstream coordinate or a /// Parse the `u` tag values.
/// git URL (the coordinate form may append `|git-url`; the coordinate is
/// the part before the first `|`), the second an optional relay hint.
fn parse(raw: &str, relay_hint: Option<&str>) -> Self { fn parse(raw: &str, relay_hint: Option<&str>) -> Self {
let coordinate = raw.split('|').next().unwrap_or(raw); let coordinate = raw.split('|').next().unwrap_or(raw);
let addr = coordinate let addr = coordinate
@@ -67,8 +61,7 @@ impl Upstream {
} }
} }
/// Text for display: the upstream coordinate when it is a NIP-34 /// Text for display.
/// repository, otherwise the raw `u` value (git-URL form).
pub fn display(&self) -> SharedString { pub fn display(&self) -> SharedString {
match &self.addr { match &self.addr {
Some(addr) => SharedString::from(addr.to_string()), 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, /// Subject of a NIP-34 issue or pull request event.
/// falling back to the first non-empty line of the content. /// Taken from the `subject` tag, else the first non-empty line of the content.
pub fn activity_subject(event: &Event) -> SharedString { pub fn activity_subject(event: &Event) -> SharedString {
let subject = event let subject = event
.tags .tags
@@ -101,12 +94,7 @@ pub fn activity_subject(event: &Event) -> SharedString {
.unwrap_or(SharedString::from("Untitled")) .unwrap_or(SharedString::from("Untitled"))
} }
/// The patch set of a pull request: the root patch event (kind `1617`) the /// The patch set of a pull request.
/// 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.
/// ///
/// Returns an empty list when no patch event can be linked to the PR. /// Returns an empty list when no patch event can be linked to the PR.
pub fn pull_request_patches<'a>( pub fn pull_request_patches<'a>(
@@ -115,17 +103,18 @@ pub fn pull_request_patches<'a>(
) -> Vec<&'a Event> { ) -> Vec<&'a Event> {
let patches: Vec<&'a Event> = patches.into_iter().collect(); let patches: Vec<&'a Event> = patches.into_iter().collect();
// The PR references its root patch via an `e` tag; follow the NIP-10 // The PR references its root patch via an `e` tag.
// reply chain forward from there (each patch of the set replies to the // Follow the NIP-10 reply chain forward from there.
// previous one). Among several replies (a revision), the newest wins. // Each patch replies to the previous one, and among several replies the newest wins.
if let Some(root_id) = pr.tags.event_ids().next() if let Some(root_id) = pr.tags.event_ids().next()
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id) && let Some(root) = patches.iter().find(|patch| patch.id == root_id)
{ {
return forward_series(root, &patches); return forward_series(root, &patches);
} }
// No `e` tag: the last patch of the set carries the PR's tip commit in // The PR has no `e` tag.
// its `commit`/`r` tag; walk the reply chain backward to the root. // 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 { let Some(tip) = current_commit_of(pr) else {
return Vec::new(); return Vec::new();
}; };
@@ -156,10 +145,7 @@ pub fn pull_request_patches<'a>(
series series
} }
/// The patch content of a pull request: the contents of every patch event of /// The patch content of a pull request.
/// 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.
pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a Event>) -> String { 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 patches: Vec<&'a Event> = patches.into_iter().collect();
let series = pull_request_patches(pr, patches.iter().copied()); 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") .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> { fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
let mut series = vec![root]; let mut series = vec![root];
loop { loop {
@@ -195,7 +181,7 @@ fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event>
series 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> { fn current_commit_of(event: &Event) -> Option<String> {
event event
.tags .tags
@@ -206,8 +192,9 @@ fn current_commit_of(event: &Event) -> Option<String> {
}) })
} }
/// Whether `patch` produces `commit` (its `commit` or `r` tag), so clients /// Whether `patch` produces `commit`, found via its `commit` or `r` tag.
/// can find existing patches for a specific commit. ///
/// It lets clients find existing patches for a specific commit.
fn patch_produces_commit(patch: &Event, commit: &str) -> bool { fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
patch patch
.tags .tags
@@ -219,7 +206,9 @@ fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
} }
impl Announcement { 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> { pub fn from_event(event: &Event) -> Option<Self> {
if event.kind != Kind::GitRepoAnnouncement { if event.kind != Kind::GitRepoAnnouncement {
return None; return None;
@@ -251,8 +240,8 @@ impl Announcement {
_ => {} _ => {}
} }
// The `u` tag is not modelled by the SDK's `Nip34Tag`; parse it // The SDK's `Nip34Tag` does not model the `u` tag, so parse it manually.
// manually (first wins). // Only the first `u` tag is used.
if upstream.is_none() && tag.kind() == "u" { if upstream.is_none() && tag.kind() == "u" {
let values = tag.as_slice(); let values = tag.as_slice();
let raw = values.get(1).map(String::as_str).unwrap_or_default(); 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()) 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. /// The description of the repository, or a default if none is provided.
pub fn description(&self) -> SharedString { pub fn description(&self) -> SharedString {
self.description self.description
@@ -291,10 +294,10 @@ impl Announcement {
.unwrap_or(SharedString::from("No description")) .unwrap_or(SharedString::from("No description"))
} }
/// The effective maintainers of this repository: the announced /// The effective maintainers of this repository,
/// `maintainers` plus the announcement author, who asserts themselves as /// the announced `maintainers` plus the announcement author.
/// a maintainer of the primary project unless a `u` tag marks this ///
/// repository as a subordinate fork (NIP-34). /// A `u` tag that marks the repository as a subordinate fork excludes them, per NIP-34.
pub fn effective_maintainers(&self) -> Vec<PublicKey> { pub fn effective_maintainers(&self) -> Vec<PublicKey> {
let mut maintainers = self.maintainers.clone(); let mut maintainers = self.maintainers.clone();
if self.upstream.is_none() && !maintainers.contains(&self.owner) { if self.upstream.is_none() && !maintainers.contains(&self.owner) {
@@ -303,8 +306,7 @@ impl Announcement {
maintainers maintainers
} }
/// The `git clone` URLs for this repository, deduplicated while /// The `git clone` URLs for this repository, deduplicated.
/// preserving the announced order (deterministic across calls).
pub fn clone_urls(&self) -> Vec<SharedString> { pub fn clone_urls(&self) -> Vec<SharedString> {
let mut seen = HashSet::new(); let mut seen = HashSet::new();
self.clone self.clone
@@ -449,8 +451,8 @@ mod tests {
let announcement = Announcement::from_event(&event).expect("parses"); let announcement = Announcement::from_event(&event).expect("parses");
let upstream = announcement.upstream.expect("parses the u tag"); let upstream = announcement.upstream.expect("parses the u tag");
// The coordinate part resolves to a repository address; the raw // The coordinate part resolves to a repository address.
// value keeps the `|git-url` suffix. // The raw value keeps the `|git-url` suffix.
assert_eq!( assert_eq!(
upstream.addr, upstream.addr,
Some(crate::repo_addr( Some(crate::repo_addr(
@@ -474,8 +476,8 @@ mod tests {
#[test] #[test]
fn parses_git_url_upstream() { fn parses_git_url_upstream() {
// The `u` tag may reference a non-nostr upstream by git URL only; // The `u` tag may reference a non-nostr upstream by git URL only.
// there is no repository address to navigate to. // There is no repository address to navigate to.
let event = announcement_event(&[ let event = announcement_event(&[
&["d", "my-fork"], &["d", "my-fork"],
&["u", "https://example.com/upstream.git"], &["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] #[test]
fn effective_maintainers_include_owner_for_primary_repos() { fn effective_maintainers_include_owner_for_primary_repos() {
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]); 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 announcement = Announcement::from_event(&event).expect("parses");
let maintainers = announcement.effective_maintainers(); let maintainers = announcement.effective_maintainers();
// The owner asserts themselves as a maintainer of the primary // The owner asserts themselves as a maintainer of the primary project, per NIP-34.
// project (NIP-34), alongside the announced co-maintainers. // Announced co-maintainers are included too.
assert_eq!(maintainers.len(), 2); assert_eq!(maintainers.len(), 2);
assert!(maintainers.contains(&announcement.owner)); assert!(maintainers.contains(&announcement.owner));
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"))); 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 announcement = Announcement::from_event(&event).expect("parses");
let maintainers = announcement.effective_maintainers(); let maintainers = announcement.effective_maintainers();
// A `u` tag marks the repository as a subordinate fork: the author // A `u` tag marks the repository as a subordinate fork.
// is not a maintainer of the primary project (NIP-34). // The author is then not a maintainer of the primary project, per NIP-34.
assert!(!maintainers.contains(&announcement.owner)); assert!(!maintainers.contains(&announcement.owner));
assert_eq!( assert_eq!(
maintainers, maintainers,
@@ -545,7 +618,7 @@ mod tests {
#[test] #[test]
fn pull_request_patch_falls_back_to_inline_content() { 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![]); let pr = pr_event("patch-inline", vec![]);
assert_eq!(pull_request_patch(&pr, [] as [&Event; 0]), "patch-inline"); assert_eq!(pull_request_patch(&pr, [] as [&Event; 0]), "patch-inline");
@@ -572,8 +645,8 @@ mod tests {
#[test] #[test]
fn pull_request_patch_joins_the_whole_patch_set() { fn pull_request_patch_joins_the_whole_patch_set() {
// NIP-34: a PR references the root patch; later patches of the set // A PR references the root patch, per NIP-34.
// reply to the previous one (NIP-10 `e` tags). // Later patches of the set reply to the previous one via NIP-10 `e` tags.
let root = patch_event("patch-one", vec![], 100); let root = patch_event("patch-one", vec![], 100);
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200); let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
let pr = pr_event("description", vec![Tag::event(root.id)]); let pr = pr_event("description", vec![Tag::event(root.id)]);
@@ -625,8 +698,8 @@ mod tests {
#[test] #[test]
fn pull_request_patches_finds_the_set_via_the_tip_commit() { 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 // PRs without an `e` tag fall back to the patch producing the tip commit.
// commit in its `r` tag; walk the reply chain backward to the root. // Walk the reply chain backward to the root.
let root = patch_event("patch-one", vec![], 100); let root = patch_event("patch-one", vec![], 100);
let tip = "1111111111111111111111111111111111111111"; let tip = "1111111111111111111111111111111111111111";
let last = patch_event( let last = patch_event(
+5 -6
View File
@@ -1,10 +1,9 @@
use nostr::prelude::*; 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` /// The `d` tag matches the repository id.
/// is the short branch name HEAD points to, published as
/// `ref: refs/heads/<branch>`. The `d` tag matches the repository id.
pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder { pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())]; let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
for (name, commit) in refs { 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. /// Parse a kind `30618` repository state event into refs and HEAD.
/// ///
/// `refs` are `(refname, commit-id)` pairs; `head` is the branch pointed to /// `refs` are `(refname, commit-id)` pairs.
/// by the `HEAD` tag, if any. /// `head` is the branch pointed to by the `HEAD` tag, if any.
pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option<String>) { pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option<String>) {
let mut refs = Vec::new(); let mut refs = Vec::new();
let mut head = None; let mut head = None;
+6 -6
View File
@@ -1,6 +1,6 @@
use nostr::prelude::*; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RepoStatus { pub enum RepoStatus {
Open, Open,
@@ -30,9 +30,9 @@ impl RepoStatus {
} }
} }
/// Check whether an event references the given root event via an `e` or `E` /// NIP-10 and NIP-34 use the lowercase `e` tag.
/// 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-22 comments, kind `1111`, use the uppercase `E` tag for the thread root.
pub fn references_root(event: &Event, root: &EventId) -> bool { pub fn references_root(event: &Event, root: &EventId) -> bool {
let root = root.to_hex(); let root = root.to_hex();
event 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())) .any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
} }
/// Resolve the status of a root event per NIP-34: /// Resolve the status of a root event per NIP-34.
/// the most recent status event from the root author or a maintainer wins. ///
/// Defaults to [`RepoStatus::Open`]. /// Defaults to [`RepoStatus::Open`].
pub fn resolve_status<'a, I>( pub fn resolve_status<'a, I>(
status_events: 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; 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"))] #[cfg(not(target_arch = "wasm32"))]
pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, UniversalSigner)> { pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, UniversalSigner)> {
let signer = UniversalSigner::new(Keys::generate()); 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)) 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")] #[cfg(target_arch = "wasm32")]
pub fn new_backend() -> Result<(Client, UniversalSigner)> { pub fn new_backend() -> Result<(Client, UniversalSigner)> {
let signer = UniversalSigner::new(Keys::generate()); 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 /// A type-erased signer whose inner signer can be swapped in-place.
/// (e.g. after login/logout). All clones see the swap.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct UniversalSigner { pub struct UniversalSigner {
inner: Arc<RwLock<Arc<dyn InnerSigner>>>, inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
+2 -4
View File
@@ -1,12 +1,10 @@
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
/// A lightweight "something changed" signal for the UI. /// A lightweight change notification for the UI.
///
/// Heavy data stays in the database; consumers re-query on receipt.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Update { pub struct Update {
pub kind: Kind, 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 coordinate: Option<Coordinate>,
pub author: PublicKey, pub author: PublicKey,
pub event_id: EventId, pub event_id: EventId,
+4
View File
@@ -8,6 +8,7 @@ publish.workspace = true
signed_core = { path = "../signed_core" } signed_core = { path = "../signed_core" }
signed_git = { path = "../signed_git" } signed_git = { path = "../signed_git" }
signed_nostr = { path = "../signed_nostr" } signed_nostr = { path = "../signed_nostr" }
settings = { path = "../settings" }
utils = { path = "../utils" } utils = { path = "../utils" }
nostr.workspace = true nostr.workspace = true
@@ -24,3 +25,6 @@ log.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rustls = "0.23" 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::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::str::FromStr; use std::str::FromStr;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; 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 bitcoin_hashes::sha1::Hash as Sha1Hash;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task}; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
use nostr::event::IntoEventBuilder; use nostr::event::IntoEventBuilder;
@@ -17,8 +18,7 @@ use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
use crate::git_store::GitStore; use crate::git_store::GitStore;
/// Keyring entry holding the user credential (`nsec1...` or `bunker://...` /// Keyring entry for the user credential.
/// with an embedded `?master=<nsec>` NIP-46 session key).
pub const USER_KEYRING: &str = "Signed Safe Storage"; pub const USER_KEYRING: &str = "Signed Safe Storage";
/// Timeout for NIP-46 signer responses. /// Timeout for NIP-46 signer responses.
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60; pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
@@ -31,34 +31,27 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
"wss://profiles.nostr1.com", "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"]; 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. /// How long an identical fetch or 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.
const FETCH_DEDUP_WINDOW: Duration = Duration::from_secs(5 * 60); const FETCH_DEDUP_WINDOW: Duration = Duration::from_secs(5 * 60);
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum BackendEvent { pub enum BackendEvent {
/// User has no signer configured. /// User has no signer configured.
SignerRequired, SignerRequired,
/// The stored identity is NIP-49 encrypted (`ncryptsec1...`); a /// The stored identity is NIP-49 encrypted key.
/// passphrase is required to decrypt it before the session can resume.
PassphraseRequired, PassphraseRequired,
/// The signer has changed (login/logout/account switch). /// The signer changed on login, logout or account switch.
SignerChanged, SignerChanged,
/// Relay bootstrap finished. /// Relay bootstrap finished.
Connected, Connected,
/// A new event was received from a relay and stored in the database. /// A new event was received from a relay and stored in the database.
NostrUpdate(Update), NostrUpdate(Update),
/// A negentropy sync completed; the database was updated directly, /// A negentropy sync completed.
/// so stores should re-query (no [`BackendEvent::NostrUpdate`] is fired
/// for synced events).
Synced, Synced,
/// A negentropy sync is in flight. Stores may re-query to render /// A negentropy sync is in flight.
/// incrementally; UI can show `current`/`total` progress.
SyncProgress { SyncProgress {
/// Total events to process. /// Total events to process.
total: u64, total: u64,
@@ -80,22 +73,21 @@ impl BackendEvent {
} }
} }
/// Global backend entity: owns the nostr client, the signer and the /// The global backend entity.
/// notification pump. Stores subscribe to [`BackendEvent`] and re-query the ///
/// local database when relevant updates arrive. /// Owns the nostr client, the signer and the notification pump.
pub struct Backend { pub struct Backend {
client: Client, client: Client,
signer: UniversalSigner, signer: UniversalSigner,
current_user: Option<PublicKey>, current_user: Option<PublicKey>,
connected: bool, connected: bool,
sync_progress: Option<(u64, u64)>, sync_progress: Option<(u64, u64)>,
/// Whether the stored credential is NIP-49 encrypted and a passphrase /// True when the stored credential is NIP-49 encrypted.
/// is still needed to resume the session.
passphrase_required: bool, passphrase_required: bool,
/// Fingerprints of recently started fetches/syncs (relay + filter set), /// Fingerprints of recently started fetches and syncs, a relay plus filter set.
/// so duplicate requests within [`FETCH_DEDUP_WINDOW`] collapse into
/// one. Entries are pruned lazily on the next request.
recent_fetches: HashMap<u64, Instant>, 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>>>, tasks: Vec<Task<Result<(), Error>>>,
} }
@@ -103,6 +95,22 @@ struct GlobalBackend(Entity<Backend>);
impl Global for GlobalBackend {} 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 EventEmitter<BackendEvent> for Backend {}
impl Backend { impl Backend {
@@ -147,6 +155,7 @@ impl Backend {
sync_progress: None, sync_progress: None,
passphrase_required: false, passphrase_required: false,
recent_fetches: HashMap::new(), recent_fetches: HashMap::new(),
pushing_repos: Arc::new(Mutex::new(HashSet::new())),
tasks: vec![pump], tasks: vec![pump],
}; };
@@ -154,8 +163,9 @@ impl Backend {
this this
} }
/// Bootstrap the client: connect to the default relays (indexers as /// Bootstrap the client.
/// discovery-only) and restore the saved session, if any. ///
/// Restore the saved session, if any.
fn bootstrap(&mut self, cx: &mut Context<Self>) { fn bootstrap(&mut self, cx: &mut Context<Self>) {
let client = self.client.clone(); let client = self.client.clone();
@@ -192,10 +202,11 @@ impl Backend {
self.restore_session(cx); self.restore_session(cx);
} }
/// Restore the saved session from the keyring. Emits /// Restore the saved session from the keyring.
/// [`BackendEvent::SignerRequired`] if no credential is stored, or ///
/// [`BackendEvent::PassphraseRequired`] if the stored identity is /// Emits [`BackendEvent::SignerRequired`] when no credential is stored.
/// NIP-49 encrypted. ///
/// Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity.
pub fn restore_session(&mut self, cx: &mut Context<Self>) { pub fn restore_session(&mut self, cx: &mut Context<Self>) {
if cfg!(target_arch = "wasm32") { if cfg!(target_arch = "wasm32") {
cx.emit(BackendEvent::SignerRequired); cx.emit(BackendEvent::SignerRequired);
@@ -229,8 +240,8 @@ impl Backend {
signer.auth_url_handler(SignedAuthUrlHandler); signer.auth_url_handler(SignedAuthUrlHandler);
this.update(cx, |this, cx| this.set_signer(signer, cx))?; this.update(cx, |this, cx| this.set_signer(signer, cx))?;
} else if content.starts_with("ncryptsec1") { } else if content.starts_with("ncryptsec1") {
// Encrypted identity: a passphrase is required to // Encrypted identity.
// decrypt it before the session can resume. // A passphrase is required to decrypt it before the session can resume.
log::warn!("stored identity is ncryptsec-encrypted; waiting for passphrase"); log::warn!("stored identity is ncryptsec-encrypted; waiting for passphrase");
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.passphrase_required = true; this.passphrase_required = true;
@@ -255,11 +266,7 @@ impl Backend {
})); }));
} }
/// Decrypt the NIP-49 encrypted credential stored in the keyring with /// Decrypt the NIP-49 keyring credential with the given passphrase.
/// 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).
pub fn restore_with_passphrase( pub fn restore_with_passphrase(
&mut self, &mut self,
password: &str, password: &str,
@@ -294,12 +301,7 @@ impl Backend {
}) })
} }
/// Create a new identity: generate keys, encrypt the secret key with the /// Create a new identity.
/// 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.
pub fn create_identity( pub fn create_identity(
&mut self, &mut self,
name: &str, name: &str,
@@ -335,8 +337,7 @@ impl Backend {
write.await?; write.await?;
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
// Become the new identity, so the publishes below are // Become the new identity so later publishes are signed with the new keys.
// signed with the new keys.
this.signer.swap_inner(keys); this.signer.swap_inner(keys);
this.current_user = Some(public_key); this.current_user = Some(public_key);
this.bootstrap_user(public_key, cx); this.bootstrap_user(public_key, cx);
@@ -387,47 +388,45 @@ impl Backend {
}) })
} }
/// Create a new repository: initialize a local clone with a `main` /// Initialize a local clone with a `main` branch and a `README.md`.
/// branch and a `README.md`, publish the NIP-34 announcement and the
/// repository state to the grasp relays, then push the initial commit
/// to each grasp server.
/// ///
/// The events must reach the grasp servers *before* the push: GRASP /// The task yields the announcement and the path of the working copy.
/// 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.
pub fn create_repository( pub fn create_repository(
&mut self, &mut self,
name: &str, name: &str,
description: &str, description: &str,
folder: PathBuf,
grasp_servers: Vec<RelayUrl>, grasp_servers: Vec<RelayUrl>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Task<Result<Announcement, Error>> { ) -> Task<Result<(Announcement, PathBuf), Error>> {
let name = name.trim().to_owned(); let name = name.trim().to_owned();
let description = description.trim().to_owned(); let description = description.trim().to_owned();
if name.is_empty() { if name.is_empty() {
return Task::ready(Err(anyhow!("Repository name is required"))); return Task::ready(Err(anyhow!("Repository name is required")));
} }
if grasp_servers.is_empty() { if grasp_servers.is_empty() {
return Task::ready(Err(anyhow!("Add at least one grasp server"))); 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 { let Some(public_key) = self.current_user else {
return Task::ready(Err(anyhow!("Sign in to create a repository"))); return Task::ready(Err(anyhow!("Sign in to create a repository")));
}; };
// The repository identifier is derived from the name, like ngit and // The repository identifier is derived from the name.
// gitworkshop: spaces become hyphens, other non-alphanumeric
// characters (except `/`) become hyphens, case is preserved.
let repo_id = identifier_from_name(&name); let repo_id = identifier_from_name(&name);
if repo_id.is_empty() || repo_id.len() > 100 { if repo_id.is_empty() || repo_id.len() > 100 {
return Task::ready(Err(anyhow!( return Task::ready(Err(anyhow!(
"Repository name must produce an identifier of 1-100 characters" "Repository name must produce an identifier of 1-100 characters"
))); )));
} }
if !repo_id.chars().any(|c| c.is_ascii_alphanumeric()) { if !repo_id.chars().any(|c| c.is_ascii_alphanumeric()) {
return Task::ready(Err(anyhow!( return Task::ready(Err(anyhow!(
"Repository name must contain at least one alphanumeric character" "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 addr = repo_addr(public_key, repo_id.clone());
let cache = GitStore::global(cx).cache().clone(); let cache = GitStore::global(cx).cache().clone();
let path = cache.repo_path(&addr); let path = cache.repo_path(&addr);
let owner = public_key let owner = public_key.to_bech32().unwrap();
.to_bech32()
.unwrap_or_else(|_| public_key.to_hex());
let servers = grasp_servers.clone(); let servers = grasp_servers.clone();
cx.spawn(async move |this, cx| { 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 work = cx.background_spawn({
let path = path.clone(); let path = path.clone();
let folder = folder.clone();
let name = name.clone(); let name = name.clone();
let description = description.clone(); let description = description.clone();
let owner = owner.clone(); let owner = owner.clone();
@@ -459,20 +457,48 @@ impl Backend {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
let commit = signed_git::init_repository(&path, &name, &description)?; let commit = signed_git::init_repository(&path, &name, &description)?;
// Point `origin` at the first grasp server so later // Point `origin` at the first grasp server.
// fetches (and pushes) have a target, like ngit. // Later fetches and pushes have a target, like ngit.
if let Some(base) = servers.first().and_then(grasp_base_url) { if let Some(base) = servers.first().and_then(grasp_base_url) {
let url = format!("{base}/{owner}/{repo_id}.git"); let url = format!("{base}/{owner}/{repo_id}.git");
signed_git::ensure_origin(&path, &url).ok(); 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, checkout_path) = work.await?;
let commit_sha = let commit_sha = Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid id"))?;
Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid initial commit id"))?;
// The nostr client queues events until each relay is connected. // The nostr client queues events until each relay is connected.
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
@@ -480,8 +506,7 @@ impl Backend {
this.add_relays(urls, cx); this.add_relays(urls, cx);
})?; })?;
// The state event is the push authorization ("purgatory"), so // The state event is the push authorization. It must be accepted before the push below.
// it must be accepted before the push below.
let announcement = GitRepositoryAnnouncement { let announcement = GitRepositoryAnnouncement {
id: repo_id.clone(), id: repo_id.clone(),
name: Some(name.clone()), name: Some(name.clone()),
@@ -527,8 +552,7 @@ impl Backend {
} }
}; };
// Push to every grasp server; creation only fails when no // Push to every grasp server. Creation fails only when no server accepted it.
// server accepted it.
let push = cx.background_spawn({ let push = cx.background_spawn({
let path = path.clone(); let path = path.clone();
let owner = owner.clone(); let owner = owner.clone();
@@ -536,9 +560,9 @@ impl Backend {
let servers = servers.clone(); let servers = servers.clone();
push_to_grasp_servers(path, owner, repo_id, servers, signed_git::push_main) push_to_grasp_servers(path, owner, repo_id, servers, signed_git::push_main)
}); });
if let Err(e) = push.await { if let Err(e) = push.await {
// The events are already published; retract them so the // The events are already published. Retract them so the repository is not left announced without content.
// repository doesn't remain announced without content.
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.retract_events(&[event.clone(), state_event.clone()], 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 /// Publish an existing local repository to NIP-34.
/// 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.
pub fn publish_local_repo( pub fn publish_local_repo(
&mut self, &mut self,
path: PathBuf, path: PathBuf,
@@ -584,8 +605,7 @@ impl Backend {
return Task::ready(Err(anyhow!("Sign in to publish a repository"))); return Task::ready(Err(anyhow!("Sign in to publish a repository")));
}; };
// The repository identifier is derived from the name as in // The identifier derives from the name, as in [`Self::create_repository`].
// [`Self::create_repository`].
let repo_id = identifier_from_name(&name); let repo_id = identifier_from_name(&name);
if repo_id.is_empty() || repo_id.len() > 100 { if repo_id.is_empty() || repo_id.len() > 100 {
@@ -620,8 +640,7 @@ impl Backend {
this.add_relays(urls, cx); this.add_relays(urls, cx);
})?; })?;
// The state event is the push authorization ("purgatory"), so // The state event is the push authorization. It must be accepted before the push below.
// it must be accepted before the push below.
let announcement = GitRepositoryAnnouncement { let announcement = GitRepositoryAnnouncement {
id: repo_id.clone(), id: repo_id.clone(),
name: Some(name.clone()), name: Some(name.clone()),
@@ -665,9 +684,9 @@ impl Backend {
} }
}; };
// Push every branch and tag to each grasp server; the init // Push every branch and tag to each grasp server. The push fails only when no server accepted it.
// only fails when no server accepted it. An empty repository //
// has nothing to push. // An empty repository has nothing to push.
if !refs.is_empty() { if !refs.is_empty() {
let push = cx.background_spawn({ let push = cx.background_spawn({
let path = path.clone(); let path = path.clone();
@@ -689,8 +708,7 @@ impl Backend {
} }
} }
// Point `origin` at the first grasp server so later pushes // Point `origin` at the first grasp server so later pushes have a target.
// have a target.
if let Some(base) = servers.first().and_then(grasp_base_url) { if let Some(base) = servers.first().and_then(grasp_base_url) {
let url = format!("{base}/{owner}/{repo_id}.git"); let url = format!("{base}/{owner}/{repo_id}.git");
let path = path.clone(); let path = path.clone();
@@ -704,35 +722,92 @@ impl Backend {
}) })
} }
/// Re-push the repository's current refs to the grasp servers announced /// Re-push the repository's current refs to the grasp servers in its `relays` tag.
/// 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.
pub fn push_repository( pub fn push_repository(
&mut self, &mut self,
announcement: Announcement, announcement: Announcement,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Task<Result<(), Error>> { ) -> Task<Result<(), Error>> {
let addr = announcement.addr();
let cache = GitStore::global(cx).cache().clone(); let cache = GitStore::global(cx).cache().clone();
let path = cache.repo_path(&addr); let path = cache.repo_path(&announcement.addr());
let owner = announcement self.push_repo_from(announcement, path, None, cx)
.owner }
.to_bech32()
.unwrap_or_else(|_| announcement.owner.to_hex()); /// 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 repo_id = announcement.id.clone();
let relays = announcement.relays.clone(); let relays = announcement.relays.clone();
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
let work = cx.background_spawn({ // Held for the whole task. Dropped on completion, on error and on cancellation alike.
let path = path.clone(); let _guard = guard;
async move { signed_git::worktree_ref_state(&path) }
}); let mut state = {
let state = work.await?; 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. // Grasp servers authorize a push by the state they have seen.
let refs = state.refs.clone(); let refs = state.refs.clone();
let head = state.head.clone(); let head = state.head.clone();
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
let builder = build_state(&repo_id, &refs, head.as_deref()); let builder = build_state(&repo_id, &refs, head.as_deref());
this.send(builder, cx) this.send(builder, cx)
@@ -757,10 +832,9 @@ impl Backend {
}) })
} }
/// Delete the repository from nostr: publish NIP-09 deletions for its /// Delete the repository from nostr.
/// announcement, state and activity events (issues, pull requests, ///
/// patches, statuses, comments). Only the repository owner may delete /// Only the repository owner may delete it.
/// it.
pub fn delete_repository( pub fn delete_repository(
&mut self, &mut self,
addr: RepoAddr, addr: RepoAddr,
@@ -801,8 +875,7 @@ impl Backend {
}) })
} }
/// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on /// Login with an `nsec1...` key or a `bunker://...` URI.
/// the credential's prefix.
pub fn login(&mut self, credential: &str, cx: &mut Context<Self>) { pub fn login(&mut self, credential: &str, cx: &mut Context<Self>) {
let credential = credential.trim(); let credential = credential.trim();
@@ -817,8 +890,7 @@ impl Backend {
} }
} }
/// Create a fresh identity and login with it. The generated key is /// Create a fresh identity and login with it.
/// persisted in the keyring like any other `nsec` credential.
pub fn login_with_new_identity(&mut self, cx: &mut Context<Self>) { pub fn login_with_new_identity(&mut self, cx: &mut Context<Self>) {
let nsec = Keys::generate() let nsec = Keys::generate()
.secret_key() .secret_key()
@@ -827,8 +899,7 @@ impl Backend {
self.login_with_nsec(&nsec, cx); self.login_with_nsec(&nsec, cx);
} }
/// Login with an `nsec1...` secret key. The credential is verified by /// Login with an `nsec1...` secret key.
/// the signer flow and persisted in the keyring.
pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context<Self>) { pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context<Self>) {
let keys = match SecretKey::parse(nsec) { let keys = match SecretKey::parse(nsec) {
Ok(secret) => Keys::new(secret), Ok(secret) => Keys::new(secret),
@@ -852,11 +923,7 @@ impl Backend {
})); }));
} }
/// Login with a `bunker://...` URI (NIP-46). A fresh session key is /// Login with a `bunker://...` URI, NIP-46.
/// 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.
pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context<Self>) { pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context<Self>) {
let uri_string = uri.trim().to_owned(); 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 /// Fetch the user's grasp list and add the listed grasp servers as relays.
/// servers as relays.
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) { fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let client = self.client.clone(); let client = self.client.clone();
@@ -973,8 +1039,7 @@ impl Backend {
self.current_user self.current_user
} }
/// Whether the stored credential is NIP-49 encrypted and a passphrase /// True when the stored credential is NIP-49 encrypted.
/// is still needed to resume the session.
pub fn passphrase_required(&self) -> bool { pub fn passphrase_required(&self) -> bool {
self.passphrase_required self.passphrase_required
} }
@@ -989,13 +1054,12 @@ impl Backend {
self.connected 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)> { pub fn sync_progress(&self) -> Option<(u64, u64)> {
self.sync_progress self.sync_progress
} }
/// Update the signer (any type implementing the async signer traits, /// Update the signer.
/// e.g. `Keys`, `NostrConnect`, a browser extension proxy).
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>) pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
where where
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static, T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
@@ -1056,8 +1120,7 @@ impl Backend {
})); }));
} }
/// Add relays used only for discovery (e.g. NIP-65 indexers) and /// Add discovery-only relays, e.g. NIP-65 indexers, and connect to them.
/// connect to them. No subscriptions or writes are routed through them.
pub fn add_discovery_relays(&mut self, urls: Vec<String>, cx: &mut Context<Self>) { pub fn add_discovery_relays(&mut self, urls: Vec<String>, cx: &mut Context<Self>) {
let client = self.client.clone(); let client = self.client.clone();
@@ -1080,8 +1143,7 @@ impl Backend {
})); }));
} }
/// Start a persistent subscription. Matching events are stored in the /// Start a persistent subscription.
/// database automatically and surface as [`BackendEvent::NostrUpdate`].
pub fn subscribe(&mut self, filter: Filter, cx: &mut Context<Self>) { pub fn subscribe(&mut self, filter: Filter, cx: &mut Context<Self>) {
let client = self.client.clone(); let client = self.client.clone();
@@ -1095,9 +1157,9 @@ impl Backend {
})); }));
} }
/// Whether an identical fetch was started within [`FETCH_DEDUP_WINDOW`] /// Whether an identical fetch started within [`FETCH_DEDUP_WINDOW`] is still recent.
/// and is still recent enough to suppress a duplicate. Records the ///
/// fingerprint (after pruning expired entries) when returning `false`. /// Records the fingerprint when returning `false`, pruning expired entries first.
fn fetch_recently_started(&mut self, fingerprint: u64) -> bool { fn fetch_recently_started(&mut self, fingerprint: u64) -> bool {
self.recent_fetches self.recent_fetches
.retain(|_, started| started.elapsed() < FETCH_DEDUP_WINDOW); .retain(|_, started| started.elapsed() < FETCH_DEDUP_WINDOW);
@@ -1108,17 +1170,7 @@ impl Backend {
false false
} }
/// Connect to relays announced by a repository (NIP-34 `relays` tag) and /// Connect to a repository's announced relays, its NIP-34 `relays` tag.
/// 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.
pub fn connect_repo_relays( pub fn connect_repo_relays(
&mut self, &mut self,
relays: Vec<RelayUrl>, relays: Vec<RelayUrl>,
@@ -1147,10 +1199,7 @@ impl Backend {
})); }));
} }
/// Start a one-shot subscription targeted only at the bootstrap relays, /// One-shot subscription on the bootstrap relays only.
/// 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.
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) { pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
let client = self.client.clone(); let client = self.client.clone();
@@ -1165,14 +1214,7 @@ impl Backend {
})); }));
} }
/// Negentropy-sync the given filter against the bootstrap relays: /// 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.
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) { pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter)); let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter));
if self.fetch_recently_started(fingerprint) { if self.fetch_recently_started(fingerprint) {
@@ -1247,12 +1289,7 @@ impl Backend {
})); }));
} }
/// Sign, broadcast and locally store an event. Emits /// Sign, broadcast and locally store an event.
/// [`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.
pub fn send( pub fn send(
&mut self, &mut self,
builder: EventBuilder, builder: EventBuilder,
@@ -1262,8 +1299,8 @@ impl Backend {
let signer = self.signer.clone(); let signer = self.signer.clone();
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
// Sign with the current signer, broadcast, and save locally so // Sign with the current signer, broadcast and save locally.
// the event is immediately visible to database queries. // The event is immediately visible to database queries.
let work = cx.background_spawn(async move { let work = cx.background_spawn(async move {
let event = builder.finalize_async(&signer).await?; let event = builder.finalize_async(&signer).await?;
let output = client.send_event(&event).await?; let output = client.send_event(&event).await?;
@@ -1302,10 +1339,7 @@ impl Backend {
}) })
} }
/// Broadcast and locally store an already-signed event, like /// Broadcast and locally store an already-signed event.
/// [`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.
pub fn publish_event( pub fn publish_event(
&mut self, &mut self,
event: Event, event: Event,
@@ -1351,9 +1385,7 @@ impl Backend {
}) })
} }
/// Publish a NIP-34 repository announcement (kind 30617) with the /// Publish a NIP-34 repository announcement, kind 30617, with the current signer.
/// current signer. The returned task yields the published event, so
/// callers can show inline progress/errors.
pub fn publish_announcement( pub fn publish_announcement(
&mut self, &mut self,
announcement: GitRepositoryAnnouncement, announcement: GitRepositoryAnnouncement,
@@ -1362,9 +1394,7 @@ impl Backend {
self.send(announcement.into_event_builder(), cx) self.send(announcement.into_event_builder(), cx)
} }
/// Sign, broadcast and store an event without awaiting the result; /// 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.
fn send_fire_and_forget(&mut self, builder: EventBuilder, cx: &mut Context<Self>) { fn send_fire_and_forget(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
let task = self.send(builder, cx); 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 NIP-09 deletions for `events`, best-effort.
/// 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.
fn retract_events(&mut self, events: &[Event], cx: &mut Context<Self>) { fn retract_events(&mut self, events: &[Event], cx: &mut Context<Self>) {
if events.is_empty() { if events.is_empty() {
return; return;
@@ -1406,8 +1433,9 @@ impl Backend {
} }
} }
/// Fingerprint of a relay + filter set, for fetch dedup. Relays and /// Fingerprint of a relay and filter set, for fetch dedup.
/// filters are sorted first so the fingerprint is order-independent. ///
/// Relays and filters are sorted first, so the fingerprint is order-independent.
fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 { fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 {
let mut relays: Vec<&str> = relays.to_vec(); let mut relays: Vec<&str> = relays.to_vec();
relays.sort_unstable(); relays.sort_unstable();
@@ -1420,11 +1448,7 @@ fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 {
hasher.finish() hasher.finish()
} }
/// Add the given relays, connect to them, and fetch the filters: a one-shot /// Add the given relays, connect and fetch the filters.
/// 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.
async fn connect_repo_relays_only( async fn connect_repo_relays_only(
client: &Client, client: &Client,
relays: Vec<RelayUrl>, relays: Vec<RelayUrl>,
@@ -1438,8 +1462,8 @@ async fn connect_repo_relays_only(
for url in &relays { for url in &relays {
added |= client.add_relay(url).await?; 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 { if added {
client.connect().await; client.connect().await;
} }
@@ -1454,9 +1478,7 @@ async fn connect_repo_relays_only(
.collect(); .collect();
client.subscribe(target).close_on(opts).await?; client.subscribe(target).close_on(opts).await?;
// Sync the filters concurrently: each reconciles against every relay // Sync the filters concurrently.
// either way, and a relay without NEG-XX support otherwise serializes
// its initial timeout behind every other filter.
let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5)); let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5));
let syncs = filters.into_iter().map(|filter| { let syncs = filters.into_iter().map(|filter| {
let client = &client; let client = &client;
@@ -1473,14 +1495,13 @@ async fn connect_repo_relays_only(
} }
} }
}); });
futures::future::join_all(syncs).await; futures::future::join_all(syncs).await;
Ok(()) Ok(())
} }
/// Subscribe only on the bootstrap relays, auto-closing after EOSE or a /// Subscribe only on the bootstrap relays.
/// short timeout. Use for one-shot data fetches (repo events, profiles)
/// instead of persistent gossip-routed subscriptions.
pub(crate) async fn subscribe_bootstrap_only( pub(crate) async fn subscribe_bootstrap_only(
client: &Client, client: &Client,
filters: Vec<Filter>, filters: Vec<Filter>,
@@ -1520,17 +1541,15 @@ fn with_master_key(uri: &str, keys: &Keys) -> String {
format!("{uri}{separator}master={nsec}") format!("{uri}{separator}master={nsec}")
} }
/// A `https://<host>` (or `http://<host>` for `ws://` grasp servers, like /// Base URL of a grasp server, `https://<host>`.
/// ngit) base URL for a grasp server. The repository then lives at ///
/// `{base}/{npub}/{repo-id}.git`. /// `ws://` grasp servers use `http://<host>`, like ngit.
pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> { pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
// `domain()` drops the port; parse the full URL to keep it (local dev // `domain()` drops the port.
// grasp servers commonly run on a custom port).
let parsed = Url::parse(relay.as_str()).ok()?; let parsed = Url::parse(relay.as_str()).ok()?;
let host = parsed.host_str()?; let host = parsed.host_str()?;
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default(); let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
// `ws://` grasp servers (e.g. local dev relays) speak plain HTTP; // `ws://` grasp servers, e.g. local dev relays, speak plain HTTP.
// everything else is HTTPS, matching ngit.
let scheme = if relay.scheme().is_secure() { let scheme = if relay.scheme().is_secure() {
"https" "https"
} else { } else {
@@ -1539,18 +1558,66 @@ pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
Some(format!("{scheme}://{host}{port}")) Some(format!("{scheme}://{host}{port}"))
} }
/// The GRASP clone URL of a repository on a grasp server, matching the /// GRASP clone URL of a repository on a grasp server.
/// format ngit announces: `https://<host>/<npub>/<repo-id>.git`.
fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url> { fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url> {
let base = grasp_base_url(relay)?; let base = grasp_base_url(relay)?;
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok() Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
} }
/// Push the repository at `path` to every grasp server: a server that /// GRASP-06 contributor namespace URL of a pull request tip.
/// rejects the push is logged, but the push only fails when no server pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String {
/// accepted it. `push` performs the single-server push (e.g. format!("{base_url}/prs/{npub}/{repo_id}.git")
/// [`signed_git::push_main`] for the create flow, [`signed_git::push_all`] }
/// for the init flow).
/// 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( async fn push_to_grasp_servers(
path: PathBuf, path: PathBuf,
owner: String, 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. /// 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) { fn extract_master_key(credential: &str) -> (&str, Keys) {
match credential.split_once("master=") { match credential.split_once("master=") {
Some((base, nsec)) => { Some((base, nsec)) => {
@@ -1628,4 +1694,90 @@ mod tests {
"https://gitnostr.com/npub1test/my-repo.git" "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 {} impl Global for GlobalGitStore {}
/// Global access to the on-disk git clone cache (grasp mirrors). /// Global access to the on-disk git clone cache, the grasp mirrors.
///
/// Installed at startup via [`GitStore::set_global`]; see also
/// [`signed_state::init`].
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct GitStore(GitCache); pub struct GitStore(GitCache);
impl GitStore { impl GitStore {
/// Register the clone cache rooted at `root` as an app-wide global. /// 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 { pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
let store = Self::new(root); let store = Self::new(root);
cx.set_global(GlobalGitStore(store.0.clone())); cx.set_global(GlobalGitStore(store.0.clone()));
@@ -25,10 +22,6 @@ impl GitStore {
} }
/// The app-wide clone cache. /// The app-wide clone cache.
///
/// # Panics
///
/// Panics if [`GitStore::set_global`] was never called.
pub fn global(cx: &App) -> Self { pub fn global(cx: &App) -> Self {
Self(cx.global::<GlobalGitStore>().0.clone()) Self(cx.global::<GlobalGitStore>().0.clone())
} }
+5 -9
View File
@@ -1,4 +1,5 @@
mod backend; mod backend;
mod checkouts;
mod git_store; mod git_store;
mod local_repos; mod local_repos;
mod profile; mod profile;
@@ -8,6 +9,7 @@ mod repo_list;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
pub use backend::{Backend, BackendEvent}; pub use backend::{Backend, BackendEvent};
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
pub use git_store::GitStore; pub use git_store::GitStore;
use gpui::{App, AppContext, Entity}; use gpui::{App, AppContext, Entity};
pub use local_repos::LocalReposStore; 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. /// Call once at startup, before opening any window that uses the stores.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -> Entity<Backend> { 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() rustls::crypto::aws_lc_rs::default_provider()
.install_default() .install_default()
.ok(); .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); Backend::set_global(entity.clone(), cx);
ProfileStore::set_global(cx.new(ProfileStore::new), 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); 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); GitStore::set_global(PathBuf::new(), cx);
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx); LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
entity entity
} }
@@ -55,13 +52,12 @@ pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub fn init(cx: &mut App) -> Entity<Backend> { pub fn init(cx: &mut App) -> Entity<Backend> {
let (client, signer) = new_backend().expect("failed to initialize nostr backend"); let (client, signer) = new_backend().expect("failed to initialize nostr backend");
let entity = cx.new(|cx| Backend::new(client, signer, cx)); let entity = cx.new(|cx| Backend::new(client, signer, cx));
Backend::set_global(entity.clone(), cx); Backend::set_global(entity.clone(), cx);
ProfileStore::set_global(cx.new(ProfileStore::new), cx); ProfileStore::set_global(cx.new(ProfileStore::new), cx);
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx); RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
GitStore::set_global(PathBuf::new(), cx); GitStore::set_global(PathBuf::new(), cx);
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx); LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
entity entity
} }
+2 -5
View File
@@ -45,9 +45,7 @@ impl LocalReposStore {
store store
} }
/// Forget a repository that has just been published to NIP-34, /// 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.
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) { pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
self.repos = Arc::new( self.repos = Arc::new(
self.repos self.repos
@@ -95,8 +93,7 @@ impl LocalReposStore {
dirty dirty
})?; })?;
// Scans requested while this one was running are coalesced into // Scans requested while this one ran are coalesced into one follow-up scan.
// a single follow-up scan.
if again { if again {
this.update(cx, |this, cx| this.rescan(cx))?; 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}; 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)] #[derive(Debug, Clone)]
pub struct Profile { pub struct Profile {
public_key: PublicKey, public_key: PublicKey,
@@ -62,18 +62,21 @@ impl Profile {
/// Message from the fetch task to the main thread. /// Message from the fetch task to the main thread.
enum Dispatch { 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, Synced,
} }
/// How long to wait for more requests before firing a batched sync. /// How long to wait for more requests before firing a batched sync.
const BATCH_TIMEOUT: Duration = Duration::from_millis(500); const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
/// Global profile cache. Profiles are fetched in batches and kept as plain /// Global profile cache.
/// data; the whole store notifies on change. ///
/// Profiles are fetched in batches and kept as plain data.
pub struct ProfileStore { pub struct ProfileStore {
profiles: HashMap<PublicKey, Profile>, 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>>, seen: RefCell<HashSet<PublicKey>>,
/// Sender for queuing fetch requests, batched by a background task. /// Sender for queuing fetch requests, batched by a background task.
sender: Sender<PublicKey>, sender: Sender<PublicKey>,
@@ -111,8 +114,7 @@ impl ProfileStore {
_ => {} _ => {}
}); });
// Fetch requests are queued on a channel and synced in batches by a // Fetch requests are queued on a channel.
// background task.
let client = backend.read(cx).client(); let client = backend.read(cx).client();
let (sender, receiver) = flume::unbounded::<PublicKey>(); let (sender, receiver) = flume::unbounded::<PublicKey>();
let (dispatch_tx, dispatch_rx) = flume::unbounded::<Dispatch>(); let (dispatch_tx, dispatch_rx) = flume::unbounded::<Dispatch>();
@@ -143,8 +145,9 @@ impl ProfileStore {
store store
} }
/// Get a profile. Returns a placeholder (default metadata) and queues a /// Get a profile.
/// fetch if the profile isn't cached yet. ///
/// Returns a placeholder with default metadata. Queues a fetch when the profile is not cached yet.
pub fn get(&self, public_key: &PublicKey) -> Profile { pub fn get(&self, public_key: &PublicKey) -> Profile {
if let Some(profile) = self.profiles.get(public_key) { if let Some(profile) = self.profiles.get(public_key) {
return profile.clone(); return profile.clone();
@@ -170,7 +173,8 @@ impl ProfileStore {
let filter = Filter::new().kind(Kind::Metadata).limit(200); let filter = Filter::new().kind(Kind::Metadata).limit(200);
let events = client.database().query(filter).await?; 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 let profiles: Vec<Profile> = events
.into_iter() .into_iter()
.map(|event| { .map(|event| {
@@ -205,7 +209,8 @@ impl ProfileStore {
let filter = Filter::new().kind(Kind::Metadata).author(public_key); let filter = Filter::new().kind(Kind::Metadata).author(public_key);
let events = client.database().query(filter).await?; 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 let profile = events
.into_iter() .into_iter()
.max_by_key(|e| e.created_at) .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 /// Re-read the latest metadata of every requested author from the local database.
/// database (used after a sync, which produces no NostrUpdate events). ///
/// Used after a sync, which produces no NostrUpdate events.
fn apply_seen(&mut self, cx: &mut Context<Self>) { fn apply_seen(&mut self, cx: &mut Context<Self>) {
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect(); 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 /// Sync metadata for requested authors in batches, debounced to collect requests.
/// requests. Runs on a background thread; results are dispatched to the ///
/// main thread, which re-reads the database. /// Results are dispatched to the main thread, which re-reads the database.
async fn handle_requests( async fn handle_requests(
client: &Client, client: &Client,
dispatch: &Sender<Dispatch>, dispatch: &Sender<Dispatch>,
@@ -316,9 +322,9 @@ impl ProfileStore {
.kind(Kind::Metadata) .kind(Kind::Metadata)
.authors(batch.drain().collect::<Vec<PublicKey>>()); .authors(batch.drain().collect::<Vec<PublicKey>>());
// Negentropy-sync with the bootstrap relays. Synced events are // Negentropy-sync with the bootstrap relays.
// written to the database directly (no NostrUpdate), so re-apply // Synced events are written to the database directly, no NostrUpdate.
// from the database afterwards. // Re-apply from the database afterwards.
match sync_bootstrap_only(client, filter, SyncOptions::default()).await { match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
Ok(_) => { Ok(_) => {
if dispatch.send(Dispatch::Synced).is_err() { if dispatch.send(Dispatch::Synced).is_err() {
+245 -207
View File
@@ -14,20 +14,23 @@ use signed_core::{
subject_override, 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; use crate::git_store::GitStore;
/// Delay between a refresh request and the actual re-query, so bursts of /// Delay between a refresh request and the actual re-query.
/// events (e.g. per-event `NostrUpdate`s) collapse into one query.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// Maximum size of one patch event, following NIP-34's guidance that /// Maximum size of one patch event.
/// patches should be used when each event is under 60kb. ///
/// NIP-34 suggests patches when each event is under 60kb.
const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024; const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
/// Per-repository store: announcement, state, issues, patches, PRs, /// Per-repository store.
/// comments and their resolved statuses. Always derived from the local ///
/// database. /// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses.
/// Always derived from the local database.
pub struct RepoStore { pub struct RepoStore {
addr: RepoAddr, addr: RepoAddr,
pub announcement: Option<Announcement>, pub announcement: Option<Announcement>,
@@ -40,34 +43,35 @@ pub struct RepoStore {
pub pull_requests: Vec<Event>, pub pull_requests: Vec<Event>,
/// Comments on issues / PRs, oldest first. /// Comments on issues / PRs, oldest first.
pub comments: Vec<Event>, pub comments: Vec<Event>,
/// Resolved status per root event (issue / patch / PR), recomputed on /// Resolved status per root event, issue, patch or PR.
/// every refresh so render paths are HashMap lookups instead of
/// scanning all status events per root.
status_by_root: HashMap<EventId, RepoStatus>, status_by_root: HashMap<EventId, RepoStatus>,
/// Open issue / root PR counts, computed with [`Self::status_by_root`] /// Open issue and root PR counts.
/// on every refresh. /// Computed with [`Self::status_by_root`] on every refresh.
open_issue_count: usize, open_issue_count: usize,
open_pr_count: usize, open_pr_count: usize,
/// Kind-1624 cover notes and kind-1985 label events referencing this /// Kind-1624 cover notes and kind-1985 label events.
/// repository's roots (ngit / GitWorkshop extensions). ///
/// They reference this repository's roots, used by ngit and GitWorkshop.
cover_notes: Vec<Event>, cover_notes: Vec<Event>,
labels: Vec<Event>, labels: Vec<Event>,
/// Incremented on every applied refresh; views key their derived-data /// Incremented on every applied refresh.
/// caches to it instead of recomputing on every render. ///
/// Views key their derived-data caches to it instead of recomputing on every render.
version: u64, version: u64,
/// Error of the last action initiated from this store, if any. /// Error of the last action initiated from this store, if any.
pub last_error: Option<String>, pub last_error: Option<String>,
/// Non-fatal warning of the last action (e.g. a PR published without /// Non-fatal warning of the last action, if any.
/// its commit reaching a grasp server), if any. ///
/// Example, a PR published without its commit reaching a grasp server.
pub last_warning: Option<String>, pub last_warning: Option<String>,
/// Relays announced by this repository (NIP-34 `relays` tag) that we /// Relays already asked to connect to, from this repository's NIP-34 `relays` tag.
/// have already been asked to connect to and fetch from, to avoid ///
/// re-subscribing on every refresh. /// Avoids re-subscribing and re-fetching on every refresh.
repo_relays: HashSet<RelayUrl>, repo_relays: HashSet<RelayUrl>,
/// Root events (issues, patches, PRs) for which the per-root fetches /// Root events, issues, patches and PRs, already fetched per root.
/// (NIP-22 comments, statuses without an `a` tag, cover notes and ///
/// labels) have already been requested, to avoid re-fetching on every /// The per-root fetches cover NIP-22 comments and statuses without an `a` tag.
/// refresh. /// Also kind-1624 cover notes and kind-1985 labels.
root_fetches: HashSet<EventId>, root_fetches: HashSet<EventId>,
refreshing: bool, refreshing: bool,
refresh_dirty: bool, refresh_dirty: bool,
@@ -90,12 +94,12 @@ impl RepoStore {
let coordinate = update.coordinate.as_ref() == Some(&this.addr); let coordinate = update.coordinate.as_ref() == Some(&this.addr);
let author = update.author == this.addr.public_key; let author = update.author == this.addr.public_key;
let kind = update.kind == Kind::GitRepoAnnouncement; let kind = update.kind == Kind::GitRepoAnnouncement;
// NIP-22 comments carry no `a` tag, so they can't be // NIP-22 comments carry no `a` tag.
// matched by coordinate; any comment may reference this // Coordinate matching fails for them.
// repository's roots. // Any comment may reference this repository's roots.
let comment = update.kind == Kind::Comment; let comment = update.kind == Kind::Comment;
// Status events may omit their `a` tag (NIP-34), so any // Status events may omit their `a` tag, NIP-34.
// status event may reference a root of this repository. // Any status event may reference a root of this repository.
let status = RepoStatus::from_kind(update.kind).is_some(); let status = RepoStatus::from_kind(update.kind).is_some();
// Cover notes and labels carry no `a` tag either. // Cover notes and labels carry no `a` tag either.
let annotation = update.kind == COVER_NOTE_KIND || update.kind == Kind::Label; let annotation = update.kind == COVER_NOTE_KIND || update.kind == Kind::Label;
@@ -106,9 +110,8 @@ impl RepoStore {
let kind = event.kind == Kind::GitRepoAnnouncement; let kind = event.kind == Kind::GitRepoAnnouncement;
let author = event.pubkey == this.addr.public_key; let author = event.pubkey == this.addr.public_key;
let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr); let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr);
// Locally published deletions may target any event of // Locally published deletions may target any event of this repository.
// this repository; refresh so they take effect // Refresh so they take effect immediately, like relay deletions.
// immediately, like relay deletions.
let deletion = let deletion =
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
@@ -149,9 +152,9 @@ impl RepoStore {
}; };
store.subscribe_remote(cx); store.subscribe_remote(cx);
// The announcement we opened the repo from may already list its // The announcement we opened the repo from may already list its relays.
// relays; connect to them right away instead of waiting for the // Connect to them right away.
// bootstrap fetch to return the same event. // Do not wait for the bootstrap fetch to return the same event.
store.connect_announced_relays(&announced_relays, cx); store.connect_announced_relays(&announced_relays, cx);
store.refresh(cx); store.refresh(cx);
store store
@@ -162,7 +165,7 @@ impl RepoStore {
&self.addr &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 { pub fn name(&self) -> SharedString {
self.announcement self.announcement
.as_ref() .as_ref()
@@ -171,35 +174,32 @@ impl RepoStore {
}) })
} }
/// Filters that make up a repository: announcement, state, /// Filters that make up a repository.
/// activity and deletions targeting it. ///
/// Announcement, state, activity and deletions targeting it.
fn repo_filters(addr: &RepoAddr) -> Vec<Filter> { fn repo_filters(addr: &RepoAddr) -> Vec<Filter> {
let mut filters = vec![ let mut filters = vec![
// Announcement and state share author and identifier, so they // Announcement and state share author and identifier.
// combine into one filter: one fewer negentropy reconciliation // They combine into one filter, one fewer negentropy reconciliation per relay.
// per relay when fetching from the repo's announced relays.
Filter::new() Filter::new()
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState]) .kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
.author(addr.public_key) .author(addr.public_key)
.identifier(addr.identifier.clone()), .identifier(addr.identifier.clone()),
filters::activity(addr), filters::activity(addr),
]; ];
// Deletion requests (NIP-09/62) must be known before any event of // Deletion requests, NIP-09/62, must be known before any event is shown.
// this repository can be shown.
filters.extend(filters::deletions_for_repo(addr)); filters.extend(filters::deletions_for_repo(addr));
filters filters
} }
/// Fetch this repository's events from the relays announced in its /// Fetch this repository's events from the relays in its NIP-34 `relays` tag.
/// 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.
fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context<Self>) { fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context<Self>) {
let new: Vec<RelayUrl> = relays let new: Vec<RelayUrl> = relays
.iter() .iter()
.filter(|url| !self.repo_relays.contains(*url)) .filter(|url| !self.repo_relays.contains(*url))
.cloned() .cloned()
.collect(); .collect();
if new.is_empty() { if new.is_empty() {
return; return;
} }
@@ -207,12 +207,13 @@ impl RepoStore {
let backend = Backend::global(cx); let backend = Backend::global(cx);
let addr = self.addr.clone(); let addr = self.addr.clone();
backend.update(cx, |backend, cx| { backend.update(cx, |backend, cx| {
backend.connect_repo_relays(new, Self::repo_filters(&addr), 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>) { fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx); let backend = Backend::global(cx);
let addr = self.addr.clone(); let addr = self.addr.clone();
@@ -223,9 +224,6 @@ impl RepoStore {
} }
/// Re-query the local database and update all fields. /// 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>) { pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refreshing { if self.refreshing {
self.refresh_dirty = true; self.refresh_dirty = true;
@@ -270,8 +268,8 @@ impl RepoStore {
let deletions = Deletions::from_events(deletion_events); let deletions = Deletions::from_events(deletion_events);
// Parse and sort off the main thread; only plain data // Parse and sort off the main thread.
// crosses back into the entity. // Only plain data crosses back into the entity.
let all_announcements = announcements let all_announcements = announcements
.into_iter() .into_iter()
.filter(|e| !deletions.is_deleted(e)); .filter(|e| !deletions.is_deleted(e));
@@ -300,9 +298,8 @@ impl RepoStore {
} }
} }
// NIP-22 comments reference their root via an `E`/`e` tag rather // NIP-22 comments reference their root via an `E` or `e` tag.
// than the repository's `a` tag, so query them by the root events // Not the repository's `a` tag, so query them by the root events.
// of this repository.
let mut seen_comments: HashSet<EventId> = comments.iter().map(|e| e.id).collect(); let mut seen_comments: HashSet<EventId> = comments.iter().map(|e| e.id).collect();
let db = client.database(); let db = client.database();
@@ -320,8 +317,8 @@ impl RepoStore {
} }
} }
// Status events may omit their `a` tag, // Status events may omit their `a` tag.
// so also query them by the root events they reference. // Query them by the root events they reference too.
let mut seen_statuses: HashSet<EventId> = statuses.iter().map(|e| e.id).collect(); let mut seen_statuses: HashSet<EventId> = statuses.iter().map(|e| e.id).collect();
let db = client.database(); let db = client.database();
@@ -339,8 +336,8 @@ impl RepoStore {
} }
} }
// Cover notes (1624) and label events (1985) reference // Cover notes, 1624, and label events, 1985, carry no `a` tag.
// so query them per root like comments and statuses. // 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_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 mut seen_labels: HashSet<EventId> = labels.iter().map(|e| e.id).collect();
let db = client.database(); let db = client.database();
@@ -371,9 +368,9 @@ impl RepoStore {
sort_newest_first(&mut cover_notes); sort_newest_first(&mut cover_notes);
sort_newest_first(&mut labels); sort_newest_first(&mut labels);
// Resolve every root's status once here; render paths do // Resolve every root's status once here.
// HashMap lookups instead of scanning all status events per // Render paths do HashMap lookups instead of per-root status scans.
// root (quadratic, with an allocation per pair). // Those scans are quadratic, with an allocation per pair.
let maintainers = announcement let maintainers = announcement
.as_ref() .as_ref()
.map(Announcement::effective_maintainers) .map(Announcement::effective_maintainers)
@@ -439,8 +436,8 @@ impl RepoStore {
let again = this.update(cx, |this, cx| { let again = this.update(cx, |this, cx| {
this.announcement = announcement; this.announcement = announcement;
// The announcement may list relays for this repository's activity, // The announcement may list relays for this repository's activity.
// connect to any we haven't fetched from yet. // Connect to any we have not fetched from yet.
let relays = this let relays = this
.announcement .announcement
.as_ref() .as_ref()
@@ -464,10 +461,10 @@ impl RepoStore {
this.labels = labels; this.labels = labels;
this.version = this.version.wrapping_add(1); this.version = this.version.wrapping_add(1);
// Comments, statuses without an `a` tag, cover notes and // Comments, statuses without an `a` tag, cover notes and labels.
// labels are not addressed to the repository, so fetch them // None are addressed to the repository.
// by the root events they reference, on the bootstrap relays // Fetch them by the root events they reference.
// and on the relays this repository announced. // Use the bootstrap relays and the relays this repository announced.
let roots = this let roots = this
.issues .issues
.iter() .iter()
@@ -484,10 +481,9 @@ impl RepoStore {
if !new_roots.is_empty() { if !new_roots.is_empty() {
this.root_fetches.extend(new_roots.iter().copied()); this.root_fetches.extend(new_roots.iter().copied());
// Batch the per-root filters: one statuses filter and one // Batch the per-root filters.
// annotations filter covering all new roots, instead of // One statuses filter and one annotations filter cover all new roots.
// one filter per root (each filter is a separate // One filter per root costs a negentropy reconciliation per relay.
// negentropy reconciliation per relay).
let mut root_filters = filters::comments_for(new_roots.clone()); let mut root_filters = filters::comments_for(new_roots.clone());
root_filters.push(filters::statuses_for(new_roots.iter().copied())); root_filters.push(filters::statuses_for(new_roots.iter().copied()));
root_filters.push(filters::annotations_for(new_roots)); 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 { if again {
this.update(cx, |this, cx| this.refresh(cx))?; 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 { pub fn status_of(&self, root: &Event) -> RepoStatus {
status_of(&self.status_by_root, root) status_of(&self.status_by_root, root)
} }
@@ -531,8 +528,7 @@ impl RepoStore {
self.version self.version
} }
/// The effective cover note of `root` (kind 1624), if any: /// The effective cover note of `root`, kind 1624, if any.
/// the latest note authored by the root author or a maintainer.
pub fn cover_note_of(&self, root: &Event) -> Option<&Event> { pub fn cover_note_of(&self, root: &Event) -> Option<&Event> {
let maintainers = self let maintainers = self
.announcement .announcement
@@ -543,8 +539,7 @@ impl RepoStore {
cover_note(root, &self.cover_notes, &maintainers) cover_note(root, &self.cover_notes, &maintainers)
} }
/// The effective hashtag labels of `root`: its own `t` tags plus labels /// The effective hashtag labels of `root`.
/// from authorized NIP-32 kind-1985 events (`#t` namespace).
pub fn labels_of(&self, root: &Event) -> Vec<String> { pub fn labels_of(&self, root: &Event) -> Vec<String> {
let maintainers = self let maintainers = self
.announcement .announcement
@@ -556,8 +551,7 @@ impl RepoStore {
labels labels
} }
/// The effective subject/title override of `root` from authorized /// The effective subject or title override of `root`, if any.
/// kind-1985 events (`#subject` namespace), if any.
pub fn subject_of(&self, root: &Event) -> Option<String> { pub fn subject_of(&self, root: &Event) -> Option<String> {
let maintainers = self let maintainers = self
.announcement .announcement
@@ -568,22 +562,26 @@ impl RepoStore {
subject_override(root, &self.labels, &maintainers) subject_override(root, &self.labels, &maintainers)
} }
/// Number of open issues: issues whose resolved status is /// Number of open issues.
/// [`RepoStatus::Open`] (issues without status events default to open). ///
/// Issues whose resolved status is [`RepoStatus::Open`].
/// Issues without status events default to open.
pub fn issue_count(&self) -> usize { pub fn issue_count(&self) -> usize {
self.open_issue_count self.open_issue_count
} }
/// Number of open pull requests: root PR events (not PR updates, whose /// Number of open pull requests.
/// status is carried by the root) with a resolved status of ///
/// [`RepoStatus::Open`]. /// Only root PR events count, PR updates do not.
/// They must resolve to [`RepoStatus::Open`].
pub fn pull_request_count(&self) -> usize { pub fn pull_request_count(&self) -> usize {
self.open_pr_count self.open_pr_count
} }
/// Whether `user` is the author (owner) of this repository: the public /// Whether `user` is the author or owner of this repository.
/// key of the repository address. Only the author may manage the ///
/// repository's pull requests (close / reopen / merge). /// 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 { pub fn is_author(&self, user: &PublicKey) -> bool {
&self.addr.public_key == user &self.addr.public_key == user
} }
@@ -601,19 +599,20 @@ impl RepoStore {
self.send(builder, cx); 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> { pub fn comments_of(&self, root: &EventId) -> impl Iterator<Item = &Event> {
self.comments self.comments
.iter() .iter()
.filter(move |e| signed_core::references_root(e, root)) .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>) { pub fn comment(&mut self, root: &Event, content: String, cx: &mut Context<Self>) {
self.reply(root, None, content, cx); 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. /// `None` publishes a top-level comment on the root itself.
pub fn reply( pub fn reply(
&mut self, &mut self,
@@ -634,27 +633,7 @@ impl RepoStore {
); );
} }
/// Open a pull request on this repository: a root PR event (kind 1618) /// Open a pull request on this repository.
/// 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.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn open_pull_request( pub fn open_pull_request(
&mut self, &mut self,
@@ -688,7 +667,8 @@ impl RepoStore {
return; 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 let Some(current_commit) = series
.last() .last()
.and_then(|part| patch_current_commit(part)) .and_then(|part| patch_current_commit(part))
@@ -704,28 +684,45 @@ impl RepoStore {
let backend = Backend::global(cx); let backend = Backend::global(cx);
let signer = backend.read(cx).signer(); 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()); self.last_error = Some("Sign in to open a pull request".into());
cx.notify(); cx.notify();
return; return;
} };
// The author's npub names their GRASP-06 namespace, `/prs/...`.
let author_npub = user.to_bech32().unwrap();
let addr = self.addr.clone(); let addr = self.addr.clone();
let owner = self.addr.public_key; let owner = self.addr.public_key;
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
let repo_id = addr.identifier.clone();
let (push_owner, push_repo_id, push_relays) = self let base_npub = owner.to_bech32().unwrap();
let push_relays = self
.announcement .announcement
.as_ref() .as_ref()
.map(|a| { .map(|a| a.relays.clone())
let owner = a.owner.to_bech32().unwrap_or_else(|_| a.owner.to_hex());
(owner, a.id.clone(), a.relays.clone())
})
.unwrap_or_default(); .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| { self.tasks.push(cx.spawn(async move |this, cx| {
// The PR references the root patch event so viewers can find // The PR references the root patch event.
// the patch without carrying it inline. // Viewers can then find the patch without carrying it inline.
let root_patch = match publish_patch_series( let root_patch = match publish_patch_series(
&this, &this,
cx, 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| { 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 { let builder = GitPullRequest {
repository: this.addr.clone(), repository: this.addr.clone(),
content: description, content: description,
subject, subject,
labels: Vec::new(), labels: Vec::new(),
branch_name, branch_name,
// NIP-34: PRs carry at least one clone URL where the tip commit can be downloaded, clone,
// 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(),
current_commit, current_commit,
root_patch_event: Some(root_patch.id), root_patch_event: Some(root_patch.id),
merge_base: merge_base merge_base: merge_base
@@ -768,16 +822,16 @@ impl RepoStore {
} }
.into_event_builder(); .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()) { match this.announcement.as_ref().and_then(|a| a.euc.clone()) {
Some(euc) => builder.tag(Tag::parse(["r", &euc]).expect("valid r tag")), Some(euc) => builder.tag(Tag::parse(["r", &euc]).expect("valid r tag")),
None => builder, None => builder,
} }
})?; })?;
// Sign before publishing so the tip can be pushed to the grasp // Sign before publishing.
// servers under `refs/nostr/<event-id>` (nak's convention): // The tip is pushed to the grasp servers under `refs/nostr/<event-id>`.
// readers fetch that ref to get the commit behind the `c` tag. // Nak's convention, readers fetch that ref for the commit behind the `c` tag.
let event = cx let event = cx
.background_spawn({ .background_spawn({
let signer = signer.clone(); let signer = signer.clone();
@@ -793,20 +847,21 @@ impl RepoStore {
let path = path.clone(); let path = path.clone();
let tip = tip.clone(); let tip = tip.clone();
let reference = reference.clone(); let reference = reference.clone();
let owner = push_owner.clone(); // Author servers first, then the announced base grasp servers.
let repo_id = push_repo_id.clone(); // The extra targets are best-effort redundancy.
let relays = push_relays.clone(); let targets: Vec<(String, String)> = author_targets
.into_iter()
.chain(base_targets)
.collect();
async move { async move {
let mut failures = Vec::new(); let mut failures = Vec::new();
let mut pushed = 0; let mut pushed = 0;
for relay in &relays { for (url, label) in &targets {
let Some(base) = grasp_base_url(relay) else { match signed_git::push_commit_ref(
continue; &path, url, &tip, &reference,
}; ) {
let url = format!("{base}/{owner}/{repo_id}.git");
match signed_git::push_commit_ref(&path, &url, &tip, &reference) {
Ok(()) => pushed += 1, Ok(()) => pushed += 1,
Err(e) => failures.push(format!("{relay}: {e}")), Err(e) => failures.push(format!("{label}: {e}")),
} }
} }
(pushed, failures) (pushed, failures)
@@ -840,8 +895,8 @@ impl RepoStore {
} }
}; };
// NIP-34: a draft PR carries a kind-1633 status event, // A draft PR carries a kind-1633 status event, NIP-34.
// publish it right after the PR event so viewers never show it open. // Publish it right after the PR event so viewers never show it open.
if draft { if draft {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_status(&pr_event, RepoStatus::Draft, 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 /// Update a pull request.
/// 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.
/// ///
/// 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>) { pub fn update_pull_request(&mut self, root: &Event, patch: String, cx: &mut Context<Self>) {
self.last_error = None; self.last_error = None;
self.last_warning = None; self.last_warning = None;
@@ -905,9 +958,8 @@ impl RepoStore {
return; return;
}; };
// NIP-34: the first patch of a revision replies to the original // The first revision patch replies to the original root patch, NIP-34.
// root patch (the PR's `e` tag; fall back to the oldest patch of // Use the PR's `e` tag, or the oldest patch of the linked set if the PR has none.
// the linked set for PRs without one).
let root_patch_id = root.tags.event_ids().next().or_else(|| { let root_patch_id = root.tags.event_ids().next().or_else(|| {
pull_request_patches(root, self.patches.iter()) pull_request_patches(root, self.patches.iter())
.first() .first()
@@ -954,8 +1006,8 @@ impl RepoStore {
} }
.into_event_builder(); .into_event_builder();
// NIP-34: the `r` EUC tag lets clients subscribe to all PR // The `r` EUC tag lets clients subscribe to all PR updates.
// updates of this repository; the SDK builder omits it. // The SDK builder omits it.
let builder = match euc.as_deref() { let builder = match euc.as_deref() {
Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")), Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")),
None => builder, None => builder,
@@ -976,9 +1028,9 @@ impl RepoStore {
})); }));
} }
/// Set the status of a root event. Per NIP-34 only the root author or a /// Set the status of a root event.
/// repository maintainer may set the status; status events from anyone ///
/// else are ignored by clients, so refuse them up front. /// 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>) { pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context<Self>) {
self.last_error = None; self.last_error = None;
@@ -1015,18 +1067,18 @@ impl RepoStore {
self.send(builder, cx); self.send(builder, cx);
} }
/// Publish a repository state announcement (kind 30618) with the refs of /// Publish a repository state announcement
/// the local clone: branches, tags and HEAD. Only the repository owner
/// may publish state, and a local clone must exist to read the refs from.
pub fn publish_state(&mut self, cx: &mut Context<Self>) { pub fn publish_state(&mut self, cx: &mut Context<Self>) {
self.last_error = None; self.last_error = None;
let backend = Backend::global(cx); let backend = Backend::global(cx);
let Some(user) = backend.read(cx).current_user() else { let Some(user) = backend.read(cx).current_user() else {
self.last_error = Some("Sign in to publish repository state".into()); self.last_error = Some("Sign in to publish repository state".into());
cx.notify(); cx.notify();
return; return;
}; };
if !self.is_author(&user) { if !self.is_author(&user) {
self.last_error = Some("Only the repository owner can publish state".into()); self.last_error = Some("Only the repository owner can publish state".into());
cx.notify(); cx.notify();
@@ -1067,17 +1119,7 @@ impl RepoStore {
})); }));
} }
/// Merge a pull request: apply its patch (the content of the linked /// Merge a pull request.
/// 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`].
pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context<Self>) { pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context<Self>) {
self.last_error = None; self.last_error = None;
self.last_warning = None; self.last_warning = None;
@@ -1093,23 +1135,28 @@ impl RepoStore {
let cache = GitStore::global(cx).cache().clone(); let cache = GitStore::global(cx).cache().clone();
let addr = self.addr.clone(); let addr = self.addr.clone();
let clone_urls: Vec<String> = self let clone_urls: Vec<String> = self
.announcement .announcement
.as_ref() .as_ref()
.map(|a| a.clone.iter().map(ToString::to_string).collect()) .map(|a| a.clone.iter().map(ToString::to_string).collect())
.unwrap_or_default(); .unwrap_or_default();
let patch = pull_request_patch(root, self.patches.iter()); let patch = pull_request_patch(root, self.patches.iter());
// The applied patch events, for the status tags below. // The applied patch events, for the status tags below.
let patches: Vec<Event> = pull_request_patches(root, self.patches.iter()) let patches: Vec<Event> = pull_request_patches(root, self.patches.iter())
.into_iter() .into_iter()
.cloned() .cloned()
.collect(); .collect();
let relay_hint = self let relay_hint = self
.announcement .announcement
.as_ref() .as_ref()
.and_then(|a| a.relays.first()) .and_then(|a| a.relays.first())
.map(ToString::to_string) .map(ToString::to_string)
.unwrap_or_default(); .unwrap_or_default();
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
let root = root.clone(); let root = root.clone();
@@ -1119,8 +1166,8 @@ impl RepoStore {
.workdir() .workdir()
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
.to_path_buf(); .to_path_buf();
// The commits created by the apply: everything between the // The commits the apply created.
// previous HEAD and the new one, oldest first. // Everything between the previous HEAD and the new one, oldest first.
let previous = signed_git::head_commit_id(&workdir)?; let previous = signed_git::head_commit_id(&workdir)?;
signed_git::apply_patch(&workdir, &patch)?; signed_git::apply_patch(&workdir, &patch)?;
let applied = signed_git::commits_since(&workdir, previous.as_deref())?; 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: /// 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).
fn publish_applied_status( fn publish_applied_status(
&mut self, &mut self,
root: &Event, root: &Event,
@@ -1171,14 +1215,16 @@ impl RepoStore {
Tag::public_key(root.pubkey), Tag::public_key(root.pubkey),
Tag::coordinate(self.addr.clone(), None), Tag::coordinate(self.addr.clone(), None),
]; ];
if let Some(euc) = euc if let Some(euc) = euc
&& let Ok(tag) = Tag::parse(["r", euc]) && let Ok(tag) = Tag::parse(["r", euc])
{ {
tags.push(tag); 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 // Tag each applied patch event.
// their statuses resolve to Applied too. // `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() { for (ix, patch) in patches.iter().enumerate() {
if let Ok(tag) = if let Ok(tag) =
Tag::parse(["q", &patch.id.to_hex(), relay_hint, &patch.pubkey.to_hex()]) Tag::parse(["q", &patch.id.to_hex(), relay_hint, &patch.pubkey.to_hex()])
@@ -1191,6 +1237,7 @@ impl RepoStore {
tags.push(tag); tags.push(tag);
} }
} }
// The commits `git am` created on top of the previous HEAD. // The commits `git am` created on top of the previous HEAD.
if !applied.is_empty() { if !applied.is_empty() {
let mut applied_tag = vec!["applied-as-commits".to_string()]; 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) events.into_iter().max_by_key(|e| e.created_at)
} }
/// Status of `root` from the precomputed map; roots without status events /// Status of `root` from the precomputed map.
/// default to [`RepoStatus::Open`], like [`signed_core::resolve_status`].
fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, root: &Event) -> RepoStatus { fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, root: &Event) -> RepoStatus {
status_by_root status_by_root
.get(&root.id) .get(&root.id)
@@ -1242,10 +1288,7 @@ fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, root: &Event) -> Rep
.unwrap_or(RepoStatus::Open) .unwrap_or(RepoStatus::Open)
} }
/// Resolve the status of every root event in one pass: status events are /// Resolve every root event's status in one pass.
/// 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.
fn resolve_statuses( fn resolve_statuses(
issues: &[Event], issues: &[Event],
patches: &[Event], patches: &[Event],
@@ -1285,20 +1328,17 @@ fn sort_oldest_first(events: &mut [Event]) {
events.sort_by_key(|e| e.created_at); events.sort_by_key(|e| e.created_at);
} }
/// The proposed commit of a `git format-patch` output: the `From <commit>` /// The proposed commit of a `git format-patch` output.
/// header on its first line. /// It is the `From <commit>` header on the first line.
fn patch_current_commit(patch: &str) -> Option<&str> { fn patch_current_commit(patch: &str) -> Option<&str> {
let line = patch.lines().next()?; let line = patch.lines().next()?;
let hex = line.strip_prefix("From ")?; let hex = line.strip_prefix("From ")?;
hex.split_whitespace().next().filter(|hex| hex.len() == 40) hex.split_whitespace().next().filter(|hex| hex.len() == 40)
} }
/// Publish a `git format-patch` series as chained kind-1617 events and /// Publish a `git format-patch` series as chained kind-1617 events.
/// 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 /// Returns the root event, the one a PR references.
/// `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.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
async fn publish_patch_series( async fn publish_patch_series(
this: &WeakEntity<RepoStore>, this: &WeakEntity<RepoStore>,
@@ -1365,10 +1405,7 @@ async fn publish_patch_series(
root.ok_or_else(|| anyhow::anyhow!("patch series is empty")) root.ok_or_else(|| anyhow::anyhow!("patch series is empty"))
} }
/// Build a NIP-22 kind-1111 comment: uppercase `E`/`K`/`P` tags scope the /// Build a NIP-22 kind-1111 comment.
/// 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.
fn comment_builder( fn comment_builder(
root: &Event, root: &Event,
parent: Option<&Event>, parent: Option<&Event>,
@@ -1384,6 +1421,7 @@ fn comment_builder(
relay_hint.cloned().map(Cow::Owned), relay_hint.cloned().map(Cow::Owned),
) )
}; };
let root_target = target(root); let root_target = target(root);
let parent_target = parent.map(target).unwrap_or_else(|| root_target.clone()); 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"); 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 e = event.tags.iter().find(|t| t.kind() == "E").expect("E tag");
let slice = e.as_slice(); let slice = e.as_slice();
assert_eq!(slice[1], root.id.to_hex()); assert_eq!(slice[1], root.id.to_hex());
assert_eq!(slice[2], relay.as_str()); assert_eq!(slice[2], relay.as_str());
assert_eq!(slice[3], root.pubkey.to_hex()); assert_eq!(slice[3], root.pubkey.to_hex());
// The lowercase `e` tag references the parent, which for a top-level // The lowercase `e` tag references the parent.
// comment is the root itself. // For a top-level comment the parent is the root itself.
let e = event.tags.iter().find(|t| t.kind() == "e").expect("e tag"); let e = event.tags.iter().find(|t| t.kind() == "e").expect("e tag");
assert_eq!(e.as_slice()[1], root.id.to_hex()); assert_eq!(e.as_slice()[1], root.id.to_hex());
@@ -1466,8 +1504,8 @@ mod tests {
.finalize(&keys) .finalize(&keys)
.expect("signed event"); .expect("signed event");
// The uppercase `E` tag still scopes the root event, while the // The uppercase `E` tag still scopes the root event.
// lowercase `e` tag references the parent comment. // The lowercase `e` tag references the parent comment.
let root_ref = event.tags.iter().find(|t| t.kind() == "E").expect("E tag"); 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"); 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()); 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}; use crate::backend::{Backend, BackendEvent};
/// Delay between a refresh request and the actual re-query, so bursts of /// Delay between a refresh request and the actual re-query.
/// events (e.g. sync progress ticks) collapse into one query. ///
/// Bursts of events, e.g. sync progress ticks, collapse into one query.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// How far back activity events count toward a repository's last activity. /// How far back activity events count toward a repository's last activity.
@@ -20,40 +21,36 @@ struct GlobalRepoListStore(Entity<RepoListStore>);
impl Global for GlobalRepoListStore {} impl Global for GlobalRepoListStore {}
/// Counts of NIP-34 activity events per repository, used to rank the /// NIP-34 activity event counts per repository, ranking the explore list by popularity.
/// 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.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RepoActivityCounts { pub struct RepoActivityCounts {
/// Root `30611` issue events addressed to the repository. /// Root `30611` issue events addressed to the repository.
pub issues: u32, pub issues: u32,
/// Root `3063` pull request events addressed to the repository /// Root `3063` pull request events addressed to the repository.
/// (updates to a PR are not new PRs and don't count). ///
/// PR updates are not new PRs and do not count.
pub pull_requests: u32, pub pull_requests: u32,
/// `1617` patch events addressed to the repository. /// `1617` patch events addressed to the repository.
pub commits: u32, pub commits: u32,
} }
impl RepoActivityCounts { 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 { pub fn score(self) -> u32 {
self.issues + self.pull_requests + self.commits self.issues + self.pull_requests + self.commits
} }
} }
/// Store listing repository announcements (global discovery or per-author). /// 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.
pub struct RepoListStore { pub struct RepoListStore {
/// Shared so views can clone the list per frame without a deep copy. /// Shared so views can clone the list per frame without a deep copy.
pub announcements: Arc<Vec<Announcement>>, pub announcements: Arc<Vec<Announcement>>,
/// Latest known activity timestamp per repository /// Latest known activity timestamp per repository.
/// (announcements, state updates, patches, PRs, issues, statuses). /// Covers announcements, state updates, patches, PRs, issues and statuses.
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>, pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
/// Issues + pull requests + commits per repository, for the Popular /// Issues, pull requests and commits per repository.
/// ranking of the explore list. ///
/// Used for the Popular ranking of the explore list.
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>, pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
author: Option<PublicKey>, author: Option<PublicKey>,
refreshing: bool, refreshing: bool,
@@ -65,8 +62,7 @@ pub struct RepoListStore {
} }
impl RepoListStore { impl RepoListStore {
/// Retrieve the global explore store (all announcements, created at /// Retrieve the global explore store.
/// startup by [`crate::init`]).
pub fn global(cx: &App) -> Entity<Self> { pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalRepoListStore>().0.clone() cx.global::<GlobalRepoListStore>().0.clone()
} }
@@ -82,12 +78,12 @@ impl RepoListStore {
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
let relevant = match event { let relevant = match event {
BackendEvent::NostrUpdate(update) => { 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 { if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
true true
} else if filters::ACTIVITY_KINDS.contains(&update.kind) { } else if filters::ACTIVITY_KINDS.contains(&update.kind) {
// Activity (patches, issues, ...) is addressed to repos via // Activity events are addressed to repos via `a` tags.
// `a` tags, so its author isn't the repo owner; always refresh. // Their author is not the repo owner, always refresh.
true true
} else { } else {
let is_announcement = update.kind == Kind::GitRepoAnnouncement; let is_announcement = update.kind == Kind::GitRepoAnnouncement;
@@ -97,13 +93,15 @@ impl RepoListStore {
} }
} }
BackendEvent::Published(event) => { BackendEvent::Published(event) => {
let announcement = event.kind == Kind::GitRepoAnnouncement let kind_match = event.kind == Kind::GitRepoAnnouncement;
&& this.author.is_none_or(|a| a == event.pubkey); let author_match = this.author.is_none_or(|a| a == event.pubkey);
// Locally published deletions (e.g. deleting a repo) let announcement = kind_match && author_match;
// are already in the local database; refresh so they
// take effect immediately, like relay deletions. // Locally published deletions are already in the local database.
// Refresh so they take effect immediately, like relay deletions.
let deletion = let deletion =
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
announcement || deletion announcement || deletion
} }
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true, BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
@@ -128,13 +126,13 @@ impl RepoListStore {
}; };
store.subscribe_remote(cx); store.subscribe_remote(cx);
// Query the local database right away; the list never waits for the // Query the local database right away.
// relay syncs started above to finish. // The list never waits for the relay syncs started above to finish.
store.refresh_initial(cx); store.refresh_initial(cx);
store 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>) { pub fn set_author(&mut self, author: Option<PublicKey>, cx: &mut Context<Self>) {
self.author = author; self.author = author;
self.subscribe_remote(cx); self.subscribe_remote(cx);
@@ -152,15 +150,15 @@ impl RepoListStore {
None => filters::all_announcements(), None => filters::all_announcements(),
}; };
backend.sync_bootstrap(filter, cx); backend.sync_bootstrap(filter, cx);
// Deletion requests (NIP-09/62) must be known before any // Deletion requests, NIP-09/62, must be known before any announcement is shown.
// announcement can be shown.
backend.sync_bootstrap(filters::deletions(), cx); backend.sync_bootstrap(filters::deletions(), cx);
}); });
} }
/// One-shot initial load: query the local database immediately (no /// One-shot initial load.
/// debounce), so stored announcements appear as soon as the app opens. ///
/// Only called from [`Self::new`], before any refresh can be pending. /// 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>) { fn refresh_initial(&mut self, cx: &mut Context<Self>) {
debug_assert!(!self.debouncing); debug_assert!(!self.debouncing);
if self.refreshing { if self.refreshing {
@@ -170,12 +168,7 @@ impl RepoListStore {
self.run_refresh(cx); self.run_refresh(cx);
} }
/// Re-query the local database. Latest announcement per repository wins. /// Re-query the local database.
///
/// 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.
pub fn refresh(&mut self, cx: &mut Context<Self>) { pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refreshing { if self.refreshing {
self.refresh_dirty = true; self.refresh_dirty = true;
@@ -198,7 +191,7 @@ impl RepoListStore {
self.tasks.push(task); 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>) { fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.refreshing = true; self.refreshing = true;
@@ -216,8 +209,8 @@ impl RepoListStore {
let deletion_events = client.database().query(filters::deletions()).await?; let deletion_events = client.database().query(filters::deletions()).await?;
let deletions = Deletions::from_events(deletion_events); let deletions = Deletions::from_events(deletion_events);
// Dedup and sort off the main thread; only the final list // Dedup and sort off the main thread.
// crosses back into the entity. // Only the final list crosses back into the entity.
let mut by_repo: HashMap<RepoAddr, Announcement> = HashMap::new(); let mut by_repo: HashMap<RepoAddr, Announcement> = HashMap::new();
for event in events { for event in events {
@@ -242,8 +235,9 @@ impl RepoListStore {
let mut announcements: Vec<Announcement> = by_repo.into_values().collect(); let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at)); announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at));
// Last activity per repository: state updates plus all NIP-34 // Last activity per repository.
// activity events (patches, PRs, issues, statuses). // 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 let mut last_activity: HashMap<RepoAddr, Timestamp> = announcements
.iter() .iter()
.map(|a| (a.addr(), a.created_at)) .map(|a| (a.addr(), a.created_at))
@@ -264,8 +258,8 @@ impl RepoListStore {
*entry = (*entry).max(event.created_at); *entry = (*entry).max(event.created_at);
} }
// Bound the activity query to a recent window; older repos fall // Bound the activity query to a recent window.
// back to their announcement / state timestamps. // Older repos fall back to their announcement or state timestamps.
let activity_filter = Filter::new() let activity_filter = Filter::new()
.kinds(filters::ACTIVITY_KINDS) .kinds(filters::ACTIVITY_KINDS)
.since(Timestamp::now() - ACTIVITY_WINDOW); .since(Timestamp::now() - ACTIVITY_WINDOW);
@@ -277,8 +271,8 @@ impl RepoListStore {
if addr.kind != Kind::GitRepoAnnouncement { if addr.kind != Kind::GitRepoAnnouncement {
continue; continue;
} }
// Skip events for repos we don't list, so the map can't // Skip events for repos we do not list.
// grow beyond the number of announcements. // The map cannot grow beyond the number of announcements.
let Some(entry) = last_activity.get_mut(&addr) else { let Some(entry) = last_activity.get_mut(&addr) else {
continue; continue;
}; };
@@ -286,9 +280,8 @@ impl RepoListStore {
} }
} }
// Popularity counts per repository (issues, pull requests and // Popularity counts per repository, issues, pull requests and patches.
// patches). Unbounded, unlike the windowed activity query // Unbounded, unlike the windowed activity query above, so totals are exact.
// above, so totals are exact.
let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new(); let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new();
let count_filter = let count_filter =
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]); Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
@@ -297,8 +290,8 @@ impl RepoListStore {
continue; continue;
} }
for addr in event.tags.coordinates() { for addr in event.tags.coordinates() {
// Skip events for repos we don't list, so the map can't // Skip events for repos we do not list.
// grow beyond the number of announcements. // The map cannot grow beyond the number of announcements.
if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr) if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr)
{ {
continue; continue;
@@ -319,7 +312,7 @@ impl RepoListStore {
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let (announcements, last_activity, counts) = match work.await { let (announcements, last_activity, counts) = match work.await {
Ok(results) => results, Ok(results) => results,
// Database errors are transient; keep the last list. // Database errors are transient, keep the last list.
Err(_) => { Err(_) => {
return this.update(cx, |this, _cx| { return this.update(cx, |this, _cx| {
this.refreshing = false; this.refreshing = false;
@@ -342,8 +335,8 @@ impl RepoListStore {
} }
})?; })?;
// Requests that arrived while the refresh was running are // Requests that arrived while the refresh was running.
// coalesced into one follow-up refresh. // They are coalesced into one follow-up refresh.
if again { if again {
this.update(cx, |this, cx| this.refresh(cx))?; 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::menu::PopupMenuItem;
use gpui_component::{ActiveTheme, StyledExt, h_flex}; use gpui_component::{ActiveTheme, StyledExt, h_flex};
/// A muted command row with a copy button: the value in a mono-friendly, /// A muted command row with a copy button.
/// truncated line, with a [`Clipboard`] button copying the full value.
pub fn copy_row<E>(copy_id: E, command: &SharedString, cx: &App) -> Div pub fn copy_row<E>(copy_id: E, command: &SharedString, cx: &App) -> Div
where where
E: Into<ElementId>, E: Into<ElementId>,
@@ -34,10 +33,7 @@ where
) )
} }
/// One row of a copy menu: a small title above the compact label, with /// One row of a copy menu, with a small title above the compact label.
/// 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.
pub fn menu_copy_row( pub fn menu_copy_row(
id: &'static str, id: &'static str,
title: &'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::menu::PopupMenu;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex}; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex};
/// A split dropdown button built on `gpui_base::Popover`: an action element /// A split dropdown button built on `gpui_base::Popover`.
/// with a separate caret trigger that opens a [`PopupMenu`]. /// 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.
/// The action and the caret are caller-supplied elements, so the look stays /// This component only owns the popover wiring.
/// in the application; this component only owns the popover wiring.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct DropdownButton { pub struct DropdownButton {
id: ElementId, id: ElementId,
@@ -38,15 +37,16 @@ impl DropdownButton {
} }
} }
/// The action half of the button. It keeps its own icon, label, tooltip /// The action half of the button.
/// and click handler. /// It keeps its own icon, label, tooltip and click handler.
pub fn action(mut self, action: impl IntoElement + 'static) -> Self { pub fn action(mut self, action: impl IntoElement + 'static) -> Self {
self.action = Some(action.into_any_element()); self.action = Some(action.into_any_element());
self self
} }
/// The menu built by `builder` — the same signature as gpui-component's /// The menu built by `builder`.
/// `DropdownButton::dropdown_menu`, so existing menu code keeps working. /// Matches gpui-component's `DropdownButton::dropdown_menu` signature.
/// Existing menu code keeps working.
pub fn dropdown_menu( pub fn dropdown_menu(
mut self, mut self,
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static, builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
@@ -55,10 +55,9 @@ impl DropdownButton {
self self
} }
/// Which corner of the caret the menu anchors to. Defaults to /// Which corner of the caret the menu anchors to.
/// [`Anchor::TopRight`], so the menu's right edge lines up with the /// Defaults to [`Anchor::TopRight`], lining the menu's right edge up with the caret's.
/// caret's. #[allow(dead_code)] // API knob, current call sites use the default anchor.
#[allow(dead_code)] // API knob; current call sites use the default anchor.
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self { pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into(); self.anchor = anchor.into();
self self
@@ -71,8 +70,8 @@ impl Styled for DropdownButton {
} }
} }
/// Holds the [`PopupMenu`] entity of one popover between renders. Dismissal /// Holds the [`PopupMenu`] entity of one popover between renders.
/// drops it, so the menu is rebuilt with fresh items on the next open. /// Dismissal drops it, so the menu is rebuilt with fresh items on the next open.
#[derive(Default)] #[derive(Default)]
struct DropdownMenuState { struct DropdownMenuState {
menu: Option<Entity<PopupMenu>>, menu: Option<Entity<PopupMenu>>,
@@ -85,7 +84,8 @@ impl RenderOnce for DropdownButton {
"a DropdownButton needs a `dropdown_menu`" "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 popover_id = SharedString::from(format!("{}-popover", self.id));
let anchor = self.anchor; let anchor = self.anchor;
let menu_state = let menu_state =
@@ -109,8 +109,8 @@ impl RenderOnce for DropdownButton {
this.child( this.child(
Popover::new(popover_id) Popover::new(popover_id)
.anchor(anchor) .anchor(anchor)
// The menu dismisses itself on outside click or Escape; // The menu dismisses itself on outside click or Escape.
// the subscription below closes the popover along with it. // The subscription below closes the popover along with it.
.overlay_closable(false) .overlay_closable(false)
.trigger_with(caret) .trigger_with(caret)
.content( .content(
@@ -148,8 +148,8 @@ impl RenderOnce for DropdownButton {
} }
} }
/// The default caret: a chevron button the height of a medium button, tinted /// The default caret, a chevron button the height of a medium button.
/// by the theme, with hover and menu-open states. /// It is tinted by the theme and styled for hover and menu-open states.
fn default_caret(id: impl Into<ElementId>, cx: &App) -> BaseButton { fn default_caret(id: impl Into<ElementId>, cx: &App) -> BaseButton {
BaseButton::new(id) BaseButton::new(id)
.h(px(32.)) .h(px(32.))
+2 -2
View File
@@ -7,8 +7,8 @@ use gpui::{
ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash, ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash,
}; };
/// Default number of images each view's cache retains. Loading a new image /// Default number of images each view's cache retains.
/// evicts the least recently used entry once this is reached. /// Loading a new image evicts the least recently used entry once this is reached.
pub const MAX_IMAGES: usize = 128; pub const MAX_IMAGES: usize = 128;
pub fn image_cache(id: impl Into<ElementId>, max_items: usize) -> AppImageCacheProvider { 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 dropdown_button;
mod nav_item; mod nav_item;
mod pixel_avatar; 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::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, div};
use gpui_component::{ActiveTheme, StyledExt, h_flex}; use gpui_component::{ActiveTheme, StyledExt, h_flex};
/// A single navigation entry in a sidebar: an arbitrary leading element /// A single navigation entry in a sidebar.
/// (an icon, avatar, ...) and a text label with a hover highlight, /// It has an arbitrary leading element, such as an icon or avatar, and a text label.
/// an optional trailing suffix (e.g. a status icon) and an optional click handler. /// Hover highlights the row.
/// It can carry a trailing suffix, such as a status icon, and an optional click handler.
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct NavItem { pub struct NavItem {
@@ -12,7 +13,7 @@ pub struct NavItem {
style: StyleRefinement, style: StyleRefinement,
icon: gpui::AnyElement, icon: gpui::AnyElement,
label: SharedString, 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>, suffix: Option<gpui::AnyElement>,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>, 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; const FILL_PROBABILITY: f32 = 0.42;
/// Probability that a filled cell uses the accent shade instead of the main color. /// Probability that a filled cell uses the accent shade instead of the main color.
const ACCENT_PROBABILITY: f32 = 0.25; const ACCENT_PROBABILITY: f32 = 0.25;
/// Minimum number of filled left-half cells, so a sparse roll still yields a /// Minimum number of filled left-half cells.
/// recognizable shape (each left-half cell is mirrored to a right-half one). /// A sparse roll still yields a recognizable shape.
/// Each left-half cell is mirrored to a right-half one.
const MIN_FILLED: usize = 5; const MIN_FILLED: usize = 5;
/// A deterministic, offline "pixel art" avatar: an 8×8 grid with horizontal /// A deterministic, offline pixel-art avatar.
/// mirror symmetry, seeded from a stable string such as the repository id and /// An 8×8 grid with horizontal mirror symmetry.
/// owner public key. The same seed always renders the same avatar. /// Seeded from a stable string such as the repository id and owner public key.
/// The same seed always renders the same avatar.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct PixelAvatar { pub struct PixelAvatar {
seed: u64, seed: u64,
@@ -24,8 +26,8 @@ pub struct PixelAvatar {
} }
impl PixelAvatar { impl PixelAvatar {
/// Create an avatar seeded from `seed`. The seed should be a stable string /// Create an avatar seeded from `seed`.
/// unique to the entity the avatar represents. /// The seed should be a stable string unique to the entity the avatar represents.
pub fn new(seed: impl AsRef<str>) -> Self { pub fn new(seed: impl AsRef<str>) -> Self {
Self { Self {
seed: fnv1a(seed.as_ref().as_bytes()), 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` /// Generate the 8×8 cell pattern for `seed`.
/// (main color) or `2` (accent shade); the right half mirrors the left half. /// 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] { fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
let mut rng = PixelRng::new(seed); let mut rng = PixelRng::new(seed);
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE]; 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 // Sparse rolls can come out nearly empty.
// minimum fill, scanning from a seeded starting cell. // Top the pattern up to the minimum fill, scanning from a seeded starting cell.
if filled < MIN_FILLED { if filled < MIN_FILLED {
let half = GRID_SIZE * GRID_SIZE / 2; let half = GRID_SIZE * GRID_SIZE / 2;
let start = (rng.next() % half as u64) as usize; 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; 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 { fn fnv1a(bytes: &[u8]) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325u64; let mut hash = 0xcbf2_9ce4_8422_2325u64;
for &byte in bytes { 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_base::{Button as BaseButton, StyledExt};
use gpui_component::ActiveTheme; use gpui_component::ActiveTheme;
/// A small count badge shown after a label, e.g. on a segmented filter /// A small count badge shown after a label.
/// button ("All 12") or a tab. Rendered from theme tokens; sized for the /// Used on segmented filter buttons, like `All 12`, and on tabs.
/// compact header buttons it lives on. /// Rendered from theme tokens and sized for the compact header buttons it lives on.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct CountBadge { pub struct CountBadge {
count: usize, count: usize,
@@ -46,12 +46,11 @@ impl RenderOnce for CountBadge {
} }
} }
/// A segmented filter/tab button: an icon, a label, an optional [`CountBadge`] /// A segmented filter/tab button with an icon, a label and an optional [`CountBadge`].
/// and a selected (pressed) state, styled from the theme's button tokens. /// 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.
/// Built on the unstyled `gpui_base::Button`, like the app's other custom /// The `primary` variant uses the primary button tokens.
/// controls; the `primary` variant uses the primary button tokens for /// It suits call-to-action buttons such as `New issue` and `New PR`.
/// call-to-action buttons ("New issue", "New PR").
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct SegmentButton { pub struct SegmentButton {
@@ -101,7 +100,7 @@ impl SegmentButton {
self 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 { pub fn primary(mut self) -> Self {
self.primary = true; self.primary = true;
self 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::prelude::*;
use gpui::{App, SharedString, div}; use gpui::{App, SharedString, div};
use gpui_component::searchable_list::SearchableListItem; use gpui_component::searchable_list::SearchableListItem;
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex}; use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
/// A dropdown option with a display label and a stored value. /// A dropdown option with a display label and a stored value.
/// /// The trigger and menu render the `label`.
/// Renders the `label` in the trigger and the menu, while `value` is what a /// The `value` is what [`gpui_component::select::SelectState`] reports as the selection.
/// [`gpui_component::select::SelectState`] reports as the selection.
#[derive(Clone)] #[derive(Clone)]
pub struct SelectOption { pub struct SelectOption {
value: SharedString, 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( pub fn setting_row(
cx: &App, cx: &App,
title: impl Into<SharedString>, title: impl Into<SharedString>,
@@ -85,8 +81,8 @@ pub fn setting_row(
) )
} }
/// A full-width settings block: title + subtitle in one header, `gap_3` /// A full-width settings block with title and subtitle in one header.
/// between the header and the control below. /// `gap_3` separates the header from the control below.
pub fn setting_block( pub fn setting_block(
cx: &App, cx: &App,
title: impl Into<SharedString>, 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 gpui_component::{ActiveTheme, Icon, Sizable, v_flex};
use signed_core::RepoStatus; use signed_core::RepoStatus;
/// The status badge shown next to an issue or pull request: icon + colored square, /// The status badge shown next to an issue or pull request.
/// with a tooltip describing the status. /// It has an icon and a colored square, with a tooltip describing the status.
pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement { pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
let (icon, label, tooltip, bg, fg) = match status { let (icon, label, tooltip, bg, fg) = match status {
RepoStatus::Open => ( RepoStatus::Open => (
+6 -6
View File
@@ -8,12 +8,12 @@ struct WindowDragState {
should_move: bool, should_move: bool,
} }
/// Make an element behave like a window title bar: dragging it moves the /// Make an element behave like a window title bar.
/// window, and double-clicking zooms the window (or performs the platform's /// Dragging it moves the window.
/// default title-bar double-click action on macOS). /// 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 /// Only the bar's non-interactive areas should get this.
/// (to reorder panels) and must not move the window. /// Tabs are draggable to reorder panels and must not move the window.
pub fn title_bar_drag_handlers( pub fn title_bar_drag_handlers(
this: Stateful<Div>, this: Stateful<Div>,
window: &mut Window, window: &mut Window,
+3 -2
View File
@@ -4,8 +4,9 @@ use gpui_component::list::ListItem;
use gpui_component::tree::TreeEntry; use gpui_component::tree::TreeEntry;
use gpui_component::{Icon, IconName, Sizable, h_flex}; use gpui_component::{Icon, IconName, Sizable, h_flex};
/// One row of a file tree: icon + name, indented by depth. /// One row of a file tree, an icon and a name indented by depth.
/// Clicking a file runs `on_click`; folders expand/collapse via the tree itself. /// 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 pub fn tree_row<F>(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem
where where
F: Fn(&mut Window, &mut App) + 'static, 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::avatar::Avatar;
use gpui_component::{ActiveTheme, Sizable, StyledExt}; use gpui_component::{ActiveTheme, Sizable, StyledExt};
/// A user avatar: the gpui-component [`Avatar`] sized small and rounded with /// A small user avatar from gpui-component [`Avatar`], rounded with the theme radius.
/// the theme radius, showing the user's picture or a name-initials fallback. /// It shows the user's picture or falls back to name initials.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct UserAvatar { pub struct UserAvatar {
name: SharedString, name: SharedString,
@@ -13,8 +13,8 @@ pub struct UserAvatar {
} }
impl UserAvatar { impl UserAvatar {
/// Create an avatar for `name`; the name seeds the initials fallback /// Create an avatar for `name`.
/// shown when no picture is set. /// The name seeds the initials fallback shown when no picture is set.
pub fn new(name: impl Into<SharedString>) -> Self { pub fn new(name: impl Into<SharedString>) -> Self {
Self { Self {
name: name.into(), name: name.into(),
+4 -3
View File
@@ -1,5 +1,6 @@
/// `[head chars]...[tail chars]` middle truncation; the value is left alone /// `[head chars]...[tail chars]` middle truncation.
/// when it is too short for the ellipsis to save space. ///
/// Values too short for the ellipsis to save space are left alone.
pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String { pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String {
let len = value.chars().count(); let len = value.chars().count();
if len <= head + tail + 3 { if len <= head + tail + 3 {
@@ -32,7 +33,7 @@ mod tests {
), ),
"30617:a008...3564d:ngit" "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"); assert_eq!(middle_truncate("short", 10, 10), "short");
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
use nostr::prelude::*; 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 { pub fn relative_time(timestamp: Timestamp) -> String {
let now = Timestamp::now().as_secs(); let now = Timestamp::now().as_secs();
let secs = now.saturating_sub(timestamp.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 { pub fn relative_time_secs(secs: i64) -> String {
relative_time(Timestamp::from_secs(secs.max(0) as u64)) relative_time(Timestamp::from_secs(secs.max(0) as u64))
} }
@@ -7,8 +7,7 @@ use signed_core::Announcement;
use signed_state::ProfileStore; use signed_state::ProfileStore;
use signed_ui::{UserAvatar, middle_truncate}; use signed_ui::{UserAvatar, middle_truncate};
/// Open the "About" dialog: every field of the repository's announcement /// Open the About dialog showing every field of the announcement event.
/// event (NIP-34, kind 30617), as parsed into [`Announcement`].
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) { pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
window.open_dialog(cx, move |dialog, _window, cx| { window.open_dialog(cx, move |dialog, _window, cx| {
let announcement = announcement.clone(); 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 /// The announcement's fields as labeled rows.
/// button, multi-value tags one line per value.
fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement { fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
let mut rows: Vec<AnyElement> = Vec::new(); 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() 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 { fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
v_flex() v_flex()
.gap_1() .gap_1()
@@ -159,8 +157,10 @@ fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
.into_any_element() .into_any_element()
} }
/// One row per maintainer: avatar and display name (falling back to a /// One row per maintainer with avatar and display name.
/// shortened npub), with a copy button for the full pubkey. /// The display name falls back to a shortened npub.
///
/// A copy button copies the full pubkey.
fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement { fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
let profile_store = ProfileStore::global(cx); let profile_store = ProfileStore::global(cx);
v_flex() v_flex()
@@ -190,8 +190,9 @@ fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
.into_any_element() .into_any_element()
} }
/// One row per item of a multi-value tag: the value is truncated to a single /// One row per item of a multi-value tag.
/// line, with a copy button that copies the full value. ///
/// 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 { fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) -> AnyElement {
v_flex() v_flex()
.gap_2() .gap_2()
@@ -16,8 +16,9 @@ use super::helpers::{code_language, is_markdown_path};
const TREE_WIDTH: f32 = 240.; const TREE_WIDTH: f32 = 240.;
/// Files larger than this are not previewed. /// Files larger than this are not previewed.
pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024; pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
/// Preview cache caps: at most this many files (or this many text bytes) /// Preview cache caps, a file count and a text byte count.
/// are kept in memory at once; the oldest previews are evicted beyond that. ///
/// The oldest previews are evicted beyond the caps.
pub(super) const MAX_PREVIEWED_FILES: usize = 32; pub(super) const MAX_PREVIEWED_FILES: usize = 32;
pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024; 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`]. /// 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 { 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) path: Option<SharedString>,
pub(super) state: Entity<TextViewState>, pub(super) state: Entity<TextViewState>,
} }
/// A code file loaded into a persistent [`InputState`], rendered as a /// A code file loaded into a persistent [`InputState`].
/// disabled (read-only) code editor with syntax highlighting, line numbers
/// and search. Persistent for the same reason as [`MarkdownView`].
pub(super) struct CodeView { pub(super) struct CodeView {
/// Source path, relative to the worktree root. /// Source path, relative to the worktree root.
pub(super) path: SharedString, pub(super) path: SharedString,
@@ -64,7 +59,7 @@ fn preview_spinner() -> AnyElement {
} }
impl RepoDetailView { 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( fn render_tree_item(
ix: usize, ix: usize,
entry: &TreeEntry, 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( pub(super) fn render_tree_column(
tree_state: Entity<TreeState>, tree_state: Entity<TreeState>,
view: WeakEntity<Self>, 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( pub(super) fn render_content_column(
&self, &self,
pane_title: SharedString, pane_title: SharedString,
@@ -159,9 +154,8 @@ impl RepoDetailView {
placeholder("No README found", cx) placeholder("No README found", cx)
}; };
// Latest commit for the current pane: the selected file, or the README // Latest commit for the current pane, the selected file or the README.
// while nothing is selected. Computed after the body above, which // Computed after the body above, which needs `&mut self`.
// needs `&mut self`.
let commit = match &self.selected_file { let commit = match &self.selected_file {
Some(path) => self.commits.get(path.as_ref()), Some(path) => self.commits.get(path.as_ref()),
None => self None => self
@@ -217,9 +211,6 @@ impl RepoDetailView {
} }
/// Load `text` into the persistent markdown TextView state. /// 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( pub(super) fn set_markdown(
&mut self, &mut self,
path: Option<SharedString>, path: Option<SharedString>,
@@ -231,16 +222,19 @@ impl RepoDetailView {
self.md = Some(MarkdownView { path, state }); self.md = Some(MarkdownView { path, state });
} }
/// The persistent markdown TextView for `path` (`None` = README), or a /// The persistent markdown TextView for `path`, where `None` is the README.
/// spinner while the document is being loaded/parsed. ///
/// Shows a spinner while the document is being loaded or parsed.
fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement { fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement {
let Some(md) = &self.md else { let Some(md) = &self.md else {
return preview_spinner(); return preview_spinner();
}; };
let ready = match path { let ready = match path {
Some(path) => md.path.as_deref() == Some(path), Some(path) => md.path.as_deref() == Some(path),
None => md.path.is_none(), None => md.path.is_none(),
}; };
if !ready { if !ready {
return preview_spinner(); return preview_spinner();
} }
@@ -255,9 +249,7 @@ impl RepoDetailView {
/// Load `text` into the persistent code editor state for `path`. /// Load `text` into the persistent code editor state for `path`.
/// ///
/// The state is created in code editor mode so the Input renders it as /// Code editor mode makes the Input render it read-only and highlighted.
/// a syntax-highlighted, read-only editor; the tree-sitter parse runs
/// on a background task like [`set_markdown`]'s.
pub(super) fn set_code( pub(super) fn set_code(
&mut self, &mut self,
path: SharedString, path: SharedString,
@@ -276,8 +268,7 @@ impl RepoDetailView {
self.code = Some(CodeView { path, state }); self.code = Some(CodeView { path, state });
} }
/// The persistent code editor for `path`, or a spinner while the file is /// The persistent code editor for `path`, or a spinner while the file loads or parses.
/// being loaded/parsed.
fn code_element(&self, path: &str, _cx: &mut Context<Self>) -> AnyElement { fn code_element(&self, path: &str, _cx: &mut Context<Self>) -> AnyElement {
let Some(code) = &self.code else { let Some(code) = &self.code else {
return preview_spinner(); return preview_spinner();
@@ -70,8 +70,6 @@ pub(super) fn commit_row(
} }
impl RepoDetailView { 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 { pub(super) fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(list) = self.all_commits.as_ref() else { let Some(list) = self.all_commits.as_ref() else {
return if self.loading_all_commits { return if self.loading_all_commits {
@@ -90,9 +88,9 @@ impl RepoDetailView {
return placeholder("No commits found", cx); return placeholder("No commits found", cx);
} }
// Copy only the values the element tree needs; the list itself is // Copy only the values the element tree needs.
// borrowed inside the renderer below instead of being cloned per // The list is borrowed by the renderer below instead of cloned per frame.
// frame (a full history can be tens of thousands of commits). // A full history can be tens of thousands of commits.
let view = cx.entity().clone(); let view = cx.entity().clone();
let sizes = self.item_sizes.clone(); let sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone(); let scroll_handle = self.scroll_handle.clone();
@@ -137,7 +135,8 @@ impl RepoDetailView {
.size_full(), .size_full(),
) )
.when(shown < total, |this| { .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( this.child(
div() div()
.py_2() .py_2()
+18 -28
View File
@@ -28,19 +28,15 @@ use super::helpers::{
/// Width of the changed-files column. /// Width of the changed-files column.
const TREE_WIDTH: f32 = 260.; const TREE_WIDTH: f32 = 260.;
/// The tree + per-file diff body shared by the commit diff panel and the /// Tree and per-file diff body, shared by the commit diff and compare views.
/// 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`].
pub struct DiffPane { pub struct DiffPane {
/// Loaded diff; `None` until [`Self::set_diff`] is called. /// Loaded diff, `None` until [`Self::set_diff`] is called.
diff: Option<CommitDiff>, diff: Option<CommitDiff>,
/// Changed-files explorer state. /// Changed-files explorer state.
tree_state: Entity<TreeState>, tree_state: Entity<TreeState>,
/// Path of the file whose diff is shown in the detail column. /// Path of the file whose diff is shown in the detail column.
selected_file: Option<SharedString>, selected_file: Option<SharedString>,
/// Rows of the selected file's diff (hunk headers + lines), backing the /// Rows of the selected file's diff, hunk headers and lines.
/// virtual list in the detail column.
rows: Vec<DiffRow>, rows: Vec<DiffRow>,
/// Per-row heights of [`Self::rows`]. /// Per-row heights of [`Self::rows`].
item_sizes: Rc<Vec<Size<Pixels>>>, item_sizes: Rc<Vec<Size<Pixels>>>,
@@ -90,8 +86,9 @@ impl DiffPane {
} }
} }
/// Forget the diff (e.g. when the compared branches changed): clear the /// Forget the diff, e.g. when the compared branches changed.
/// tree, the selection and the diff rows. ///
/// Clears the tree, the selection and the diff rows.
pub fn clear(&mut self, cx: &mut Context<Self>) { pub fn clear(&mut self, cx: &mut Context<Self>) {
self.diff = None; self.diff = None;
self.selected_file = 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>) { fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
self.selected_file = Some(path.into()); self.selected_file = Some(path.into());
self.set_diff_rows(path); self.set_diff_rows(path);
cx.notify(); cx.notify();
} }
/// Rebuild the virtual list state for the file at `path` and scroll back /// Rebuild the virtual list state for `path` and scroll back to the top.
/// to the top.
fn set_diff_rows(&mut self, path: &str) { fn set_diff_rows(&mut self, path: &str) {
let Some(diff) = self.diff.as_ref() else { let Some(diff) = self.diff.as_ref() else {
return; return;
@@ -123,7 +119,7 @@ impl DiffPane {
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); 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( fn render_tree_item(
ix: usize, ix: usize,
entry: &TreeEntry, 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 { fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
let tree_state = self.tree_state.clone(); let tree_state = self.tree_state.clone();
let view = cx.entity().downgrade(); let view = cx.entity().downgrade();
@@ -170,7 +166,7 @@ impl DiffPane {
.into_any_element() .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 { fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(diff) = self.diff.as_ref() else { let Some(diff) = self.diff.as_ref() else {
return placeholder("No changes", cx); return placeholder("No changes", cx);
@@ -188,8 +184,7 @@ impl DiffPane {
self.render_file_diff(file, cx.entity(), cx) self.render_file_diff(file, cx.entity(), cx)
} }
/// The diff of one file: a header with status and stats, then the hunks /// The diff of one file, with a header showing status and stats.
/// 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 { fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
let status_label = match file.status { let status_label = match file.status {
DiffStatus::Added => "A", DiffStatus::Added => "A",
@@ -316,25 +311,21 @@ impl Render for DiffPane {
} }
} }
/// Detail panel showing the diff of one commit: a metadata header plus the /// Detail panel showing the diff of one commit.
/// shared [`DiffPane`] body.
pub struct CommitDiffView { pub struct CommitDiffView {
focus_handle: FocusHandle, focus_handle: FocusHandle,
/// Local clone the commit lives in. /// Local clone the commit lives in.
worktree: PathBuf, worktree: PathBuf,
/// Display name of the repository the commit belongs to. /// Display name of the repository the commit belongs to.
repo_name: SharedString, repo_name: SharedString,
/// The commit being shown (header and tab title). Starts as an id-only /// The commit being shown in the header and tab title.
/// stub; [`Self::load`] replaces it with the full metadata, which the
/// history list intentionally omits.
commit: FileCommit, commit: FileCommit,
/// The diff is being computed on a background task. /// The diff is being computed on a background task.
loading: bool, loading: bool,
error: Option<SharedString>, error: Option<SharedString>,
/// Changed-files explorer and per-file diff, shared with the compare /// Changed-files explorer and per-file diff, also used by the new PR panel's compare view.
/// view of the new-pull-request panel.
pane: Entity<DiffPane>, 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>>>, 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 /// Load the commit diff and the full commit metadata.
/// task and populate the tree.
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true; self.loading = true;
self.error = None; self.error = None;
@@ -419,7 +409,7 @@ impl CommitDiffView {
self.tasks.push(task); 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 { fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let commit = &self.commit; let commit = &self.commit;
let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| { 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_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff};
use signed_ui::{menu_copy_row, middle_truncate}; 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 { pub(super) struct TreeItemSeed {
/// Path of the node, relative to the worktree root. /// Path of the node, relative to the worktree root.
id: String, id: String,
@@ -22,12 +19,6 @@ pub(super) struct TreeItemSeed {
children: Vec<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> { pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem { fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
let mut item = TreeItem::new(seed.id, seed.label); 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() .collect()
} }
/// Build nested tree items from a flat, sorted (dirs-first) entry list. /// Build nested tree items from a flat entry list sorted dirs-first.
///
/// 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.
pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> { 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 index: HashMap<String, usize> = HashMap::new();
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new(); let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
let mut roots: 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. /// 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> { pub(super) fn code_language(path: &str) -> Option<&'static str> {
let name = Path::new(path) let name = Path::new(path)
.file_name() .file_name()
@@ -166,7 +149,7 @@ pub(super) fn is_markdown_path(path: &str) -> bool {
} }
pub(super) struct ShareTargets { 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, pub(super) naddr: String,
/// Hex ID of the announcement event itself. /// Hex ID of the announcement event itself.
pub(super) event_id: String, pub(super) event_id: String,
@@ -195,8 +178,9 @@ impl ShareTargets {
} }
} }
/// The share dropdown menu: one row per target, each showing a compact /// The share dropdown menu, one row per target.
/// label while the copy button (and row click) copy the full value. ///
/// Each shows a compact label, the copy button and row click copy the full value.
pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu { pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu {
menu.min_w(px(340.)) menu.min_w(px(340.))
.item(menu_copy_row( .item(menu_copy_row(
@@ -226,9 +210,7 @@ impl ShareTargets {
} }
} }
/// Shorten an naddr link to `<url>/naddr1...[last tail chars]`, e.g. /// Shorten an naddr link to `<url>/naddr1...[last tail chars]`.
/// `https://gitworkshop.dev/naddr1...abcd`. Only the label is shortened;
/// the value to be copied stays the full URL.
fn truncate_naddr_link(url: &str, tail: usize) -> String { fn truncate_naddr_link(url: &str, tail: usize) -> String {
let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else { let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else {
return url.to_string(); return url.to_string();
@@ -244,7 +226,8 @@ pub(super) const GUTTER_WIDTH: f32 = 44.;
/// Height of one row in a virtual diff list. /// Height of one row in a virtual diff list.
pub(super) const DIFF_ROW_HEIGHT: f32 = 20.; 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. /// Shared by the commit diff and pull request diff viewers.
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub(super) enum DiffRow { pub(super) enum DiffRow {
@@ -258,7 +241,7 @@ pub(super) enum DiffRow {
Line { hunk: usize, line: usize }, 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> { pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
let mut rows = Vec::new(); let mut rows = Vec::new();
for (hunk_ix, hunk) in file.hunks.iter().enumerate() { for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
@@ -276,7 +259,7 @@ pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
rows 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 { pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
match row { match row {
DiffRow::Hunk { 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, /// One diff line, old and new line numbers in the gutters.
/// tinted by kind (addition / deletion / context). ///
/// The content is tinted by kind, addition, deletion or context.
pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
let bg = match line.kind { let bg = match line.kind {
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)), 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; let gutter = cx.theme().muted_foreground;
// Fixed height and nowrap: the virtual list assumes every row has // Fixed height and nowrap, the virtual list assumes every row has the same height.
// the same height, so long lines are clipped instead of wrapped. // Long lines are clipped instead of wrapped.
h_flex() h_flex()
.w_full() .w_full()
.h(px(DIFF_ROW_HEIGHT)) .h(px(DIFF_ROW_HEIGHT))
@@ -379,7 +363,7 @@ mod tests {
let items = build_tree_items(&entries); 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.len(), 3);
assert_eq!(items[0].label, "src"); assert_eq!(items[0].label, "src");
assert_eq!(items[0].id, "src"); assert_eq!(items[0].id, "src");
@@ -411,9 +395,9 @@ mod tests {
#[test] #[test]
fn tree_builder_merges_shared_prefixes() { fn tree_builder_merges_shared_prefixes() {
// File children of a directory arrive after other directories' // File children of a directory arrive after other directories' entries.
// entries (the worktree list is dirs-first globally); the shared // The worktree list is dirs-first globally.
// prefix must still resolve to one node. // The shared prefix must still resolve to one node.
let entries = vec![ let entries = vec![
PathBuf::from("a/x.txt"), PathBuf::from("a/x.txt"),
PathBuf::from("b/y.txt"), PathBuf::from("b/y.txt"),
@@ -461,7 +445,7 @@ mod tests {
truncate_naddr_link("https://gitworkshop.dev/naddr1qqqxyzabc1234", 4), truncate_naddr_link("https://gitworkshop.dev/naddr1qqqxyzabc1234", 4),
"https://gitworkshop.dev/naddr1...1234" "https://gitworkshop.dev/naddr1...1234"
); );
// No naddr1 prefix: unchanged. // Without the naddr1 prefix, unchanged.
assert_eq!( assert_eq!(
truncate_naddr_link("https://example.com/x", 4), truncate_naddr_link("https://example.com/x", 4),
"https://example.com/x" "https://example.com/x"
@@ -26,10 +26,6 @@ pub struct InitRepoState {
} }
/// Open the Init dialog for the local repository at `local_path`. /// 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( pub fn open(
local_path: PathBuf, local_path: PathBuf,
view: WeakEntity<RepoDetailView>, view: WeakEntity<RepoDetailView>,
@@ -40,23 +36,25 @@ pub fn open(
.file_name() .file_name()
.map(|name| name.to_string_lossy().into_owned()) .map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default(); .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 name_input = cx.new(|cx| InputState::new(window, cx).default_value(default_name));
let desc_input = cx.new(|cx| { let desc_input = cx.new(|cx| {
TextareaState::new(window, cx) TextareaState::new(window, cx)
.auto_grow(3, 5) .auto_grow(3, 5)
.placeholder("Short description") .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); load_user_grasp_servers(grasp_state.clone(), window, cx);
window.open_dialog(cx, move |dialog, _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 /// Run the init flow.
/// its NIP-34 mode on success. ///
/// Closes the dialog and switches the repository into NIP-34 mode on success.
fn init_repository( fn init_repository(
local_path: PathBuf, local_path: PathBuf,
inputs: (Entity<InputState>, Entity<TextareaState>), inputs: (Entity<InputState>, Entity<TextareaState>),
@@ -25,7 +25,7 @@ pub struct IssueDetailView {
store: Entity<RepoStore>, store: Entity<RepoStore>,
issue_id: EventId, issue_id: EventId,
contents: HashMap<EventId, SharedString>, contents: HashMap<EventId, SharedString>,
/// Input state of the "leave a comment" textarea. /// Input state of the comment textarea.
comment_input: Entity<TextareaState>, comment_input: Entity<TextareaState>,
focus_handle: FocusHandle, focus_handle: FocusHandle,
} }
@@ -58,7 +58,7 @@ impl IssueDetailView {
return div().into_any_element(); 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]; let mut participants: Vec<PublicKey> = vec![issue.pubkey];
participants.extend(store.comments_of(&issue.id).map(|comment| comment.pubkey)); participants.extend(store.comments_of(&issue.id).map(|comment| comment.pubkey));
participants.sort_by_key(PublicKey::to_hex); participants.sort_by_key(PublicKey::to_hex);
@@ -138,8 +138,7 @@ impl IssueDetailView {
let author = profile.name(); let author = profile.name();
let picture = profile.picture(); let picture = profile.picture();
let age = relative_time(comment.created_at); let age = relative_time(comment.created_at);
// Comment bodies are cloned into shared strings once per // Comment bodies become shared strings once per comment, not per render.
// comment, not on every render.
let content = self let content = self
.contents .contents
.entry(comment.id) .entry(comment.id)
@@ -24,8 +24,7 @@ use utils::relative_time;
use super::issue_detail::IssueDetailView; use super::issue_detail::IssueDetailView;
/// Height of one issue row in the virtual list: `py_2` padding, a 32px /// Height of one issue row in the virtual list.
/// title line (`h_8`), a 24px meta line (`h_6`) and the 1px bottom border.
const ISSUE_ROW_HEIGHT: f32 = 73.; const ISSUE_ROW_HEIGHT: f32 = 73.;
/// Status filter of the issues list, chosen via the header's filter buttons. /// Status filter of the issues list, chosen via the header's filter buttons.
@@ -35,8 +34,7 @@ enum IssueFilter {
All, All,
/// Issues whose resolved status is [`RepoStatus::Open`]. /// Issues whose resolved status is [`RepoStatus::Open`].
Open, Open,
/// Issues whose resolved status is [`RepoStatus::Closed`] or /// Issues whose resolved status is [`RepoStatus::Closed`].
/// [`RepoStatus::Applied`] (both are "done" states).
Closed, Closed,
} }
@@ -63,14 +61,11 @@ pub struct IssuesView {
filter: IssueFilter, filter: IssueFilter,
/// Per-row heights of the virtual list. /// Per-row heights of the virtual list.
item_sizes: Rc<Vec<Size<Pixels>>>, 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, issue_len: usize,
/// Indices into the store's `issues` matching [`Self::filter`]; the /// Indices into the store's `issues` matching [`Self::filter`].
/// virtual list renders this slice. Rebuilt only when the store
/// version or the filter changes, keyed by [`Self::cache_key`].
visible_issues: Vec<usize>, visible_issues: Vec<usize>,
/// Header counts `(total, open, closed)`, rebuilt with /// Header counts `(total, open, closed)`, rebuilt with [`Self::visible_issues`].
/// [`Self::visible_issues`].
counts: (usize, usize, usize), counts: (usize, usize, usize),
/// Store version and filter the cached rows/counts were built from. /// Store version and filter the cached rows/counts were built from.
cache_key: Option<(u64, IssueFilter)>, cache_key: Option<(u64, IssueFilter)>,
@@ -82,10 +77,11 @@ impl IssuesView {
pub fn new( pub fn new(
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>, store: Entity<RepoStore>,
repo_name: SharedString,
_window: &mut Window, _window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
let repo_name = store.read(cx).name();
Self { Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
dock_area, 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( fn open_issue_detail(
&mut self, &mut self,
issue_id: EventId, 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 { fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context<Self>) -> AnyElement {
let issue = &self.store.read(cx).issues[issue_ix]; let issue = &self.store.read(cx).issues[issue_ix];
let title = activity_subject(issue); let title = activity_subject(issue);
@@ -183,8 +177,7 @@ impl IssuesView {
} }
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement { fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
// Counts of the last list rebuild (`render` rebuilds first when the // Counts of the last list rebuild.
// store version or filter changed, so this is never stale).
let (total, open, closed) = self.counts; let (total, open, closed) = self.counts;
h_flex() h_flex()
@@ -242,8 +235,7 @@ impl IssuesView {
} }
} }
/// Open the "new issue" dialog: a title and a content input that submit /// Open the new issue dialog, a title and a content input.
/// through [`RepoStore::open_issue`] when confirmed.
pub(super) fn open_new_issue_dialog(store: Entity<RepoStore>, window: &mut Window, cx: &mut App) { 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 subject = cx.new(|cx| InputState::new(window, cx).placeholder("Issue title"));
let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue...")); 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 { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let filter = self.filter; let filter = self.filter;
// Rebuild the filtered rows and header counts only when the store // Rows and counts are rebuilt only when the store refreshed or filter changed.
// refreshed or the filter changed; other renders reuse the cache.
let version = self.store.read(cx).version(); let version = self.store.read(cx).version();
if self.cache_key != Some((version, filter)) { if self.cache_key != Some((version, filter)) {
let store = self.store.read(cx); let store = self.store.read(cx);
let mut counts = (0usize, 0usize, 0usize); let mut counts = (0usize, 0usize, 0usize);
@@ -359,8 +351,8 @@ impl Render for IssuesView {
let count = self.visible_issues.len(); let count = self.visible_issues.len();
// The virtual list's item count comes from `item_sizes`; rebuild it // The virtual list's item count comes from `item_sizes`.
// whenever the filtered issue count changes. // Rebuild it whenever the filtered issue count changes.
if count != self.issue_len { if count != self.issue_len {
self.issue_len = count; self.issue_len = count;
self.item_sizes = Rc::new(vec![size(px(0.), px(ISSUE_ROW_HEIGHT)); 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::prelude::*;
use gpui::{ use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
ScrollStrategy, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, relative, ScrollStrategy, SharedString, Size, Task, WeakEntity, Window, div, px, relative, size,
size,
}; };
use gpui_component::button::{Button, ButtonVariants}; use gpui_component::button::{Button, ButtonVariants};
use gpui_component::clipboard::Clipboard; use gpui_component::clipboard::Clipboard;
@@ -41,63 +40,56 @@ use super::helpers::{
/// Width of the changed-files column. /// Width of the changed-files column.
const TREE_WIDTH: f32 = 260.; const TREE_WIDTH: f32 = 260.;
/// Height of one commit row in the commits tab's virtual list: a single /// Height of one commit row in the commits tab's virtual list.
/// text line plus the 1px bottom border. const ROW_HEIGHT: f32 = 37.;
const PR_COMMIT_ROW_HEIGHT: f32 = 37.;
/// Detail panel of a single pull request. /// Detail panel of a single pull request.
pub struct PullRequestDetailView { pub struct PullRequestDetailView {
focus_handle: FocusHandle, 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>, dock_area: WeakEntity<DockArea>,
/// Repo store holding the PR, its status and comments. /// Repo store holding the PR, its status and comments.
store: Entity<RepoStore>, 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, pr_id: EventId,
/// Input state of the "leave a comment" textarea. /// Input state of the comment textarea.
comment_input: Entity<TextareaState>, comment_input: Entity<TextareaState>,
/// Display name of the repository, for panels opened from here. /// Display name of the repository, for panels opened from here.
repo_name: SharedString, repo_name: SharedString,
/// Local clone the PR's git changes come from; `None` while the diff is /// Local clone the PR's git changes come from.
/// parsed from the nostr patch set (no commit diff viewer then).
worktree: Option<PathBuf>, worktree: Option<PathBuf>,
/// Root PR's content, shown as plain text. /// Root PR's content, shown as plain text.
description: SharedString, 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>, 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>, 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>, diff: Option<CommitDiff>,
/// The patch is being parsed on a background task. /// The patch is being parsed on a background task.
loading: bool, loading: bool,
error: Option<SharedString>, error: Option<SharedString>,
/// Active header tab: 0 = Discussion, 1 = Files, 2 = Commits. /// Active header tab, 0 = Discussion, 1 = Files, 2 = Commits.
active_tab: usize, active_tab: usize,
/// Changed-files explorer state. /// Changed-files explorer state.
tree_state: Entity<TreeState>, tree_state: Entity<TreeState>,
/// Path of the file whose diff is shown in the detail column. /// Path of the file whose diff is shown in the detail column.
selected_file: Option<SharedString>, 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>, rows: Vec<DiffRow>,
/// Per-row heights of [`Self::rows`]. /// Per-row heights of [`Self::rows`].
item_sizes: Rc<Vec<Size<Pixels>>>, item_sizes: Rc<Vec<Size<Pixels>>>,
/// Virtual list state of the diff rows. /// Virtual list state of the diff rows.
scroll_handle: VirtualListScrollHandle, scroll_handle: VirtualListScrollHandle,
/// Per-row heights of the commits tab's virtual list, built when the /// Per-row heights of the commits tab's virtual list, built when the patch series loads.
/// patch series is loaded.
commit_item_sizes: Rc<Vec<Size<Pixels>>>, commit_item_sizes: Rc<Vec<Size<Pixels>>>,
/// Virtual list state of the commits tab. /// Virtual list state of the commits tab.
commit_scroll_handle: VirtualListScrollHandle, commit_scroll_handle: VirtualListScrollHandle,
/// Comment bodies as shared strings, keyed by comment event ID, so /// Comment bodies as shared strings, keyed by comment event ID.
/// re-renders don't clone full contents again (events are immutable,
/// so the cache never needs invalidation).
contents: HashMap<EventId, SharedString>, contents: HashMap<EventId, SharedString>,
/// In-flight tasks; finished tasks are pruned on every push, so the vec /// In-flight tasks, finished tasks are pruned on every push.
/// stays bounded by the number of concurrent loads.
tasks: Vec<Task<Result<(), anyhow::Error>>>, tasks: Vec<Task<Result<(), anyhow::Error>>>,
/// Subscriptions keeping the view live as the store refreshes.
_subscriptions: Vec<Subscription>,
} }
impl PullRequestDetailView { impl PullRequestDetailView {
@@ -108,26 +100,12 @@ impl PullRequestDetailView {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
let repo_name = store.read(cx).name();
let tree_state = cx.new(|cx| TreeState::new(cx)); let tree_state = cx.new(|cx| TreeState::new(cx));
let comment_input = let comment_input =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment...")); 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. // Defer loading until the window is ready, like the commit diff view.
cx.defer_in(window, |this, window, cx| { cx.defer_in(window, |this, window, cx| {
this.load(window, cx); this.load(window, cx);
@@ -157,16 +135,10 @@ impl PullRequestDetailView {
commit_scroll_handle: VirtualListScrollHandle::new(), commit_scroll_handle: VirtualListScrollHandle::new(),
contents: HashMap::new(), contents: HashMap::new(),
tasks: Vec::new(), tasks: Vec::new(),
_subscriptions: subscriptions,
} }
} }
/// Snapshot the PR events from the store, then compute the file changes /// Snapshot the PR events from the store.
/// 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.
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true; self.loading = true;
self.error = None; self.error = None;
@@ -176,6 +148,7 @@ impl PullRequestDetailView {
let (description, patch, current_commit, merge_base, clone_urls, addr, has_patch_link) = { let (description, patch, current_commit, merge_base, clone_urls, addr, has_patch_link) = {
let store = self.store.read(cx); let store = self.store.read(cx);
let Some(root) = store let Some(root) = store
.pull_requests .pull_requests
.iter() .iter()
@@ -186,19 +159,24 @@ impl PullRequestDetailView {
cx.notify(); cx.notify();
return; return;
}; };
let update = latest_update(store.pull_requests.iter(), root); let update = latest_update(store.pull_requests.iter(), root);
let tip = update let tip = update
.and_then(current_commit_of) .and_then(current_commit_of)
.or_else(|| current_commit_of(root)); .or_else(|| current_commit_of(root));
let base = update let base = update
.and_then(merge_base_of) .and_then(merge_base_of)
.or_else(|| merge_base_of(root)); .or_else(|| merge_base_of(root));
let clone_urls = clone_urls_of(root).or_else(|| { let clone_urls = clone_urls_of(root).or_else(|| {
store store
.announcement .announcement
.as_ref() .as_ref()
.map(|a| a.clone.iter().map(ToString::to_string).collect()) .map(|a| a.clone.iter().map(ToString::to_string).collect())
}); });
( (
root.content.clone(), root.content.clone(),
pull_request_patch(root, store.patches.iter()), pull_request_patch(root, store.patches.iter()),
@@ -209,16 +187,17 @@ impl PullRequestDetailView {
root.tags.event_ids().next().is_some(), root.tags.event_ids().next().is_some(),
) )
}; };
self.description = description.into(); self.description = description.into();
let task = cx.spawn_in(window, async move |this, cx| { let task = cx.spawn_in(window, async move |this, cx| {
// Parse the nostr patch set first.
let nostr_diff = cx let nostr_diff = cx
.background_spawn({ .background_spawn({
let patch = patch.clone(); let patch = patch.clone();
async move { patch_diffs(&patch) } async move { patch_diffs(&patch) }
}) })
.await; .await;
let nostr_commits = cx let nostr_commits = cx
.background_spawn({ .background_spawn({
let patch = patch.clone(); let patch = patch.clone();
@@ -226,13 +205,13 @@ impl PullRequestDetailView {
}) })
.await; .await;
// PRs without patch events (e.g. published by ngit) carry their // PRs without patch events, e.g. published by ngit, carry their changes in git.
// changes in the git repository: fetch the clone and diff the // Fetch the clone and diff the `merge-base..tip` range.
// `merge-base..tip` range.
let use_nostr = match &nostr_diff { let use_nostr = match &nostr_diff {
Ok(diff) => has_patch_link || !diff.files.is_empty(), Ok(diff) => has_patch_link || !diff.files.is_empty(),
Err(_) => true, Err(_) => true,
}; };
let git = if use_nostr { let git = if use_nostr {
None None
} else { } else {
@@ -241,19 +220,22 @@ impl PullRequestDetailView {
let clone_urls = clone_urls.clone(); let clone_urls = clone_urls.clone();
let base = merge_base.clone(); let base = merge_base.clone();
let tip = current_commit.clone(); let tip = current_commit.clone();
Some( Some(
cx.background_spawn(async move { cx.background_spawn(async move {
let repo = cache.ensure_clone(&addr, &clone_urls)?; let repo = cache.ensure_clone(&addr, &clone_urls)?;
let workdir = repo let workdir = repo
.workdir() .workdir()
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
.to_path_buf(); .to_path_buf();
let tip = let tip =
tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?; tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
let base = match base { let base = match base {
Some(base) => base, Some(base) => base,
// No `merge-base` tag: use the merge base of the // No `merge-base` tag. Use the merge base of the tip and the default branch.
// tip with the default branch.
None => { None => {
let head = repo let head = repo
.head_id() .head_id()
@@ -262,9 +244,11 @@ impl PullRequestDetailView {
repo.merge_base(tip_id, head)?.to_string() repo.merge_base(tip_id, head)?.to_string()
} }
}; };
let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?; let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?;
let commits = let commits =
signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?; signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?;
Ok::<_, anyhow::Error>((diff, commits, workdir)) Ok::<_, anyhow::Error>((diff, commits, workdir))
}) })
.await, .await,
@@ -281,9 +265,9 @@ impl PullRequestDetailView {
this.loading = false; this.loading = false;
this.worktree = worktree; this.worktree = worktree;
this.current_commit = current_commit.map(SharedString::from); this.current_commit = current_commit.map(SharedString::from);
this.commit_item_sizes = this.commit_item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]);
Rc::new(vec![size(px(0.), px(PR_COMMIT_ROW_HEIGHT)); commits.len()]);
this.commits = commits; this.commits = commits;
match diff { match diff {
Ok(diff) => { Ok(diff) => {
let mut paths: Vec<PathBuf> = diff let mut paths: Vec<PathBuf> = diff
@@ -312,6 +296,7 @@ impl PullRequestDetailView {
this.error = Some(error.to_string().into()); this.error = Some(error.to_string().into());
} }
} }
cx.notify(); cx.notify();
})?; })?;
@@ -322,15 +307,14 @@ impl PullRequestDetailView {
self.tasks.push(task); 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>) { fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
self.selected_file = Some(path.into()); self.selected_file = Some(path.into());
self.set_diff_rows(path); self.set_diff_rows(path);
cx.notify(); cx.notify();
} }
/// Rebuild the virtual list state for the file at `path` and scroll back /// Rebuild the virtual list state for the file at `path` and scroll back to the top.
/// to the top.
fn set_diff_rows(&mut self, path: &str) { fn set_diff_rows(&mut self, path: &str) {
let Some(diff) = self.diff.as_ref() else { let Some(diff) = self.diff.as_ref() else {
return; 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( fn render_tree_item(
ix: usize, ix: usize,
entry: &TreeEntry, entry: &TreeEntry,
@@ -387,7 +371,6 @@ impl PullRequestDetailView {
}) })
} }
/// Left column: the changed-files tree.
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement { fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
let tree_state = self.tree_state.clone(); let tree_state = self.tree_state.clone();
let view = cx.entity().downgrade(); let view = cx.entity().downgrade();
@@ -417,7 +400,6 @@ impl PullRequestDetailView {
.into_any_element() .into_any_element()
} }
/// Right column: header of the selected file plus its diff.
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement { fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
if self.loading { if self.loading {
return v_flex() return v_flex()
@@ -427,12 +409,15 @@ impl PullRequestDetailView {
.child(Spinner::new().small()) .child(Spinner::new().small())
.into_any_element(); .into_any_element();
} }
if let Some(error) = self.error.clone() { if let Some(error) = self.error.clone() {
return placeholder(&error, cx); return placeholder(&error, cx);
} }
let Some(diff) = self.diff.as_ref() else { let Some(diff) = self.diff.as_ref() else {
return placeholder("Failed to load diff", cx); return placeholder("Failed to load diff", cx);
}; };
let Some(path) = self.selected_file.clone() else { let Some(path) = self.selected_file.clone() else {
return if diff.files.is_empty() { return if diff.files.is_empty() {
placeholder("No files changed in this pull request", cx) placeholder("No files changed in this pull request", cx)
@@ -440,14 +425,13 @@ impl PullRequestDetailView {
placeholder("Select a file", cx) placeholder("Select a file", cx)
}; };
}; };
let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else { let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else {
return placeholder("File not found", cx); return placeholder("File not found", cx);
}; };
self.render_file_diff(file, cx.entity(), 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 { fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
let status_label = match file.status { let status_label = match file.status {
signed_git::DiffStatus::Added => "A", signed_git::DiffStatus::Added => "A",
@@ -456,6 +440,7 @@ impl PullRequestDetailView {
signed_git::DiffStatus::Renamed => "R", signed_git::DiffStatus::Renamed => "R",
signed_git::DiffStatus::Copied => "C", signed_git::DiffStatus::Copied => "C",
}; };
let status_color = match file.status { let status_color = match file.status {
signed_git::DiffStatus::Added => cx.theme().success, signed_git::DiffStatus::Added => cx.theme().success,
signed_git::DiffStatus::Modified => cx.theme().info, signed_git::DiffStatus::Modified => cx.theme().info,
@@ -464,6 +449,7 @@ impl PullRequestDetailView {
cx.theme().muted_foreground cx.theme().muted_foreground
} }
}; };
let title = match &file.old_path { let title = match &file.old_path {
Some(old) => format!("{old} → {}", file.path), Some(old) => format!("{old} → {}", file.path),
None => file.path.clone(), None => file.path.clone(),
@@ -476,6 +462,7 @@ impl PullRequestDetailView {
} else { } else {
let sizes = self.item_sizes.clone(); let sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone(); let scroll_handle = self.scroll_handle.clone();
v_flex() v_flex()
.size_full() .size_full()
.relative() .relative()
@@ -564,7 +551,6 @@ impl PullRequestDetailView {
.into_any_element() .into_any_element()
} }
/// Underline tab bar: Discussion, Files and Commits.
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement { fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
let active = self.active_tab; let active = self.active_tab;
let files_count = self.diff.as_ref().map(|diff| diff.files.len()); let files_count = self.diff.as_ref().map(|diff| diff.files.len());
@@ -610,8 +596,6 @@ impl PullRequestDetailView {
.into_any_element() .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 { fn render_discussion(&mut self, cx: &mut Context<Self>) -> AnyElement {
if self.loading { if self.loading {
return v_flex() return v_flex()
@@ -694,7 +678,7 @@ impl PullRequestDetailView {
.into_any_element() .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 { fn render_sidebar(&self, cx: &mut Context<Self>) -> AnyElement {
let profile_store = ProfileStore::global(cx); let profile_store = ProfileStore::global(cx);
let store = self.store.read(cx); let store = self.store.read(cx);
@@ -708,7 +692,7 @@ impl PullRequestDetailView {
return div().into_any_element(); 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]; let mut participants: Vec<PublicKey> = vec![root.pubkey];
participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey)); participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey));
participants.sort_by_key(PublicKey::to_hex); participants.sort_by_key(PublicKey::to_hex);
@@ -776,8 +760,6 @@ impl PullRequestDetailView {
.into_any_element() .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 { fn render_files_tab(&self, cx: &mut Context<Self>) -> AnyElement {
h_flex() h_flex()
.flex_1() .flex_1()
@@ -789,8 +771,9 @@ impl PullRequestDetailView {
.into_any_element() .into_any_element()
} }
/// Full-height Commits tab: every commit of the patch series, or a /// Full-height Commits tab.
/// status message while loading / when there are none. ///
/// Every commit of the patch series, or a status message while loading or empty.
fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement { fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
if self.loading { if self.loading {
return v_flex() return v_flex()
@@ -838,8 +821,9 @@ impl PullRequestDetailView {
.into_any_element() .into_any_element()
} }
/// One row of the commits tab: id, summary, author and time. Clicking a /// One row of the commits tab, id, summary, author and time.
/// row opens the commit's diff in the bottom dock. ///
/// Clicking a row opens the commit's diff in the bottom dock.
fn render_commit_row( fn render_commit_row(
&self, &self,
ix: usize, ix: usize,
@@ -852,7 +836,7 @@ impl PullRequestDetailView {
h_flex() h_flex()
.id(ix) .id(ix)
.px_4() .px_4()
.h(px(PR_COMMIT_ROW_HEIGHT)) .h(px(ROW_HEIGHT))
.gap_2() .gap_2()
.items_center() .items_center()
.text_sm() .text_sm()
@@ -882,8 +866,8 @@ impl PullRequestDetailView {
.child(SharedString::from(meta)), .child(SharedString::from(meta)),
) )
}) })
// Commits parsed from the nostr patch set may not exist in any // Commits parsed from the nostr patch set may not exist in any local clone.
// local clone; only git-backed PRs open a diff viewer. // Only git-backed PRs open a diff viewer.
.when_some(self.worktree.clone(), |this, worktree| { .when_some(self.worktree.clone(), |this, worktree| {
this.on_click(cx.listener(move |this, _event, window, cx| { this.on_click(cx.listener(move |this, _event, window, cx| {
this.open_commit_diff(worktree.clone(), &id, window, cx); this.open_commit_diff(worktree.clone(), &id, window, cx);
@@ -892,8 +876,7 @@ impl PullRequestDetailView {
.into_any_element() .into_any_element()
} }
/// One comment card, same design as the issue panel: avatar, author, /// One comment card, same design as the issue panel.
/// "commented" and age on the header row, content below.
fn render_comments(&mut self, id: &EventId, cx: &mut Context<Self>) -> AnyElement { fn render_comments(&mut self, id: &EventId, cx: &mut Context<Self>) -> AnyElement {
let store = self.store.read(cx); let store = self.store.read(cx);
let comments: Vec<&Event> = store.comments_of(id).collect(); let comments: Vec<&Event> = store.comments_of(id).collect();
@@ -907,8 +890,7 @@ impl PullRequestDetailView {
let author = profile.name(); let author = profile.name();
let picture = profile.picture(); let picture = profile.picture();
let age = relative_time(comment.created_at); let age = relative_time(comment.created_at);
// Comment bodies are cloned into shared strings once per // Comment bodies become shared strings once per comment, not per render.
// comment, not on every render.
let content = self let content = self
.contents .contents
.entry(comment.id) .entry(comment.id)
@@ -1002,7 +984,7 @@ impl PullRequestDetailView {
.into_any_element() .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 { fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let current_commit = self.current_commit.clone(); let current_commit = self.current_commit.clone();
let (title, status, branch, author) = { 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 backend = Backend::global(cx);
let can_update = backend.read(cx).current_user() == Some(author); 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 /// Open the update pull request dialog.
/// revision through [`RepoStore::update_pull_request`] when confirmed.
fn open_update_pull_request_dialog( fn open_update_pull_request_dialog(
store: Entity<RepoStore>, store: Entity<RepoStore>,
root: Event, root: Event,
@@ -1115,8 +1096,8 @@ fn open_update_pull_request_dialog(
let patch = cx.new(|cx| { let patch = cx.new(|cx| {
TextareaState::new(window, cx).placeholder("Paste the updated `git format-patch` output...") TextareaState::new(window, cx).placeholder("Paste the updated `git format-patch` output...")
}); });
// Both the dialog body and the submit button capture the root event; // Both the dialog body and submit button capture the root event.
// share it instead of cloning into each closure. // Share it instead of cloning into each closure.
let root = Rc::new(root); let root = Rc::new(root);
window.open_dialog(cx, move |dialog, _window, _cx| { window.open_dialog(cx, move |dialog, _window, _cx| {
@@ -1176,7 +1157,7 @@ fn sidebar_title(text: &str, cx: &App) -> AnyElement {
.into_any_element() .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> { fn current_commit_of(event: &Event) -> Option<String> {
event event
.tags .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 /// The `merge-base` tag of a PR event, as hex.
/// target branch), as hex. ///
/// The most recent common ancestor with the target branch.
fn merge_base_of(event: &Event) -> Option<String> { fn merge_base_of(event: &Event) -> Option<String> {
event event
.tags .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 /// The `clone` tag of a PR event.
/// fetched), or `None` if the PR has none. ///
/// URLs where the proposed branch can be fetched, or `None` if the PR has none.
fn clone_urls_of(event: &Event) -> Option<Vec<String>> { fn clone_urls_of(event: &Event) -> Option<Vec<String>> {
event event
.tags .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 /// The latest PR update, kind 1619, revising `root`.
/// `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).
fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &Event) -> Option<&'a Event> { fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &Event) -> Option<&'a Event> {
let root_hex = root.id.to_hex(); let root_hex = root.id.to_hex();
events 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) .max_by_key(|e| e.created_at)
} }
/// One-line commit metadata for the commits list: author and relative time, /// One-line commit metadata for the commits list.
/// whichever is available. ///
/// Author and relative time, whichever is available.
fn commit_meta(commit: &FileCommit) -> String { fn commit_meta(commit: &FileCommit) -> String {
let author = commit.author.trim(); let author = commit.author.trim();
let time = commit.time > 0; let time = commit.time > 0;
@@ -1357,8 +1339,7 @@ mod tests {
created_at, created_at,
) )
}; };
// An update revising a different PR must be ignored even though it // An update revising a different PR must be ignored even though it is newer.
// is newer.
let unrelated = signed( let unrelated = signed(
Kind::GitPullRequestUpdate, Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")], vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
@@ -1386,8 +1367,8 @@ mod tests {
.finalize(&other) .finalize(&other)
.expect("signed event"); .expect("signed event");
// The tip of a PR is only mutable by its author: a newer update // The tip of a PR is only mutable by its author.
// from anyone else must not win. // A newer update from anyone else must not win.
assert!(latest_update([&stranger, &root].into_iter(), &root).is_none()); 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::pull_request_detail::PullRequestDetailView;
use super::send_patch::open_send_patch_panel; use super::send_patch::open_send_patch_panel;
/// Height of one pull request row in the virtual list; same layout as an /// Height of one pull request row in the virtual list.
/// issue row. const ROW_HEIGHT: f32 = 73.;
const PR_ROW_HEIGHT: f32 = 73.;
/// Status filter of the pull request list, chosen via the header's filter /// Status filter of the pull request list, chosen via the header's filter buttons.
/// buttons.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PullRequestFilter { enum PullRequestFilter {
/// Every pull request, regardless of status. /// Every pull request, regardless of status.
@@ -70,17 +68,11 @@ pub struct PullRequestsView {
filter: PullRequestFilter, filter: PullRequestFilter,
/// Per-row heights of the virtual list. /// Per-row heights of the virtual list.
item_sizes: Rc<Vec<Size<Pixels>>>, item_sizes: Rc<Vec<Size<Pixels>>>,
/// Number of rows [`Self::item_sizes`] was built for (the filtered /// The filtered pull request count [`Self::item_sizes`] was built for.
/// pull request count); rebuilt on change.
pr_len: usize, pr_len: usize,
/// Indices into the store's `pull_requests` matching [`Self::filter`] /// 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`].
visible_prs: Vec<usize>, visible_prs: Vec<usize>,
/// Header counts `(total, open, closed, draft, merged)` of the root /// Header counts `(total, open, closed, draft, merged)`.
/// pull requests only (revisions are not separate PRs), rebuilt with
/// [`Self::visible_prs`].
counts: (usize, usize, usize, usize, usize), counts: (usize, usize, usize, usize, usize),
/// Store version and filter the cached rows/counts were built from. /// Store version and filter the cached rows/counts were built from.
cache_key: Option<(u64, PullRequestFilter)>, cache_key: Option<(u64, PullRequestFilter)>,
@@ -92,10 +84,11 @@ impl PullRequestsView {
pub fn new( pub fn new(
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>, store: Entity<RepoStore>,
repo_name: SharedString,
_window: &mut Window, _window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
let repo_name = store.read(cx).name();
Self { Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
dock_area, 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( fn open_pull_request_detail(
&mut self, &mut self,
pr_id: EventId, pr_id: EventId,
@@ -137,8 +130,9 @@ impl PullRequestsView {
}); });
} }
/// Render one row of the pull request list; `ix` is the row index and /// Render one row of the pull request list.
/// `pr_ix` the index of the pull request in the store's `pull_requests`. ///
/// `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 { 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 = &self.store.read(cx).pull_requests[pr_ix];
let pr_id = pr.id; let pr_id = pr.id;
@@ -203,8 +197,7 @@ impl PullRequestsView {
} }
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement { fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
// Counts of the last list rebuild (`render` rebuilds first when the // Counts of the last list rebuild.
// store version or filter changed, so this is never stale).
let (total, open, closed, draft, merged) = self.counts; let (total, open, closed, draft, merged) = self.counts;
h_flex() h_flex()
@@ -339,9 +332,9 @@ impl Render for PullRequestsView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let filter = self.filter; let filter = self.filter;
// Rebuild the filtered rows and header counts only when the store // Rows and counts are rebuilt only when the store refreshed or filter changed.
// refreshed or the filter changed; other renders reuse the cache.
let version = self.store.read(cx).version(); let version = self.store.read(cx).version();
if self.cache_key != Some((version, filter)) { if self.cache_key != Some((version, filter)) {
let store = self.store.read(cx); let store = self.store.read(cx);
let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize); let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
@@ -350,43 +343,43 @@ impl Render for PullRequestsView {
.iter() .iter()
.enumerate() .enumerate()
.filter_map(|(ix, pr)| { .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 { if pr.kind != Kind::GitPullRequest {
return None; return None;
} }
let status = store.status_of(pr); let status = store.status_of(pr);
counts.0 += 1; counts.0 += 1;
match status { match status {
RepoStatus::Open => counts.1 += 1, RepoStatus::Open => counts.1 += 1,
RepoStatus::Closed => counts.2 += 1, RepoStatus::Closed => counts.2 += 1,
RepoStatus::Draft => counts.3 += 1, RepoStatus::Draft => counts.3 += 1,
RepoStatus::Applied => counts.4 += 1, RepoStatus::Applied => counts.4 += 1,
} }
filter.matches(status).then_some(ix) filter.matches(status).then_some(ix)
}) })
.collect(); .collect();
self.counts = counts; self.counts = counts;
self.cache_key = Some((version, filter)); self.cache_key = Some((version, filter));
} }
let count = self.visible_prs.len(); let count = self.visible_prs.len();
// The virtual list's item count comes from `item_sizes`; rebuild it // The virtual list's item count comes from `item_sizes`.
// whenever the filtered pull request count changes. // Rebuild it whenever the filtered pull request count changes.
if count != self.pr_len { if count != self.pr_len {
self.pr_len = count; 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 sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone(); let scroll_handle = self.scroll_handle.clone();
let view = cx.entity().clone(); let view = cx.entity().clone();
// Non-fatal warnings and errors of the last action (e.g. creating // Non-fatal warnings and errors of the last action, like creating or updating a PR.
// or updating a PR), shown as dismissible banners above the list. // Shown as dismissible banners above the list.
let (last_error, last_warning) = { let (last_error, last_warning) = {
let store = self.store.read(cx); let store = self.store.read(cx);
(store.last_error.clone(), store.last_warning.clone()) (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::prelude::*;
use gpui::{ use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, 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_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::scroll::ScrollableElement;
use gpui_component::spinner::Spinner; use gpui_component::spinner::Spinner;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex}; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
@@ -19,17 +19,16 @@ pub struct SendPatchView {
store: Entity<RepoStore>, store: Entity<RepoStore>,
/// Display name of the repository, for the panel title. /// Display name of the repository, for the panel title.
repo_name: SharedString, repo_name: SharedString,
/// Title input (required). /// Title input, required.
subject: Entity<InputState>, subject: Entity<InputState>,
/// Description input (optional). /// Description input, optional.
description: Entity<TextareaState>, description: Entity<TextareaState>,
/// The pasted `git format-patch` output (required). /// The pasted `git format-patch` output, required.
patch: Entity<TextareaState>, patch: Entity<TextareaState>,
/// A submit is in flight. /// A submit is in flight.
submitting: bool, 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>, error: Option<SharedString>,
_subscriptions: Vec<Subscription>,
} }
impl SendPatchView { impl SendPatchView {
@@ -41,22 +40,14 @@ impl SendPatchView {
) -> Self { ) -> Self {
let repo_name = store.read(cx).name(); let repo_name = store.read(cx).name();
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title")); let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
let description = cx let description = cx
.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change (optional)")); .new(|cx| TextareaState::new(window, cx).placeholder("Describe the change (optional)"));
let patch = cx.new(|cx| { 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") 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 { Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
dock_area, dock_area,
@@ -67,24 +58,23 @@ impl SendPatchView {
patch, patch,
submitting: false, submitting: false,
error: None, error: None,
_subscriptions: subscriptions,
} }
} }
/// Publish the pull request from the pasted patch. The store validates /// Publish the pull request from the pasted patch.
/// 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.
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.submitting { if self.submitting {
return; return;
} }
let subject = self.subject.read(cx).value().to_string(); let subject = self.subject.read(cx).value().to_string();
let description = self.description.read(cx).value().to_string(); let description = self.description.read(cx).value().to_string();
let patch = self.patch.read(cx).value().to_string(); let patch = self.patch.read(cx).value().to_string();
if patch.is_empty() { if patch.is_empty() {
return; return;
} }
let store = self.store.clone(); let store = self.store.clone();
let dock_area = self.dock_area.clone(); let dock_area = self.dock_area.clone();
let entity = cx.entity().clone(); let entity = cx.entity().clone();
@@ -93,8 +83,7 @@ impl SendPatchView {
self.error = None; self.error = None;
cx.notify(); cx.notify();
// Errors the store detects before publishing are returned // Errors the store detects before publishing.
// synchronously through `last_error`.
let sync_error = store.update(cx, |store, cx| { let sync_error = store.update(cx, |store, cx| {
store.open_pull_request( store.open_pull_request(
(!subject.is_empty()).then_some(subject), (!subject.is_empty()).then_some(subject),
@@ -128,6 +117,7 @@ impl SendPatchView {
} }
} }
}); });
cx.notify(); cx.notify();
} }
+20 -21
View File
@@ -24,13 +24,13 @@ use super::open_repo_panel;
const COLUMNS: usize = 2; const COLUMNS: usize = 2;
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.; 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; const RECENT_COUNT: usize = 10;
/// Sort of the explore list, chosen via the header's filter buttons. /// Sort of the explore list, chosen via the header's filter buttons.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum RepoFilter { enum RepoFilter {
/// Every repository, newest first (the store's default order). /// Every repository in the store's default order, newest first.
All, All,
#[default] #[default]
/// Repositories ranked by total issues + pull requests + commits. /// Repositories ranked by total issues + pull requests + commits.
@@ -40,15 +40,15 @@ enum RepoFilter {
} }
impl RepoFilter { impl RepoFilter {
/// Indices into the store's `announcements` included by this filter, in /// Indices into the store's `announcements` this filter includes, in display order.
/// display order, narrowed to repositories whose name (or id) contains ///
/// `query`; an empty query matches everything. /// Narrowed to repositories whose name or id contains `query`.
fn visible(self, store: &RepoListStore, query: &str) -> Vec<usize> { fn visible(self, store: &RepoListStore, query: &str) -> Vec<usize> {
let announcements = &store.announcements; let announcements = &store.announcements;
let mut indices: Vec<usize> = (0..announcements.len()).collect(); let mut indices: Vec<usize> = (0..announcements.len()).collect();
// Narrow by the search query first, so "Recent" limits the matches // Narrow by the search query first.
// and "Popular" ranks them. // Recent then limits the matches and Popular ranks them.
let query = query.trim().to_lowercase(); let query = query.trim().to_lowercase();
if !query.is_empty() { if !query.is_empty() {
indices.retain(|&ix| { indices.retain(|&ix| {
@@ -93,10 +93,9 @@ pub struct RepoListView {
filter: RepoFilter, filter: RepoFilter,
/// Per-row heights of the virtual list. /// Per-row heights of the virtual list.
item_sizes: Rc<Vec<Size<Pixels>>>, 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, repo_len: usize,
/// Indices into the store's `announcements` matching [`Self::filter`], /// Indices matching [`Self::filter`] into the store's `announcements`.
/// in display order; the virtual list renders this slice.
visible: Vec<usize>, visible: Vec<usize>,
/// Search box filtering repositories by name. /// Search box filtering repositories by name.
search: Entity<InputState>, search: Entity<InputState>,
@@ -121,8 +120,8 @@ impl RepoListView {
} }
}); });
// Keep the visible slice and row sizes in sync with the store, // Keep the visible slice and row sizes in sync with the store.
// so newly announced repositories appear without waiting for a click. // Newly announced repositories appear without waiting for a click.
let subscription = cx.observe(&store, |this, _store, cx| { let subscription = cx.observe(&store, |this, _store, cx| {
this.rebuild_rows(cx); this.rebuild_rows(cx);
}); });
@@ -141,16 +140,17 @@ impl RepoListView {
_subscription: subscription, _subscription: subscription,
}; };
// Seed the rows right away; the store may already hold announcements // Seed the rows right away.
// (it loaded before the panel opened), and the first render must not // The store may already hold announcements from before the panel opened.
// depend on a later store update. // The first render must not depend on a later store update.
this.rebuild_rows(cx); this.rebuild_rows(cx);
this this
} }
/// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the current /// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the store.
/// store contents, [`Self::filter`] and the search query. ///
/// Uses the store contents, [`Self::filter`] and the search query.
fn rebuild_rows(&mut self, cx: &mut Context<Self>) { fn rebuild_rows(&mut self, cx: &mut Context<Self>) {
let filter = self.filter; let filter = self.filter;
let query = self.search.read(cx).value(); let query = self.search.read(cx).value();
@@ -201,8 +201,8 @@ impl RepoListView {
.map(|label| SharedString::from(format!("Updated {label}"))) .map(|label| SharedString::from(format!("Updated {label}")))
.unwrap_or_default(); .unwrap_or_default();
// Fork badge: the upstream's display name when its announcement is // The fork badge shows the upstream name when its announcement is known locally.
// known locally, otherwise its repository id from the `u` tag. // Otherwise it shows the repository id from the `u` tag.
let fork_label: Option<SharedString> = let fork_label: Option<SharedString> =
announcement.upstream.as_ref().and_then(|upstream| { announcement.upstream.as_ref().and_then(|upstream| {
let addr = upstream.addr.as_ref()?; let addr = upstream.addr.as_ref()?;
@@ -347,8 +347,7 @@ impl RepoListView {
.into_any_element() .into_any_element()
} }
/// One segmented filter button of the header, styled like the issues /// One segmented header filter button, like the issues list's status filter buttons.
/// list's status filter buttons.
fn filter_button( fn filter_button(
&self, &self,
filter: RepoFilter, filter: RepoFilter,
@@ -1,3 +1,5 @@
use std::path::PathBuf;
use dock::DockArea; use dock::DockArea;
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px}; 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 gpui_component::{ActiveTheme, Disableable, IconName, WindowExt, h_flex};
use settings::SettingsStore; use settings::SettingsStore;
use signed_core::Announcement; use signed_core::Announcement;
use signed_state::Backend; use signed_state::{Backend, CheckoutsStore};
use super::super::open_repo_panel; use super::super::open_repo_panel;
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers}; use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
@@ -22,10 +24,6 @@ pub struct CreateRepoState {
} }
/// Open the Create Repository dialog. /// 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) { pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
let settings = SettingsStore::global(cx); let settings = SettingsStore::global(cx);
let default_folder = settings 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| { window.open_dialog(cx, move |dialog, _window, _cx| {
const DESC: &str = "Publish a new repository to your grasp servers."; 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 name_input = name_input.clone();
let desc_input = desc_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({ .on_click({
let name_input = name_input.clone(); let name_input = name_input.clone();
let desc_input = desc_input.clone(); let desc_input = desc_input.clone();
let folder_input = folder_input.clone();
let state = state.clone(); let state = state.clone();
let grasp_state = grasp_state.clone(); let grasp_state = grasp_state.clone();
let dock_area = dock_area.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( create_repository(
name_input.clone(), name_input.clone(),
desc_input.clone(), desc_input.clone(),
folder_input.clone(),
state.clone(), state.clone(),
grasp_state.clone(), grasp_state.clone(),
dock_area.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 /// Pick the repository's storage folder with the platform's native folder picker.
/// 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.
fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mut App) { fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
let handle = window.window_handle(); let handle = window.window_handle();
let folder_input = folder_input.clone(); let folder_input = folder_input.clone();
@@ -194,10 +191,14 @@ fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mu
.detach(); .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( fn create_repository(
name_input: Entity<InputState>, name_input: Entity<InputState>,
desc_input: Entity<TextareaState>, desc_input: Entity<TextareaState>,
folder_input: Entity<InputState>,
state: Entity<CreateRepoState>, state: Entity<CreateRepoState>,
grasp_state: Entity<GraspServersState>, grasp_state: Entity<GraspServersState>,
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
@@ -206,6 +207,7 @@ fn create_repository(
) { ) {
let name = name_input.read(cx).value().trim().to_owned(); let name = name_input.read(cx).value().trim().to_owned();
let description = desc_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(); let servers = grasp_state.read(cx).grasp_servers.clone();
if name.is_empty() { if name.is_empty() {
@@ -228,16 +230,24 @@ fn create_repository(
let backend = Backend::global(cx); let backend = Backend::global(cx);
let task = backend.update(cx, |backend, 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 handle = window.window_handle();
let state = state.clone(); let state = state.clone();
let dock_area = dock_area.clone(); let dock_area = dock_area.clone();
cx.spawn(async move |cx| match task.await { cx.spawn(async move |cx| match task.await {
Ok(announcement) => { Ok((announcement, local_path)) => {
cx.update_window(handle, |_, window, cx| { cx.update_window(handle, |_, window, cx| {
window.close_dialog(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); open_repo(dock_area, announcement, window, cx);
}) })
.ok(); .ok();
@@ -9,24 +9,22 @@ use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
use signed_core::filters; use signed_core::filters;
use signed_state::Backend; use signed_state::Backend;
/// State of the grasp-server section of a publish dialog, so async /// State of the grasp-server section of a publish dialog, so async results can be rendered.
/// results can be rendered.
#[derive(Default)] #[derive(Default)]
pub struct GraspServersState { 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 loading_servers: bool,
pub grasp_servers: Vec<RelayUrl>, 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, 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>, pub error: Option<SharedString>,
} }
impl GraspServersState { 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 /// Persisted settings supply the defaults, an empty list falls back to the built-ins.
/// built-in defaults when the configured list is empty.
pub fn new_default(settings: &GraspServersSettings) -> Self { pub fn new_default(settings: &GraspServersSettings) -> Self {
let urls: Vec<String> = if settings.default_servers.is_empty() { let urls: Vec<String> = if settings.default_servers.is_empty() {
DEFAULT_GRASP_SERVERS DEFAULT_GRASP_SERVERS
@@ -48,10 +46,7 @@ impl GraspServersState {
} }
} }
/// The "Grasp servers" form field shared by the publish dialogs: an /// The Grasp servers form field shared by the publish dialogs.
/// 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.
pub fn grasp_servers_field( pub fn grasp_servers_field(
state: &Entity<GraspServersState>, state: &Entity<GraspServersState>,
relay_input: &Entity<InputState>, 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( fn render_server_row(
ix: usize, ix: usize,
relay: &RelayUrl, 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 { fn display_server(relay: &RelayUrl) -> SharedString {
relay relay
.domain() .domain()
@@ -190,7 +185,7 @@ fn display_server(relay: &RelayUrl) -> SharedString {
.unwrap_or_else(|| SharedString::from(relay.to_string())) .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( fn add_relay(
state: &Entity<GraspServersState>, state: &Entity<GraspServersState>,
input: &Entity<InputState>, input: &Entity<InputState>,
@@ -226,8 +221,9 @@ fn add_relay(
} }
} }
/// Load the user's grasp list (kind `10317`) from the local database and /// Load the user's grasp list of kind `10317` from the local database.
/// replace the defaults with it when it lists any servers. ///
/// It replaces the defaults when it lists any servers.
pub fn load_user_grasp_servers( pub fn load_user_grasp_servers(
state: Entity<GraspServersState>, state: Entity<GraspServersState>,
window: &mut Window, window: &mut Window,
@@ -2,8 +2,6 @@ use gpui::{App, Window, px};
use gpui_component::WindowExt; use gpui_component::WindowExt;
/// Open the Import Identity dialog. /// 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) { pub fn open(window: &mut Window, cx: &mut App) {
window.open_dialog(cx, move |dialog, _window, _cx| { window.open_dialog(cx, move |dialog, _window, _cx| {
dialog.title("Import identity").width(px(400.)) 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::input::InputState;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use signed_core::{Announcement, identifier_from_name}; 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::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}; use super::{RepoDetailView, RepoListView, open_repo_panel};
@@ -30,23 +32,26 @@ mod settings_dialog;
use self::onboarding_dialog::OnboardingState; use self::onboarding_dialog::OnboardingState;
/// Left-dock panel with navigation entries. Entries open content panels in /// Left-dock panel with navigation entries.
/// the dock area. /// Entries open content panels in the dock area.
pub struct SidebarPanel { pub struct SidebarPanel {
focus_handle: FocusHandle, focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
explore: Option<WeakEntity<RepoListView>>, explore: Option<WeakEntity<RepoListView>>,
logged_in: bool, logged_in: bool,
/// Repositories announced by the current user, listed under /// Repositories the current user announced, listed under the All Repositories heading.
/// "All Repositories". Recreated when the signer changes. /// Recreated when the signer changes.
my_repos: Option<Entity<RepoListStore>>, my_repos: Option<Entity<RepoListStore>>,
/// Observes the current user's repo store so the list re-renders. /// Observes the current user's repo store so the list re-renders.
my_repos_subscription: Option<Subscription>, my_repos_subscription: Option<Subscription>,
/// Banner artwork shown behind the sign-in screen, /// Banner artwork behind the sign-in screen.
/// picked at random from the bundled `backgrounds/` assets. /// Picked at random from the bundled `backgrounds/` assets.
banner: SharedString, banner: SharedString,
/// Observes the local-repository scan so new discoveries re-render. /// Observes the local-repository scan so new discoveries re-render.
_local_repos_subscription: Subscription, _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, _subscription: Subscription,
} }
@@ -77,6 +82,11 @@ impl SidebarPanel {
cx.notify(); cx.notify();
}); });
let checkouts_store = CheckoutsStore::global(cx);
let checkouts_subscription = cx.observe(&checkouts_store, |_, _, cx| {
cx.notify();
});
let mut panel = Self { let mut panel = Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
dock_area, dock_area,
@@ -86,6 +96,7 @@ impl SidebarPanel {
my_repos_subscription: None, my_repos_subscription: None,
banner: pick_banner(), banner: pick_banner(),
_local_repos_subscription: local_repos_subscription, _local_repos_subscription: local_repos_subscription,
_checkouts_subscription: checkouts_subscription,
_subscription: subscription, _subscription: subscription,
}; };
@@ -96,7 +107,8 @@ impl SidebarPanel {
panel 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>) { fn refresh_my_repos(&mut self, cx: &mut Context<Self>) {
self.my_repos_subscription = None; 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))); self.my_repos = author.map(|author| cx.new(|cx| RepoListStore::new(Some(author), cx)));
if let Some(store) = self.my_repos.as_ref() { 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 /// Open the Explore repository list panel in the dock area's center.
/// area. No-op if it's already open. /// No-op if it is already open.
pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self if self
.explore .explore
@@ -162,8 +191,8 @@ impl SidebarPanel {
open_repo_panel(&self.dock_area, announcement, window, &mut *cx); open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
} }
/// Open a local repository's detail view in the dock's center; the /// Open a local repository's detail view in the dock's center.
/// detail view offers to publish it to NIP-34. /// 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>) { fn open_local_repo(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
let detail = let detail =
cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, window, cx)); 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 All Repositories section of the sidebar.
/// the current user's repositories below it, lazily rendered through a /// A header with the create button above the current user's repositories.
/// [`uniform_list`], followed by the local git repositories discovered /// Rendered lazily through a [`uniform_list`].
/// by the startup scan. /// Followed by local git repositories from the startup scan.
fn render_my_repos(&self, cx: &mut Context<Self>) -> impl IntoElement { fn render_my_repos(&self, cx: &mut Context<Self>) -> impl IntoElement {
let store = self.my_repos.as_ref(); let store = self.my_repos.as_ref();
let local = LocalReposStore::global(cx); let local = LocalReposStore::global(cx);
@@ -236,11 +265,10 @@ impl SidebarPanel {
) )
.when_some(store, |builder, store| { .when_some(store, |builder, store| {
let announcements = store.read(cx).announcements.clone(); let announcements = store.read(cx).announcements.clone();
// Local repositories that have already been published to // Local repositories already published to NIP-34 appear above.
// NIP-34 are listed among the user's repositories above; // Hide them from the local section here.
// hide them from the local section (matched by the // Matched by the identifier derived from the directory name.
// identifier derived from the directory name, like the // Same derivation as the init dialog's default name.
// init dialog's default name).
let announced_ids: HashSet<String> = let announced_ids: HashSet<String> =
announcements.iter().map(|a| a.id.clone()).collect(); announcements.iter().map(|a| a.id.clone()).collect();
let local_repos: Vec<PathBuf> = local_repos let local_repos: Vec<PathBuf> = local_repos
@@ -253,8 +281,8 @@ impl SidebarPanel {
}) })
.cloned() .cloned()
.collect(); .collect();
// One merged list: the user's NIP-34 repositories first, // One merged list, the user's NIP-34 repositories first.
// then the local repositories discovered by the scan. // Local repositories discovered by the scan follow.
let total = announcements.len() + local_repos.len(); let total = announcements.len() + local_repos.len();
if total == 0 { if total == 0 {
@@ -297,8 +325,7 @@ impl SidebarPanel {
}) })
} }
/// One row of the merged sidebar list: a NIP-34 repository or a local /// One row of the merged sidebar list, a NIP-34 or a local repository.
/// repository.
fn render_repo_row_at( fn render_repo_row_at(
&self, &self,
announcements: &[Announcement], announcements: &[Announcement],
@@ -328,17 +355,31 @@ impl SidebarPanel {
.clone() .clone()
.unwrap_or_else(|| SharedString::from(announcement.id.clone())); .unwrap_or_else(|| SharedString::from(announcement.id.clone()));
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); 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)), cx.listener(move |this, _ev, window, cx| this.open_repo(&announcement, window, cx)),
) )
} }
/// One local repository row: a deterministic pixel avatar seeded from /// One local repository row, a deterministic pixel avatar seeded from the path.
/// the path, the directory name, and a warning suffix marking it as /// The directory name and a warning suffix, the repo is not yet set up for NIP-34.
/// not yet set up for NIP-34. Clicking it opens the repository's /// Clicking opens the detail view, which offers to initialize it.
/// detail view, which offers to initialize it.
fn render_local_row(&self, path: &Path, cx: &mut Context<Self>) -> impl IntoElement { fn render_local_row(&self, path: &Path, cx: &mut Context<Self>) -> impl IntoElement {
let name = path let name = path
.file_name() .file_name()
@@ -366,7 +407,7 @@ impl SidebarPanel {
import_dialog::open(window, cx); 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( fn render_user(
&self, &self,
profile: &Profile, profile: &Profile,
@@ -396,8 +437,8 @@ impl SidebarPanel {
) )
} }
/// Sign-in placeholder shown while logged out: banner artwork behind a /// Sign-in placeholder shown while logged out.
/// scrim so the CTA buttons stay readable in both themes. /// 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 { fn render_sign_in(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
v_flex() v_flex()
.size_full() .size_full()
@@ -15,10 +15,6 @@ pub struct OnboardingState {
} }
/// Open the Onboarding dialog for creating a new identity. /// 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( pub fn open(
name_input: Entity<InputState>, name_input: Entity<InputState>,
pass_input: Entity<InputState>, pass_input: Entity<InputState>,
@@ -17,10 +17,7 @@ pub struct PassphraseState {
_enter_subscription: Option<Subscription>, _enter_subscription: Option<Subscription>,
} }
/// Open the dialog asking for the passphrase that protects the stored /// Open the dialog asking for the passphrase that protects the stored identity.
/// NIP-49 encrypted identity (`ncryptsec1...`).
///
/// Called when the backend emits [`signed_state::BackendEvent::PassphraseRequired`].
pub fn open(window: &mut Window, cx: &mut App) { pub fn open(window: &mut Window, cx: &mut App) {
let pass_input = cx.new(|cx| { let pass_input = cx.new(|cx| {
InputState::new(window, 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; /// Submit the passphrase to the backend.
/// on failure the error is rendered inline and the dialog stays open.
fn unlock( fn unlock(
pass_input: &Entity<InputState>, pass_input: &Entity<InputState>,
state: &Entity<PassphraseState>, 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::cell::Cell;
use std::path::PathBuf; use std::path::PathBuf;
use std::rc::Rc; use std::rc::Rc;
@@ -55,8 +48,7 @@ fn theme_options(cx: &App) -> (Vec<SelectOption>, Vec<SelectOption>) {
(light, dark) (light, dark)
} }
/// Stateful controls of the settings dialog, created once when it opens so /// Stateful controls of the settings dialog, created once when it opens.
/// their values survive re-renders of the dialog content.
struct SettingsControls { struct SettingsControls {
appearance: Entity<SelectState<Vec<SelectOption>>>, appearance: Entity<SelectState<Vec<SelectOption>>>,
light_theme: Entity<SelectState<Vec<SelectOption>>>, light_theme: Entity<SelectState<Vec<SelectOption>>>,
@@ -66,8 +58,7 @@ struct SettingsControls {
radius: Entity<InputState>, radius: Entity<InputState>,
radius_lg: Entity<InputState>, radius_lg: Entity<InputState>,
grasp_server_input: Entity<InputState>, grasp_server_input: Entity<InputState>,
/// The effective default create-repository folder, shown in the disabled /// The effective default create-repository folder, shown in the disabled input.
/// folder selector.
default_folder: Entity<InputState>, default_folder: Entity<InputState>,
/// Keeps the control subscriptions alive for the dialog's lifetime. /// Keeps the control subscriptions alive for the dialog's lifetime.
_subscriptions: Vec<Subscription>, _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 /// The settings content, one section per related setting.
/// horizontal separator lines. /// Sections are divided by horizontal separator lines.
fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement { fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement {
let store = SettingsStore::global(cx); let store = SettingsStore::global(cx);
let settings = store.read(cx).settings().clone(); 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 /// Theme configuration, the registry theme names plus tweaks the app customizes at startup.
/// the visual tweaks the application customizes at startup.
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement { fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
v_flex() v_flex()
.gap_3() .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( fn grasp_servers_section(
settings: &Settings, settings: &Settings,
controls: &SettingsControls, controls: &SettingsControls,
@@ -413,8 +403,8 @@ fn grasp_servers_section(
)) ))
} }
/// The editable list of default grasp servers plus an add-relay input, /// The editable list of default grasp servers plus an add-relay input.
/// styled like the grasp-server section of the publish dialogs. /// Styled like the grasp-server section of the publish dialogs.
fn grasp_server_editor( fn grasp_server_editor(
servers: &[String], servers: &[String],
controls: &SettingsControls, controls: &SettingsControls,
@@ -478,8 +468,8 @@ fn grasp_server_editor(
) )
} }
/// 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.
/// matching how the publish dialogs display servers. /// Matches how the publish dialogs display servers.
fn display_server(server: &str) -> SharedString { fn display_server(server: &str) -> SharedString {
RelayUrl::parse(server) RelayUrl::parse(server)
.ok() .ok()
@@ -513,8 +503,8 @@ fn repositories_section(
)) ))
} }
/// The editable list of scan directories plus an add-directory button, /// The editable list of scan directories plus an add-directory button.
/// styled like the grasp-server list. /// Styled like the grasp-server list.
fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement { fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement {
v_flex() v_flex()
.w_full() .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 /// The default-folder selector, a disabled input plus a picker button.
/// folder plus a picker button, matching the create-repository dialog. /// Matches the create-repository dialog.
fn folder_selector(controls: &SettingsControls) -> impl IntoElement { fn folder_selector(controls: &SettingsControls) -> impl IntoElement {
let default_folder = controls.default_folder.clone(); let default_folder = controls.default_folder.clone();
h_flex() 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 /// Parse the server input and append it to the default grasp servers.
/// default grasp servers. /// A bare host is accepted.
fn add_server(input: &Entity<InputState>, window: &mut Window, cx: &mut App) { fn add_server(input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
let value = input.read(cx).value().trim().to_owned(); let value = input.read(cx).value().trim().to_owned();
if value.is_empty() { if value.is_empty() {
@@ -660,8 +650,8 @@ fn add_scan_path(cx: &mut App) {
.detach(); .detach();
} }
/// Prompt for the folder the Create Repository dialog should default to, /// Prompt for the Create Repository dialog's default folder.
/// remembering it in the settings and showing it in the disabled input. /// 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) { fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Window, cx: &mut App) {
let handle = window.window_handle(); let handle = window.window_handle();
let default_folder = default_folder.clone(); let default_folder = default_folder.clone();
@@ -695,8 +685,9 @@ fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Windo
.detach(); .detach();
} }
/// Wire a number input to the settings: steps clamp and persist, typed /// Wire a number input to the settings.
/// changes parse, clamp and persist. /// Step actions clamp and persist the value.
/// Typed changes parse, clamp and persist.
fn wire_number_input( fn wire_number_input(
state: &Entity<InputState>, state: &Entity<InputState>,
subscriptions: &mut Vec<Subscription>, subscriptions: &mut Vec<Subscription>,
+4 -10
View File
@@ -45,10 +45,7 @@ impl Workspace {
let mut subscriptions = vec![]; let mut subscriptions = vec![];
// A bottom/right dock whose last panel was dragged away is removed // A bottom or right dock whose last panel was dragged away is removed entirely.
// 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.
let dock_for_pruning = dock.clone(); let dock_for_pruning = dock.clone();
subscriptions.push(cx.subscribe_in( subscriptions.push(cx.subscribe_in(
&dock, &dock,
@@ -78,9 +75,7 @@ impl Workspace {
let backend = Backend::global(cx); let backend = Backend::global(cx);
// Ask for the passphrase when the stored identity is NIP-49 // Ask for the passphrase when the stored identity is NIP-49 encrypted.
// encrypted. Subscribed via the window, since opening a dialog
// needs one.
let passphrase_subscription = let passphrase_subscription =
window.subscribe(&backend, cx, |_backend, event, window, cx| { window.subscribe(&backend, cx, |_backend, event, window, cx| {
if matches!(event, BackendEvent::PassphraseRequired) { if matches!(event, BackendEvent::PassphraseRequired) {
@@ -88,9 +83,8 @@ impl Workspace {
} }
}); });
// The event may have fired before this window existed (the backend // The event may have fired before this window existed.
// is initialized before the first window opens); fall back to the // Fall back to the backend state in that case.
// backend state in that case.
if backend.read(cx).passphrase_required() { if backend.read(cx).passphrase_required() {
passphrase_dialog::open(window, cx); passphrase_dialog::open(window, cx);
} }
+4 -4
View File
@@ -17,14 +17,14 @@ fn main() {
gpui_component::init(cx); gpui_component::init(cx);
theme::init(cx); theme::init(cx);
// Load the persisted settings before applying the theme, // Load the persisted settings before applying the theme.
// so the stored appearance and theme configuration take effect at startup. // Stored appearance and theme settings then take effect at startup.
let store = cx.new(|cx| SettingsStore::new(paths::settings_file(), cx)); let store = cx.new(|cx| SettingsStore::new(paths::settings_file(), cx));
SettingsStore::set_global(store.clone(), cx); SettingsStore::set_global(store.clone(), cx);
let settings = store.read(cx).settings().clone(); let settings = store.read(cx).settings().clone();
// Register the built-in "Signed" theme (light + dark variants) // Register the built-in Signed theme, light and dark variants.
// and make it the active theme, following the stored appearance. // The stored appearance then selects the active theme.
let registry = ThemeRegistry::global_mut(cx); let registry = ThemeRegistry::global_mut(cx);
for (name, content) in Assets.themes() { for (name, content) in Assets.themes() {
if let Err(err) = registry.load_themes_from_str(&content) { 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 ```mermaid
graph TD graph TD
A["New pull request dialog"] --> B{"Patch source"} A["New pull request panel"] --> B{"Compare source"}
B -->|"Paste"| C["Paste git format-patch output"] B -->|"Local checkout"| C["Pick folder (or auto-prefilled from remembered checkouts)"]
B -->|"Local checkout"| D["Browse for checkout"] B -->|"Announced fork"| D["Pick fork repo + branch"]
D --> E["Defaults: source = current branch, target = announced HEAD"] D --> D1["Ensure base mirror (GitCache), fetch origin"]
E --> F["Generate: merge-base plus format-patch base..tip"] D1 --> D2["Import fork heads as refs/fork/&lt;owner&gt;/&lt;id&gt;/*"]
F --> G["Apply check vs mirror clone - non-blocking warning"] C --> E["Defaults: target = announced HEAD, source = current branch / fork main"]
C --> H["Submit"] E --> F["merge-base + commits + diff of target..source (Files/Commits tabs)"]
F --> H F --> H["Submit: format-patch base..tip at publish time"]
G --> H
H --> I["split_patch_series: one part per commit"] H --> I["split_patch_series: one part per commit"]
I --> J{"Any part over 60 KB?"} I --> J{"Any part over 60 KB?"}
J -->|"Yes"| K["Refuse with message"] J -->|"Yes"| K["Refuse with message"]
J -->|"No"| L["tip = last part's From commit"] 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"] 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"] 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 -->|"All rejected"| Q["last_warning banner in PR list"]
P --> R["Publish kind-1618 PR event"] P --> R["Publish kind-1618 PR event"]
Q --> R Q --> R
@@ -50,47 +49,81 @@ graph TD
Key points of the write side: Key points of the write side:
- **Merge base**: only computable in the local-checkout path - **Compare sources** (NIP-34 / GRASP-06 native, no fork identity on the
(`signed_git::merge_base`); the paste path publishes none. The dialog wire):
reuses it at submit only while the patch textarea is unchanged. - *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 - **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* 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 of the series (the tip), and each part carries its own
`commit`/`r` tags. `commit`/`r` tags.
- **Push before publish**: the tip is pushed to every announced grasp - **Push before publish**: failure is non-fatal — the patch events remain
server under `refs/nostr/<event-id>` (nak's convention) so the announced the source of truth — and surfaces as a `last_warning` banner.
`clone` URLs really can serve the commit. Failure is non-fatal — the - **1619 updates are paste-only today** (no repo path holds the new tip's
patch events remain the source of truth — and surfaces as a objects), so updates are not pushed; hosting them is deferred until the
`last_warning` banner. update dialog gains a local-checkout source.
## Creating a pull request - event ordering ## Creating a pull request - event ordering
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant User participant User
participant App participant P as Base mirror (GitCache)
participant Checkout as Local checkout participant F as Fork grasp server
participant Grasp as Grasp servers participant A as Author grasp (GRASP-06 /prs/)
participant Relays as Nostr relays participant B as Base repo grasps
participant R as Nostr relays
User->>App: pick checkout and branches, Generate User->>P: ensure mirror (fork mode) / pick local checkout
App->>Checkout: merge-base(source, target) P-->>F: fetch fork heads -> refs/fork/... (fork mode)
Checkout-->>App: base commit User->>P: merge-base, range commits, range diff
App->>Checkout: format-patch base..tip User->>P: submit: format-patch base..compare-ref
Checkout-->>App: patch series
App->>App: split series, check per-part size
loop each patch of the series 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 end
App->>App: build and sign kind-1618 PR event User->>User: build and sign kind-1618 (clone = /prs/ URLs + announced)
App->>Grasp: push tip to refs/nostr/event-id User->>A: push tip to refs/nostr/event-id (author servers, first)
Grasp-->>App: accepted or rejected (best-effort) User->>B: push tip to refs/nostr/event-id (best-effort)
App->>Relays: publish kind-1618 PR event A-->>User: accepted or rejected (all rejected -> warning)
User->>R: publish kind-1618 PR event
opt draft opt draft
App->>Relays: publish kind-1633 draft status User->>R: publish kind-1633 draft status
end 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 ## Updating and merging
```mermaid ```mermaid
@@ -100,7 +133,7 @@ sequenceDiagram
participant Maintainer participant Maintainer
participant Clone as Mirror clone 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 revision patch series (t root-revision, e reply to original root)
Author->>Relays: publish kind-1619 update (E/P tags, c = new tip) 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. stranger's update is ignored.
- **Diff**: the patch set is preferred (NIP-34 `e`-linked chain); PRs from - **Diff**: the patch set is preferred (NIP-34 `e`-linked chain); PRs from
other clients without patch events fall back to diffing 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. - [ ] 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. - [ ] 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). - [ ] 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.
- [ ] 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. - [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 ## Performance: render path