feat: push checkout (#14)
Reviewed-on: https://git.reya.su/reya/signed/pulls/14
This commit was merged in pull request #14.
This commit is contained in:
Generated
+69
-96
@@ -14,38 +14,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_atspi_common"
|
||||
version = "0.18.1"
|
||||
version = "0.19.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
|
||||
checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.36.0",
|
||||
"accesskit_consumer",
|
||||
"atspi-common",
|
||||
"phf 0.13.1",
|
||||
"serde",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_consumer"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_consumer"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950720ce064757a1b629caad3a408e8d2c63bb01f29b8a3ff8daa331053ffeb"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_consumer"
|
||||
version = "0.38.0"
|
||||
@@ -63,7 +43,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.38.0",
|
||||
"accesskit_consumer",
|
||||
"hashbrown 0.16.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
@@ -72,9 +52,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_unix"
|
||||
version = "0.21.1"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
|
||||
checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_atspi_common",
|
||||
@@ -90,12 +70,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_windows"
|
||||
version = "0.33.1"
|
||||
version = "0.34.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36e93ac7bf50b964f1cbb75f741629a4e950571baa1ef1274457ab5a80d9bcc2"
|
||||
checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.37.0",
|
||||
"accesskit_consumer",
|
||||
"hashbrown 0.16.1",
|
||||
"static_assertions",
|
||||
"windows 0.62.2",
|
||||
@@ -311,7 +291,6 @@ dependencies = [
|
||||
"anyhow",
|
||||
"gpui",
|
||||
"gpui-component",
|
||||
"log",
|
||||
"rust-embed",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -342,9 +321,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.43"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8"
|
||||
checksum = "515a1f282e33d55983c499d7e9e87082e81cbc32974825bf9032f928392d5844"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
@@ -678,9 +657,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.71.1"
|
||||
version = "0.72.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
|
||||
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"cexpr",
|
||||
@@ -1238,7 +1217,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collections"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"gpui_util",
|
||||
"indexmap",
|
||||
@@ -1263,9 +1242,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.38"
|
||||
version = "0.4.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
|
||||
checksum = "2fe67f2944eef52fc7b106b8c9450d243a88701a0c065f7f57235e76abaed7df"
|
||||
dependencies = [
|
||||
"bzip2",
|
||||
"compression-core",
|
||||
@@ -1275,9 +1254,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.32"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
||||
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
@@ -1704,7 +1683,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "derive_refineable"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1866,7 +1845,7 @@ dependencies = [
|
||||
"cc",
|
||||
"memchr",
|
||||
"rustc_version",
|
||||
"toml 1.1.4+spec-1.1.0",
|
||||
"toml 1.1.5+spec-1.1.0",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
]
|
||||
@@ -3484,7 +3463,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui"
|
||||
version = "0.2.2"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"anyhow",
|
||||
@@ -3547,7 +3526,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"waker-fn",
|
||||
"web-time",
|
||||
"windows 0.61.3",
|
||||
"windows 0.62.2",
|
||||
"zed-font-kit",
|
||||
"zed-scap",
|
||||
"ztracing",
|
||||
@@ -3556,7 +3535,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-base"
|
||||
version = "0.5.2"
|
||||
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5"
|
||||
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -3592,7 +3571,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-component"
|
||||
version = "0.5.2"
|
||||
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5"
|
||||
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -3669,7 +3648,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-component-assets"
|
||||
version = "0.5.1"
|
||||
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5"
|
||||
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gpui",
|
||||
@@ -3683,7 +3662,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-component-macros"
|
||||
version = "0.5.1"
|
||||
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5"
|
||||
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3693,21 +3672,21 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-fps"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5"
|
||||
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
||||
dependencies = [
|
||||
"gpui",
|
||||
"instant",
|
||||
"libc",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-kit",
|
||||
"sysinfo 0.37.2",
|
||||
"web-time",
|
||||
"windows 0.58.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpui_apple"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"block",
|
||||
@@ -3730,7 +3709,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_linux"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_unix",
|
||||
@@ -3776,7 +3755,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_macos"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_macos",
|
||||
@@ -3822,7 +3801,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_macros"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
@@ -3833,7 +3812,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_platform"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"gpui",
|
||||
@@ -3846,7 +3825,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_shared_string"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"schemars",
|
||||
"serde",
|
||||
@@ -3856,7 +3835,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_util"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"log",
|
||||
@@ -3866,7 +3845,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_web"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"console_error_panic_hook",
|
||||
@@ -3890,7 +3869,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_wgpu"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytemuck",
|
||||
@@ -3916,7 +3895,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_windows"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_windows",
|
||||
@@ -3935,10 +3914,10 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"smallvec",
|
||||
"uuid",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-numerics 0.2.0",
|
||||
"windows-registry 0.5.3",
|
||||
"windows 0.62.2",
|
||||
"windows-core 0.62.2",
|
||||
"windows-numerics 0.3.1",
|
||||
"windows-registry 0.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4230,7 +4209,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "http_client"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-compression",
|
||||
@@ -4250,7 +4229,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "http_client_tls"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rustls",
|
||||
@@ -5034,9 +5013,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.18.3"
|
||||
version = "0.18.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c"
|
||||
checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
@@ -5206,7 +5185,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bindgen",
|
||||
@@ -5300,9 +5279,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.2"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
||||
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
@@ -6119,9 +6098,9 @@ checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.2"
|
||||
version = "5.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ade3be4664bc1ef537ce133015f04c176b737815c2ba9fd60edf212d6e90dd55"
|
||||
checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634"
|
||||
dependencies = [
|
||||
"is-wsl",
|
||||
"libc",
|
||||
@@ -6264,7 +6243,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
[[package]]
|
||||
name = "perf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"collections",
|
||||
"serde",
|
||||
@@ -7052,7 +7031,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "refineable"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"derive_refineable",
|
||||
]
|
||||
@@ -7135,7 +7114,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "reqwest_client"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -7513,7 +7492,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "scheduler"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"backtrace",
|
||||
@@ -7907,11 +7886,7 @@ dependencies = [
|
||||
"dock",
|
||||
"gpui",
|
||||
"gpui-component",
|
||||
"gpui_linux",
|
||||
"gpui_macos",
|
||||
"gpui_platform",
|
||||
"gpui_windows",
|
||||
"log",
|
||||
"paths",
|
||||
"reqwest_client",
|
||||
"settings",
|
||||
@@ -7925,7 +7900,6 @@ dependencies = [
|
||||
name = "signed_core"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"gpui",
|
||||
"nostr",
|
||||
]
|
||||
|
||||
@@ -7945,13 +7919,11 @@ name = "signed_nostr"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nostr",
|
||||
"nostr-connect",
|
||||
"nostr-gossip-memory",
|
||||
"nostr-lmdb",
|
||||
"nostr-memory",
|
||||
"nostr-sdk",
|
||||
"signed_core",
|
||||
"webbrowser",
|
||||
]
|
||||
|
||||
@@ -7969,9 +7941,11 @@ dependencies = [
|
||||
"nostr-connect",
|
||||
"nostr-sdk",
|
||||
"rustls",
|
||||
"settings",
|
||||
"signed_core",
|
||||
"signed_git",
|
||||
"signed_nostr",
|
||||
"tempfile",
|
||||
"utils",
|
||||
]
|
||||
|
||||
@@ -8227,7 +8201,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
[[package]]
|
||||
name = "sum_tree"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"heapless 0.9.3",
|
||||
"log",
|
||||
@@ -8813,9 +8787,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.4+spec-1.1.0"
|
||||
version = "1.1.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
|
||||
checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
@@ -9625,7 +9599,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
[[package]]
|
||||
name = "util_macros"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"perf",
|
||||
"quote",
|
||||
@@ -10505,13 +10479,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10819,7 +10793,6 @@ version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assets",
|
||||
"chrono",
|
||||
"dock",
|
||||
"futures",
|
||||
"gix",
|
||||
@@ -11394,7 +11367,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
||||
[[package]]
|
||||
name = "zlog"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -11411,7 +11384,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
[[package]]
|
||||
name = "ztracing"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -11422,7 +11395,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ztracing_macro"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
||||
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
|
||||
@@ -12,9 +12,6 @@ publish = false
|
||||
# GPUI
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_windows = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_macos = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
|
||||
@@ -37,7 +34,6 @@ nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||
|
||||
gix = { version = "0.87.1", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation"] }
|
||||
|
||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||
smol = "2"
|
||||
futures = "0.3"
|
||||
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
||||
|
||||
@@ -8,7 +8,6 @@ publish.workspace = true
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
rust-embed.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -151,14 +151,14 @@ mod tests {
|
||||
assert_eq!(theme.border, parse("#27272A")); // neutral-800
|
||||
assert_eq!(theme.green, parse("#22C55E")); // green-500
|
||||
} else {
|
||||
// Light theme chrome is neutral; lime is a brand accent only.
|
||||
// Light theme chrome is neutral, lime is a brand accent only.
|
||||
assert_eq!(theme.background, parse("#FFFFFF"));
|
||||
assert_eq!(theme.foreground, parse("#18181B"));
|
||||
assert_eq!(theme.border, parse("#E4E4E7"));
|
||||
assert_eq!(theme.green, parse("#16A34A"));
|
||||
}
|
||||
// Active tab: a paler lime on light, a dim moss on dark — each
|
||||
// paired with readable, contrasting text.
|
||||
// Active tab, a paler lime on light and a dim moss on dark.
|
||||
// Each is paired with readable contrasting text.
|
||||
if config.mode.is_dark() {
|
||||
assert_eq!(theme.tab_active, parse("#19200A")); // dim lime
|
||||
assert_eq!(theme.tab_active_foreground, parse("#C6FF4D")); // nostr-lime
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
//! The dock-area appearance: the outer frame, the split frames, and one
|
||||
//! dock's chrome. Ported from the vendored dock's `DockArea`/`Dock` render
|
||||
//! onto `gpui_base::dock::DockAreaRenderer`.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::ops::Deref as _;
|
||||
use std::rc::Rc;
|
||||
@@ -26,8 +22,7 @@ use crate::tab_panel::SignedTabGroupSkin;
|
||||
use crate::tiles::SignedTilesSkin;
|
||||
use crate::{TAB_BAR_HEIGHT, panel_handle};
|
||||
|
||||
/// What every part of the skin reads, and the dock area it belongs to.
|
||||
/// Shared by reference with the per-container renderers.
|
||||
/// State the skin shares with its per-container renderers.
|
||||
pub(crate) struct SkinShared {
|
||||
area: WeakEntity<DockArea>,
|
||||
toggle_button_visible: Cell<bool>,
|
||||
@@ -53,16 +48,14 @@ impl SkinShared {
|
||||
&self.resizing_dock
|
||||
}
|
||||
|
||||
/// Redraw the area after a setting changed. The skin is not an entity, so
|
||||
/// nothing else would notice.
|
||||
/// Redraw the area after a setting changed. The skin is not an entity, so nothing else would.
|
||||
pub(crate) fn notify(&self, cx: &mut App) {
|
||||
_ = self.area.update(cx, |_, cx| cx.notify());
|
||||
}
|
||||
}
|
||||
|
||||
/// The Signed appearance for a [`DockArea`].
|
||||
///
|
||||
/// Install it at construction, where the area's own weak handle is available:
|
||||
/// Install it in the constructor, the only place the area's weak handle is available.
|
||||
///
|
||||
/// ```ignore
|
||||
/// let dock = cx.new(|cx| {
|
||||
@@ -90,8 +83,7 @@ impl SignedDockSkin {
|
||||
&self.shared
|
||||
}
|
||||
|
||||
/// Whether tab bars offer the affordance that collapses a neighbouring
|
||||
/// dock.
|
||||
/// Whether tab bars offer the affordance that collapses a neighbouring dock.
|
||||
pub fn is_toggle_button_visible(&self) -> bool {
|
||||
self.shared.is_toggle_button_visible()
|
||||
}
|
||||
@@ -112,8 +104,9 @@ impl SignedDockSkin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The payload a dock's resize handle drags. It draws nothing: the handle
|
||||
/// itself is the affordance.
|
||||
/// Payload a dock's resize handle drags.
|
||||
///
|
||||
/// It draws nothing, the handle element is the visible affordance.
|
||||
#[derive(Clone)]
|
||||
struct ResizePanel;
|
||||
|
||||
@@ -144,9 +137,7 @@ impl DockAreaRenderer for SignedDockSkin {
|
||||
}
|
||||
|
||||
fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
// `size_full` is what the old `StackPanel::render` carried; `flex_1`
|
||||
// is belt and braces so the frame never collapses to zero height in
|
||||
// an unsizing parent.
|
||||
// `size_full` and `flex_1` stop the frame collapsing in an unsizing parent.
|
||||
div()
|
||||
.id(("dock-split-frame", node.as_u64()))
|
||||
.size_full()
|
||||
@@ -166,8 +157,8 @@ impl DockAreaRenderer for SignedDockSkin {
|
||||
let placement = dock.placement();
|
||||
let open = dock.is_open();
|
||||
|
||||
// A closed left or right dock takes no space at all; a closed bottom
|
||||
// dock keeps a strip so its tab bar stays clickable.
|
||||
// A closed left or right dock takes no space.
|
||||
// A closed bottom dock keeps a strip so its tab bar stays clickable.
|
||||
if !open && !placement.is_bottom() {
|
||||
return div().into_any_element();
|
||||
}
|
||||
@@ -183,8 +174,7 @@ impl DockAreaRenderer for SignedDockSkin {
|
||||
// Base never builds a dock for the centre.
|
||||
DockPlacement::Center => this,
|
||||
})
|
||||
// The closed bottom dock's strip is the tab bar itself, which is
|
||||
// a full tab bar tall.
|
||||
// The closed bottom dock's strip is the tab bar itself, a full tab bar tall.
|
||||
.when(!open && placement.is_bottom(), |this| {
|
||||
this.h(TAB_BAR_HEIGHT)
|
||||
})
|
||||
@@ -197,9 +187,8 @@ impl DockAreaRenderer for SignedDockSkin {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The "unknown panel" message the old `InvalidPanel` drew. It answers
|
||||
/// `dump` with the state it was handed, so a layout written by a build
|
||||
/// that knows the panel survives a load and save here.
|
||||
/// Placeholder for a panel this build cannot construct.
|
||||
/// It dumps the state it was handed, so the layout survives a load and save.
|
||||
fn build_placeholder(
|
||||
&self,
|
||||
state: &PanelState,
|
||||
@@ -241,10 +230,8 @@ impl SignedDockSkin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns the window's mouse stream into dock resizing. A resize is driven
|
||||
/// by pointer moves anywhere in the window, so this paints nothing and
|
||||
/// exists for its `paint` hook — the only place a window-level mouse
|
||||
/// listener can be registered.
|
||||
/// Turns the window's mouse stream into dock resizing.
|
||||
/// It draws nothing, the `paint` hook is the only window listener registration point.
|
||||
struct DockResizeTracker {
|
||||
dock: DockContext,
|
||||
shared: Rc<SkinShared>,
|
||||
@@ -310,10 +297,8 @@ impl Element for DockResizeTracker {
|
||||
if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
|
||||
return;
|
||||
}
|
||||
// Dragging a closed dock's handle reopens it. The live
|
||||
// state is read rather than the render-time snapshot in
|
||||
// `dock`, which would still say closed for the rest of the
|
||||
// frame and toggle it shut again on the next move.
|
||||
// Dragging a closed dock's handle reopens it.
|
||||
// Read the live state, the snapshot in `dock` would toggle it shut again.
|
||||
let open = shared
|
||||
.area()
|
||||
.upgrade()
|
||||
@@ -332,8 +317,8 @@ impl Element for DockResizeTracker {
|
||||
return;
|
||||
}
|
||||
shared.resizing_dock().set(None);
|
||||
// The size lives on the dock, not in the layout tree, so
|
||||
// nothing else tells a subscriber to persist it.
|
||||
// The size lives on the dock, not the layout tree.
|
||||
// Nothing else tells a subscriber to persist it.
|
||||
_ = shared
|
||||
.area()
|
||||
.update(cx, |_, cx| cx.emit(DockEvent::LayoutChanged));
|
||||
|
||||
@@ -7,10 +7,8 @@ use gpui_component::ActiveTheme as _;
|
||||
|
||||
use crate::Panel;
|
||||
|
||||
/// Stands in for a panel this build cannot construct. It reports the
|
||||
/// original [`PanelState`] from [`dump`](gpui_base::dock::Panel::dump), so
|
||||
/// a layout written by a build that knows the panel survives a load and
|
||||
/// save here.
|
||||
/// Stands in for a panel this build cannot construct.
|
||||
/// It returns the state it was handed, so the layout survives a load and save.
|
||||
pub(crate) struct InvalidPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
+17
-18
@@ -1,15 +1,7 @@
|
||||
//! The Signed dock skin.
|
||||
//!
|
||||
//! The dock engine lives upstream (`gpui_base::dock` owns the layout tree,
|
||||
//! drags, zoom and persistence); this crate is the appearance the app used
|
||||
//! to vendor from gpui-component — a 44px tab bar that doubles as the
|
||||
//! window title bar, with pill tabs, window controls, title-bar dragging
|
||||
//! and previous/next tab buttons.
|
||||
//!
|
||||
//! Everything `gpui_component::dock` exports is re-exported here, so the app
|
||||
//! keeps importing the dock from a single place.
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{Pixels, px};
|
||||
use gpui::{Context, Pixels, Window, px};
|
||||
use gpui_base::dock::PanelView;
|
||||
|
||||
mod dock_area;
|
||||
mod invalid_panel;
|
||||
@@ -19,20 +11,27 @@ mod window_controls;
|
||||
|
||||
pub use dock_area::SignedDockSkin;
|
||||
pub use gpui_component::dock::{
|
||||
AnyDrag, BasePanel, BasePanelView, ClosePanel, DockArea, DockAreaState, DockContext, DockEvent,
|
||||
DockLayout, DockPlacement, DockState, DragPanel, DropIndicator, DropPlaceholderBounds,
|
||||
DropTarget, Panel, PanelControl, PanelEvent, PanelHandle, PanelInfo, PanelState, PanelStyle,
|
||||
PanelView, TitleStyle, ToggleZoom, panel_handle, register_panel,
|
||||
BasePanel, DockArea, DockEvent, DockLayout, DockPlacement, Panel, PanelEvent, panel_handle,
|
||||
};
|
||||
|
||||
/// Add an already-wrapped panel handle to the center of `area`.
|
||||
///
|
||||
/// Every panel entry point opens its panel there.
|
||||
pub fn add_center_panel(
|
||||
area: &mut DockArea,
|
||||
panel: Arc<dyn PanelView>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<DockArea>,
|
||||
) {
|
||||
area.add_panel_view(panel, DockPlacement::Center, None, window, cx);
|
||||
}
|
||||
|
||||
/// The fixed height of the tab bar, which doubles as the window title bar.
|
||||
pub const TAB_BAR_HEIGHT: Pixels = px(44.);
|
||||
|
||||
/// Minimal i18n shim replacing gpui-component's `rust_i18n::t!()`, keeping the
|
||||
/// same `Dock.*` keys resolved to English so the crate has no i18n dependency.
|
||||
/// i18n shim resolving `Dock.*` keys to English, so the crate has no i18n dependency.
|
||||
pub(crate) fn t(key: &'static str) -> &'static str {
|
||||
match key {
|
||||
"Dock.Unnamed" => "Unnamed",
|
||||
"Dock.Close" => "Close",
|
||||
"Dock.Zoom In" => "Zoom In",
|
||||
"Dock.Zoom Out" => "Zoom Out",
|
||||
|
||||
+59
-112
@@ -1,13 +1,3 @@
|
||||
//! The Signed appearance for a tab group.
|
||||
//!
|
||||
//! `gpui_base::dock::TabGroup` owns the behavior — membership, the displayed
|
||||
//! tab, drag hit-testing, the zoom flag — and draws none of it. Everything
|
||||
//! visible is here, ported from the vendored dock: the pill tab bar that
|
||||
//! doubles as the window title bar (with window controls, title-bar
|
||||
//! dragging, and previous/next tab buttons), the toolbar, the ellipsis menu,
|
||||
//! the dock collapse affordance, the drop placeholder, and the styled drag
|
||||
//! preview.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
@@ -27,6 +17,7 @@ use gpui_base::dock::{
|
||||
use gpui_base::{ElementExt, InteractiveElementExt, Tab, Tabs};
|
||||
use gpui_component::animation::{Lerp as _, ease_out_cubic};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::dock::{ClosePanel, PanelControl, PanelHandle, ToggleZoom};
|
||||
use gpui_component::menu::DropdownMenu as _;
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, Disableable as _, IconName, Selectable as _, Sizable as _, h_flex, v_flex,
|
||||
@@ -34,16 +25,12 @@ use gpui_component::{
|
||||
use signed_ui::title_bar_drag_handlers;
|
||||
|
||||
use crate::dock_area::SkinShared;
|
||||
use crate::{
|
||||
ClosePanel, PanelControl, PanelHandle, TAB_BAR_HEIGHT, ToggleZoom, t, window_controls,
|
||||
};
|
||||
use crate::{TAB_BAR_HEIGHT, t, window_controls};
|
||||
|
||||
/// The size the styled drag preview occupies, reported to base so a drop
|
||||
/// placeholder knows where to fly in from.
|
||||
/// The drag preview's size, reported to base for the drop placeholder.
|
||||
const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = size(px(96.), px(30.));
|
||||
|
||||
/// A panel's title, or its registered name when it reached base without this
|
||||
/// crate's handle and so carries no presentation. See [`PanelHandle::of`].
|
||||
/// A panel's title, or its registered name when the panel has no handle.
|
||||
pub(crate) fn panel_title(
|
||||
panel: &Arc<dyn BasePanelView>,
|
||||
window: &mut Window,
|
||||
@@ -56,9 +43,7 @@ pub(crate) fn panel_title(
|
||||
}
|
||||
|
||||
/// The preview that follows the cursor while a panel is dragged.
|
||||
///
|
||||
/// `gpui_base::dock::DragPanel` is the payload and draws nothing; this is the
|
||||
/// appearance half, reintroduced here.
|
||||
/// Base's `DragPanel` is the payload and draws nothing, this is the appearance half.
|
||||
struct DragPanelPreview {
|
||||
panel: Arc<dyn BasePanelView>,
|
||||
}
|
||||
@@ -83,10 +68,8 @@ impl Render for DragPanelPreview {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the zoom affordance goes for the group's displayed panel, or `None`
|
||||
/// when there is none to offer. Both [`Panel::zoom_control`] (where) and
|
||||
/// [`gpui_base::dock::Panel::zoomable`] (whether) must pass; base refuses a
|
||||
/// zoom that fails the latter.
|
||||
/// The zoom affordance for the group's displayed panel, if it offers one.
|
||||
/// The panel must offer a control and be zoomable, base refuses a zoom otherwise.
|
||||
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
||||
let panel = group.active_panel()?;
|
||||
panel
|
||||
@@ -95,8 +78,8 @@ fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// The left-most, top-most tab group in a container — where a left dock's
|
||||
/// collapse affordance goes. Mirrors the old `StackPanel::left_top_tab_panel`.
|
||||
/// The left-most, top-most tab group in a container.
|
||||
/// A left dock's collapse button lives in this group.
|
||||
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
match node.kind() {
|
||||
PaneRef::Tabs { .. } => Some(node.id()),
|
||||
@@ -105,9 +88,8 @@ fn left_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The right-most, top-most tab group. A vertical split stacks its children,
|
||||
/// so its *first* child is the top one; a horizontal split's last child is
|
||||
/// the right-most. Mirrors the old `StackPanel::right_top_tab_panel`.
|
||||
/// The right-most, top-most tab group.
|
||||
/// A vertical split picks its first child, a horizontal split picks its last.
|
||||
fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
match node.kind() {
|
||||
PaneRef::Tabs { .. } => Some(node.id()),
|
||||
@@ -120,23 +102,17 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
}
|
||||
}
|
||||
|
||||
/// One tab group's appearance. Built once per container, so the tab bar's
|
||||
/// scroll position and measured title-bar geometry belong to the group.
|
||||
/// One tab group's appearance, built once per container so its geometry is its own.
|
||||
pub(crate) struct SignedTabGroupSkin {
|
||||
shared: Rc<SkinShared>,
|
||||
scroll_handle: ScrollHandle,
|
||||
/// The displayed tab the last frame drew, so a change scrolls the new tab
|
||||
/// into view.
|
||||
/// The tab shown last frame, so a change scrolls the new one into view.
|
||||
last_active_ix: Cell<Option<usize>>,
|
||||
/// Bounds of the title bar row (the wrapper around the tab bar), in
|
||||
/// window coordinates. Measured via `on_prepaint` to position the
|
||||
/// title-bar drag overlay.
|
||||
/// Bounds of the title bar row, measured to place the title-bar drag overlay.
|
||||
title_bar_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||
/// Bounds of the tab bar's trailing empty space (right after the last
|
||||
/// tab), which marks where the draggable region starts.
|
||||
/// Bounds of the empty strip after the last tab, where the drag region starts.
|
||||
title_bar_strip_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||
/// Bounds of the tab bar's suffix (toolbar) area, which marks where the
|
||||
/// draggable region ends.
|
||||
/// Bounds of the suffix area, where the drag region ends.
|
||||
title_bar_suffix_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||
}
|
||||
|
||||
@@ -152,9 +128,8 @@ impl SignedTabGroupSkin {
|
||||
}
|
||||
}
|
||||
|
||||
/// A group that is the left dock's whole content with a single panel
|
||||
/// draws no chrome at all — the vendored dock rendered such a panel bare,
|
||||
/// and the sidebar is one.
|
||||
/// A group that is the left dock's only group, with one panel, draws no chrome.
|
||||
/// The vendored dock rendered such a panel bare and the sidebar is one.
|
||||
fn is_plain_sidebar_group(&self, group: &TabGroupContext, cx: &mut App) -> bool {
|
||||
let Some(area) = self.shared.area().upgrade() else {
|
||||
return false;
|
||||
@@ -169,11 +144,8 @@ impl SignedTabGroupSkin {
|
||||
left == group.node() && group.panels().len() == 1
|
||||
}
|
||||
|
||||
/// The bottom or right dock whose root tab group this group is, if any.
|
||||
///
|
||||
/// Base bars a dock's only group from being dragged or closed, so the
|
||||
/// dock cannot be emptied — but a bottom/right panel is supposed to be
|
||||
/// closable, so the skin routes around the bar for these groups.
|
||||
/// The bottom or right dock whose root tab group is this one, if any.
|
||||
/// Base keeps a dock's last group, so the skin removes these docks as a whole.
|
||||
fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option<DockPlacement> {
|
||||
let area = self.shared.area().upgrade()?;
|
||||
let area = area.read(cx);
|
||||
@@ -185,10 +157,8 @@ impl SignedTabGroupSkin {
|
||||
})
|
||||
}
|
||||
|
||||
/// The drag payload for the tab at `ix`, or `None` when this group must
|
||||
/// not be rearranged. A locked group is never draggable; a group that is
|
||||
/// a bottom/right dock's only content still is, because the center is
|
||||
/// always there to land in.
|
||||
/// The tab's drag payload, or `None` when the group must not be rearranged.
|
||||
/// A locked group never is, a bottom or right dock root always is.
|
||||
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
|
||||
if group.is_locked() {
|
||||
return None;
|
||||
@@ -199,8 +169,8 @@ impl SignedTabGroupSkin {
|
||||
group.drag_panel(ix, cx)
|
||||
}
|
||||
|
||||
/// Whether a dock's collapse affordance belongs in *this* group's tab
|
||||
/// bar, and which way it points. `None` means this group draws none.
|
||||
/// A dock's collapse button for this group's bar, or `None` when it does not belong.
|
||||
/// The icon direction depends on whether the dock is open.
|
||||
fn dock_toggle_button(
|
||||
&self,
|
||||
placement: DockPlacement,
|
||||
@@ -213,8 +183,7 @@ impl SignedTabGroupSkin {
|
||||
|
||||
let area = self.shared.area().upgrade()?;
|
||||
let area = area.read(cx);
|
||||
// A dock that does not exist is not collapsible, so this covers the
|
||||
// old `left_dock.is_some()` test too.
|
||||
// A missing dock is not collapsible, this also covers the old `left_dock.is_some()` test.
|
||||
if !area.is_dock_collapsible(placement) {
|
||||
return None;
|
||||
}
|
||||
@@ -263,8 +232,8 @@ impl SignedTabGroupSkin {
|
||||
)
|
||||
}
|
||||
|
||||
/// The previous/next tab buttons shown in the tab bar's leading prefix.
|
||||
/// Always rendered, disabled at the ends of the strip (or collapsed).
|
||||
/// The previous and next tab buttons in the tab bar's leading prefix.
|
||||
/// Always rendered, disabled at the strip ends or when collapsed.
|
||||
fn render_prev_next_tab_buttons(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
@@ -306,8 +275,7 @@ impl SignedTabGroupSkin {
|
||||
)
|
||||
}
|
||||
|
||||
/// The trailing controls: the panel's own buttons, the zoom affordance,
|
||||
/// and the ellipsis menu.
|
||||
/// The trailing controls, the panel's own buttons, zoom and the ellipsis menu.
|
||||
fn render_toolbar(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
@@ -323,9 +291,8 @@ impl SignedTabGroupSkin {
|
||||
let control = zoom_control(group, cx);
|
||||
let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible());
|
||||
let menu_zoom = control.is_some_and(|control| control.menu_visible());
|
||||
// A bottom/right dock's only panel cannot be closed through the
|
||||
// group (base keeps a dock's last group), but the skin handles that
|
||||
// close by removing the whole dock, so the item is offered.
|
||||
// A bottom or right dock's only panel cannot close through the group.
|
||||
// The close item is offered, the skin removes the whole dock instead.
|
||||
let closable = group.is_closable()
|
||||
|| (self.is_dock_root_group(group, cx).is_some()
|
||||
&& group.active_panel().is_some_and(|panel| panel.closable(cx)));
|
||||
@@ -397,9 +364,9 @@ impl SignedTabGroupSkin {
|
||||
)
|
||||
}
|
||||
|
||||
/// One tab of the pill strip. While collapsed, tabs lose the active
|
||||
/// style and all interactions, and the strip becomes the way a closed
|
||||
/// bottom dock is opened again.
|
||||
/// One tab of the pill strip.
|
||||
/// While collapsed, tabs lose the active style and all interactions.
|
||||
/// The strip is also how a closed bottom dock is opened again.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_tab(
|
||||
&self,
|
||||
@@ -433,8 +400,7 @@ impl SignedTabGroupSkin {
|
||||
Some(tab_name) => this.child(tab_name),
|
||||
None => this.child(panel_title(&panel, window, cx)),
|
||||
})
|
||||
// Pill presentation: the selected tab is the filled pill, the
|
||||
// rest are transparent until hovered.
|
||||
// Pill style, the selected tab is the filled pill, others show only on hover.
|
||||
.styles(|styles| {
|
||||
styles.selected(|style| {
|
||||
style
|
||||
@@ -457,8 +423,7 @@ impl SignedTabGroupSkin {
|
||||
move |_, window, cx| {
|
||||
group.select_tab(ix, window, cx);
|
||||
|
||||
// Clicking the strip of a collapsed bottom dock is how it
|
||||
// is opened again.
|
||||
// Clicking the strip of a collapsed bottom dock reopens it.
|
||||
if is_bottom_dock && collapsed {
|
||||
_ = area.update(cx, |area, cx| {
|
||||
area.toggle_dock(DockPlacement::Bottom, window, cx);
|
||||
@@ -509,9 +474,8 @@ impl SignedTabGroupSkin {
|
||||
})
|
||||
}
|
||||
|
||||
/// The strip after the last tab: a drop target for panels and host-owned
|
||||
/// drag items. Its left edge (right after the last tab) marks the start
|
||||
/// of the title-bar drag overlay.
|
||||
/// The strip after the last tab, a drop target for panels and other drag items.
|
||||
/// Its left edge marks where the title-bar drag overlay starts.
|
||||
fn render_empty_space(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
@@ -541,9 +505,8 @@ impl SignedTabGroupSkin {
|
||||
let group = group.clone();
|
||||
let node = group.node();
|
||||
move |drag: &DragPanel, window, cx| {
|
||||
// A panel dropped past its own last tab lands in the
|
||||
// final slot; one from elsewhere is appended in the
|
||||
// background.
|
||||
// A panel dropped past its own last tab lands in the final slot.
|
||||
// A panel from elsewhere is appended in the background.
|
||||
let ix = (drag.source() == node).then(|| tabs_count - 1);
|
||||
group.drop_panel(drag.clone(), ix, false, window, cx);
|
||||
}
|
||||
@@ -564,37 +527,30 @@ impl SignedTabGroupSkin {
|
||||
impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
let control = zoom_control(group, cx);
|
||||
// An emptied group — its last panel was dragged away — draws nothing,
|
||||
// so an emptied dock does not leave a bare tab bar behind.
|
||||
// An emptied group draws nothing, so no bare tab bar is left behind.
|
||||
if group.panels().is_empty() {
|
||||
return div().id("tab-panel");
|
||||
}
|
||||
// Closing the only panel of a bottom/right dock would leave an
|
||||
// empty dock, which base refuses; the skin removes the dock instead.
|
||||
// Base refuses an empty dock, so closing its only panel removes the dock.
|
||||
let dock_to_remove = (group.panels().len() <= 1)
|
||||
.then(|| self.is_dock_root_group(group, cx))
|
||||
.flatten();
|
||||
let shared = self.shared.clone();
|
||||
|
||||
// `v_flex`, not `div`: gpui's default display is Block, and in block
|
||||
// layout a child's `flex_grow` is ignored — the content region below
|
||||
// the tab bar would resolve to zero height.
|
||||
// `v_flex`, a plain `div` ignores `flex_grow` and the content would collapse.
|
||||
v_flex()
|
||||
.id("tab-panel")
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.bg(cx.theme().tokens.background)
|
||||
// A collapsed group is a strip of tabs with no content, and the
|
||||
// actions act on content.
|
||||
// A collapsed group has no content, so these actions are not registered.
|
||||
.when(!group.is_collapsed(), |this| {
|
||||
this.on_action({
|
||||
let group = group.clone();
|
||||
move |_: &ToggleZoom, window, cx| {
|
||||
// The affordance decides the control, so a panel
|
||||
// offering none is not zoomed *in* by the keybinding
|
||||
// either. Zooming out is never refused: a panel that
|
||||
// stopped offering the control while zoomed would
|
||||
// otherwise strand the user with no way back.
|
||||
// A panel with no zoom control is not zoomed in by the keybinding.
|
||||
// Zooming out is never refused.
|
||||
// Otherwise a zoomed panel that lost its control would strand the user.
|
||||
if !group.is_zoomed() && control.is_none() {
|
||||
return;
|
||||
}
|
||||
@@ -628,8 +584,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
fn content_frame(&self, group: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
||||
v_flex()
|
||||
.id("active-panel")
|
||||
// A collapsed group draws its tab strip and nothing else, so the
|
||||
// content region must not claim any space.
|
||||
// A collapsed group draws its tab strip only, so the content claims no space.
|
||||
.when(!group.is_collapsed(), |this| this.flex_1())
|
||||
}
|
||||
|
||||
@@ -639,14 +594,12 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
// An emptied group draws no tab bar; the app prunes the emptied
|
||||
// bottom/right dock a moment later.
|
||||
// An emptied group draws no tab bar, the app prunes the emptied dock later.
|
||||
if group.panels().is_empty() {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
// The sidebar group draws no chrome at all, like the vendored dock's
|
||||
// bare `DockItem::Panel`.
|
||||
// The sidebar group draws no chrome, like the vendored `DockItem::Panel`.
|
||||
if self.is_plain_sidebar_group(group, cx) {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
@@ -660,10 +613,9 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
||||
let is_bottom_dock = bottom_dock_button.is_some();
|
||||
|
||||
// macOS: the traffic lights overlay the window's top-left corner.
|
||||
// Only the group whose tab bar actually sits under them reserves the
|
||||
// space — the center's left-most, top-most group when the left dock
|
||||
// is closed or absent.
|
||||
// On macOS the traffic lights overlay the window's top-left corner.
|
||||
// Only the tab bar that sits under them reserves the space.
|
||||
// That is the center's top-left group when the left dock is closed or absent.
|
||||
let needs_traffic_light_padding = cfg!(target_os = "macos")
|
||||
&& self.shared.area().upgrade().is_some_and(|area| {
|
||||
let area = area.read(cx);
|
||||
@@ -674,8 +626,8 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
== Some(group.node())
|
||||
});
|
||||
|
||||
// Bring a newly displayed tab into view. The group owns selection
|
||||
// now, so the skin notices the change rather than being told about it.
|
||||
// Bring a newly displayed tab into view.
|
||||
// The group owns selection, so the skin watches for the change itself.
|
||||
let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
|
||||
let visible: Vec<usize> = group
|
||||
.panels()
|
||||
@@ -690,10 +642,8 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
self.scroll_handle.scroll_to_item(visible_ix);
|
||||
}
|
||||
|
||||
// The tab strip lays out at content width, so the area after the
|
||||
// last tab has no element. Cover that dead zone (last tab's right
|
||||
// edge to suffix's left edge) with a measured overlay so the whole
|
||||
// non-interactive area can drag the window.
|
||||
// The tab strip ends at the last tab, the bar has no element after it.
|
||||
// Cover that dead zone with an overlay so it can drag the window.
|
||||
let drag_overlay = match (
|
||||
self.title_bar_bounds.get(),
|
||||
self.title_bar_strip_bounds.get(),
|
||||
@@ -728,8 +678,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
if !panel.visible(cx) {
|
||||
return None;
|
||||
}
|
||||
// A collapsed group shows no tab as active: the strip is a
|
||||
// way back in, not a selection.
|
||||
// Collapsed tabs never show as active, the strip only reopens the dock.
|
||||
if collapsed {
|
||||
active = false;
|
||||
}
|
||||
@@ -768,7 +717,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
h_flex()
|
||||
.items_center()
|
||||
.top_0()
|
||||
// Right -1 for avoid border overlap with the first tab
|
||||
// -1 px so the border does not overlap the first tab.
|
||||
.right(-px(1.))
|
||||
.h_full()
|
||||
.gap_2()
|
||||
@@ -852,9 +801,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
cx: &mut App,
|
||||
) -> Option<AnyElement> {
|
||||
let (from, to) = (indicator.from(), indicator.to());
|
||||
// The placeholder animates from wherever it was to where the drop
|
||||
// would land, so its own element is positioned at the destination and
|
||||
// the animation only has to walk the difference back to zero.
|
||||
// The element sits at the drop target, the animation walks back from the source.
|
||||
let offset = from.origin() - to.origin();
|
||||
|
||||
Some(
|
||||
|
||||
+16
-38
@@ -1,11 +1,3 @@
|
||||
//! The Signed appearance for a tiles canvas.
|
||||
//!
|
||||
//! `gpui_base::dock::TilesState` owns the geometry — snapping, the resize
|
||||
//! arithmetic, the undo stack, the zoom flag — and draws none of it. The tile
|
||||
//! frame, its title bar and its resize affordances are here, ported from
|
||||
//! gpui-component's `TilesSkin` (the vendored dock had no tiles canvas, so
|
||||
//! there is no local look to preserve).
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
@@ -18,6 +10,7 @@ use gpui_base::dock::{
|
||||
DRAG_BAR_HEIGHT, HANDLE_SIZE, NodeId, ResizeSide, TileContext, TilesRenderer,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::dock::PanelHandle;
|
||||
use gpui_component::menu::{DropdownMenu as _, PopupMenuItem};
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::{
|
||||
@@ -25,8 +18,8 @@ use gpui_component::{
|
||||
};
|
||||
|
||||
use crate::dock_area::SkinShared;
|
||||
use crate::t;
|
||||
use crate::tab_panel::panel_title;
|
||||
use crate::{PanelHandle, t};
|
||||
|
||||
/// How far a resize handle sticks out past the tile's edge.
|
||||
const HANDLE_OFFSET: Pixels = px(-4.);
|
||||
@@ -52,9 +45,7 @@ impl Render for DragResizing {
|
||||
}
|
||||
|
||||
/// One tiles canvas's appearance.
|
||||
///
|
||||
/// Built per canvas — `DockAreaRenderer::tiles_renderer` is called once per
|
||||
/// container — so the scroll position belongs to the canvas it scrolls.
|
||||
/// Built once per container, so its scroll position belongs to the canvas it scrolls.
|
||||
pub(crate) struct SignedTilesSkin {
|
||||
shared: Rc<SkinShared>,
|
||||
scroll_handle: ScrollHandle,
|
||||
@@ -101,11 +92,8 @@ impl SignedTilesSkin {
|
||||
})
|
||||
}
|
||||
|
||||
/// The trailing controls of a tile's title bar: zoom, close and the
|
||||
/// ellipsis menu. They use click handlers rather than the
|
||||
/// [`ToggleZoom`](crate::ToggleZoom)/[`ClosePanel`](crate::ClosePanel)
|
||||
/// actions, which are dispatched to a focused tab group — a tile is
|
||||
/// not one.
|
||||
/// The trailing controls of a tile's title bar, zoom, close and the ellipsis menu.
|
||||
/// They use click handlers, the zoom and close actions target a focused tab group.
|
||||
fn render_tile_controls(
|
||||
&self,
|
||||
tile: &TileContext,
|
||||
@@ -213,21 +201,17 @@ impl TilesRenderer for SignedTilesSkin {
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().tile_radius)
|
||||
// Room for the title bar, which is positioned over the padding so
|
||||
// the panel below it is never covered. Base draws the panel view
|
||||
// as a plain child, so this is the only way to keep the two from
|
||||
// overlapping.
|
||||
// Room for the title bar, which overlays the top padding.
|
||||
// Base draws the panel as a plain child, this keeps them apart.
|
||||
.pt(DRAG_BAR_HEIGHT)
|
||||
// Base installs the stored bounds on an ordinary tile and nothing
|
||||
// at all on a zoomed one — how a zoomed tile fills the dock is
|
||||
// this skin's decision.
|
||||
// Base stores no bounds on a zoomed tile, the skin decides how it fills the dock.
|
||||
.when(tile.is_zoomed(), |this| this.size_full())
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
let tile = tile.clone();
|
||||
move |_, window, cx| tile.bring_to_front(window, cx)
|
||||
})
|
||||
// A gesture can end with the pointer anywhere, so both halves are
|
||||
// needed; each is a no-op unless this tile is the one moving.
|
||||
// A gesture can end anywhere, so both mouse-up hooks run.
|
||||
// Each is a no-op unless this tile is the one that moved.
|
||||
.on_mouse_up(MouseButton::Left, {
|
||||
let tile = tile.clone();
|
||||
move |_, window, cx| {
|
||||
@@ -274,8 +258,7 @@ impl TilesRenderer for SignedTilesSkin {
|
||||
)
|
||||
.children(handle.and_then(|handle| handle.title_suffix(window, cx)))
|
||||
.child(self.render_tile_controls(tile, window, cx))
|
||||
// A zoomed tile is not at its stored bounds, so there is nothing
|
||||
// for a move to mean; base refuses the gesture too.
|
||||
// A zoomed tile is not at its stored bounds, so moving it would mean nothing.
|
||||
.when(!tile.is_zoomed(), |this| {
|
||||
this.cursor_grab()
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
@@ -309,10 +292,8 @@ impl TilesRenderer for SignedTilesSkin {
|
||||
) -> AnyElement {
|
||||
let bounds = tile.bounds();
|
||||
|
||||
// A passive full-tile box so each handle is positioned against the
|
||||
// tile rather than against whatever the flow put it next to. It
|
||||
// registers no interaction of its own, so it does not shadow the panel
|
||||
// underneath.
|
||||
// A passive full-tile box, so handles sit against the tile, not its flow neighbours.
|
||||
// It registers no interaction, so it does not shadow the panel underneath.
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
@@ -376,9 +357,7 @@ impl TilesRenderer for SignedTilesSkin {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The panel of a tile gets `size_full` here; base draws the panel as a
|
||||
/// plain child, so without it a panel that does not size itself has no
|
||||
/// size.
|
||||
/// Gives the tile's panel `size_full`, base draws it as a plain child otherwise.
|
||||
fn panel_frame(&self, tile: &TileContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
||||
h_flex()
|
||||
.id(("tile-panel", tile.panel_id().as_u64()))
|
||||
@@ -386,9 +365,8 @@ impl TilesRenderer for SignedTilesSkin {
|
||||
.size_full()
|
||||
}
|
||||
|
||||
/// The canvas scrollbar. It must be an overlay: the frame is the scroll
|
||||
/// container and base appends the tiles after it, so a scrollbar placed
|
||||
/// inside would paint and hit-test underneath every tile.
|
||||
/// The canvas scrollbar, as an overlay.
|
||||
/// Placed inside the frame it would end up underneath every tile.
|
||||
fn render_overlay(
|
||||
&self,
|
||||
content: Size<Pixels>,
|
||||
|
||||
@@ -147,8 +147,7 @@ pub(crate) fn window_controls(window: &mut Window, cx: &mut App) -> impl IntoEle
|
||||
.items_center()
|
||||
.flex_shrink_0()
|
||||
.h_full()
|
||||
// Like native windows apps, the controls span the title bar but never
|
||||
// grow past the tab bar height.
|
||||
// The controls span the title bar but never grow past the tab bar height.
|
||||
.when(cfg!(target_os = "windows"), |this| {
|
||||
this.max_h(TAB_BAR_HEIGHT)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
//! Render-path smoke tests: the skin reads the dock area while rendering, and
|
||||
//! GPUI panics if an entity is read while it is leased (being updated). These
|
||||
//! pin that the first frame — docks, groups, tab bars — renders without
|
||||
//! tripping the lease check.
|
||||
|
||||
use dock::{BasePanel, Panel, SignedDockSkin, panel_handle};
|
||||
use gpui::{
|
||||
App, AppContext, Context, Empty, EventEmitter, FocusHandle, Focusable, IntoElement, Render,
|
||||
@@ -84,12 +79,10 @@ fn the_first_frame_renders_the_area_and_its_docks(cx: &mut TestAppContext) {
|
||||
});
|
||||
});
|
||||
|
||||
// The first frame walks every render hook — the dock frame, each group's
|
||||
// tab bar, the toolbar — all of which read the dock area.
|
||||
// The first frame walks every render hook, all of which read the dock area.
|
||||
cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||
|
||||
// Emptying a dock leaves an empty group behind; its render must also be
|
||||
// safe (and draw nothing).
|
||||
// Emptying a dock leaves an empty group, its render must also be safe.
|
||||
cx.update(|window, cx| {
|
||||
area.update(cx, |area, cx| {
|
||||
area.remove_panel(bottom, window, cx);
|
||||
|
||||
+16
-89
@@ -1,23 +1,16 @@
|
||||
//! Paths to locations used by Signed.
|
||||
//!
|
||||
//! Follows the same pattern as Zed's `paths` crate: platform-correct base
|
||||
//! directories, resolved once and cached, with an optional custom data dir
|
||||
//! override for portable/dev installs.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// The application name, used to derive platform-specific data, config and
|
||||
/// cache directory paths.
|
||||
/// The application name.
|
||||
///
|
||||
/// It derives the platform-specific data, config and cache directory paths.
|
||||
pub const APP_NAME: &str = "Signed";
|
||||
|
||||
/// Lowercased form of [`APP_NAME`], for use in XDG-style paths on
|
||||
/// Linux/FreeBSD and the macOS `~/.config` fallback.
|
||||
/// Lowercased form of [`APP_NAME`].
|
||||
///
|
||||
/// Used in XDG-style paths on Linux and FreeBSD, and the macOS `~/.config` fallback.
|
||||
pub const APP_NAME_LOWERCASE: &str = "signed";
|
||||
|
||||
/// A custom data directory override, set only by [`set_custom_data_dir`].
|
||||
static CUSTOM_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// The resolved data directory.
|
||||
/// On macOS, this is `~/Library/Application Support/Signed`.
|
||||
/// On Linux/FreeBSD, this is `$XDG_DATA_HOME/signed`.
|
||||
@@ -35,45 +28,24 @@ pub fn home_dir() -> PathBuf {
|
||||
dirs::home_dir().expect("failed to determine home directory")
|
||||
}
|
||||
|
||||
/// Returns the current user's Desktop folder, falling back to the home
|
||||
/// directory (or an empty path) when it can't be determined.
|
||||
/// Returns the current user's Desktop folder.
|
||||
///
|
||||
/// Falls back to the home directory or an empty path when it cannot be determined.
|
||||
pub fn desktop_dir() -> PathBuf {
|
||||
dirs::desktop_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Returns the current user's Documents folder, falling back to the home
|
||||
/// directory (or an empty path) when it can't be determined.
|
||||
/// Returns the current user's Documents folder.
|
||||
///
|
||||
/// Falls back to the home directory or an empty path when it cannot be determined.
|
||||
pub fn documents_dir() -> PathBuf {
|
||||
dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Sets a custom directory for all user data, overriding the default data
|
||||
/// directory. Must be called before any other path operation. The directory
|
||||
/// is created if it doesn't exist and canonicalized to an absolute path.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if called after [`data_dir`] or [`config_dir`] was initialized, or
|
||||
/// if the directory cannot be created/canonicalized.
|
||||
pub fn set_custom_data_dir(dir: &str) -> &'static PathBuf {
|
||||
if CURRENT_DATA_DIR.get().is_some() || CONFIG_DIR.get().is_some() {
|
||||
panic!("set_custom_data_dir called after data_dir or config_dir was initialized");
|
||||
}
|
||||
|
||||
CUSTOM_DATA_DIR.get_or_init(|| {
|
||||
let path = PathBuf::from(dir);
|
||||
std::fs::create_dir_all(&path).expect("failed to create custom data directory");
|
||||
path.canonicalize()
|
||||
.expect("failed to canonicalize custom data directory")
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the configuration directory.
|
||||
pub fn config_dir() -> &'static PathBuf {
|
||||
CONFIG_DIR.get_or_init(|| {
|
||||
if let Some(custom_dir) = CUSTOM_DATA_DIR.get() {
|
||||
custom_dir.join("config")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
if cfg!(target_os = "windows") {
|
||||
dirs::config_dir()
|
||||
.expect("failed to determine RoamingAppData directory")
|
||||
.join(APP_NAME)
|
||||
@@ -93,9 +65,7 @@ pub fn config_dir() -> &'static PathBuf {
|
||||
/// Returns the path to the data directory.
|
||||
pub fn data_dir() -> &'static PathBuf {
|
||||
CURRENT_DATA_DIR.get_or_init(|| {
|
||||
if let Some(custom_dir) = CUSTOM_DATA_DIR.get() {
|
||||
custom_dir.clone()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
if cfg!(target_os = "macos") {
|
||||
home_dir()
|
||||
.join("Library/Application Support")
|
||||
.join(APP_NAME)
|
||||
@@ -116,50 +86,13 @@ pub fn data_dir() -> &'static PathBuf {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the cache directory.
|
||||
pub fn cache_dir() -> &'static PathBuf {
|
||||
static CACHE_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
CACHE_DIR.get_or_init(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
dirs::cache_dir()
|
||||
.expect("failed to determine caches directory")
|
||||
.join(APP_NAME)
|
||||
} else if cfg!(target_os = "windows") {
|
||||
dirs::cache_dir()
|
||||
.expect("failed to determine LocalAppData directory")
|
||||
.join(APP_NAME)
|
||||
} else if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||
if let Ok(flatpak_xdg_cache) = std::env::var("FLATPAK_XDG_CACHE_HOME") {
|
||||
flatpak_xdg_cache.into()
|
||||
} else {
|
||||
dirs::cache_dir().expect("failed to determine XDG_CACHE_HOME directory")
|
||||
}
|
||||
.join(APP_NAME_LOWERCASE)
|
||||
} else {
|
||||
home_dir().join(".cache").join(APP_NAME_LOWERCASE)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the logs directory.
|
||||
pub fn logs_dir() -> &'static PathBuf {
|
||||
static LOGS_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
LOGS_DIR.get_or_init(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
home_dir().join("Library/Logs").join(APP_NAME)
|
||||
} else {
|
||||
data_dir().join("logs")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the nostr database directory (LMDB).
|
||||
/// Returns the path to the nostr database directory, LMDB.
|
||||
pub fn nostr_dir() -> &'static PathBuf {
|
||||
static NOSTR_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
NOSTR_DIR.get_or_init(|| data_dir().join("nostr"))
|
||||
}
|
||||
|
||||
/// Returns the path to the local git clone cache (grasp mirrors).
|
||||
/// Returns the path to the local git clone cache, the grasp mirrors.
|
||||
pub fn repos_dir() -> &'static PathBuf {
|
||||
static REPOS_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
REPOS_DIR.get_or_init(|| data_dir().join("repos"))
|
||||
@@ -170,9 +103,3 @@ pub fn settings_file() -> &'static PathBuf {
|
||||
static SETTINGS_FILE: OnceLock<PathBuf> = OnceLock::new();
|
||||
SETTINGS_FILE.get_or_init(|| config_dir().join("settings.json"))
|
||||
}
|
||||
|
||||
/// Returns the path to the `keymap.json` file.
|
||||
pub fn keymap_file() -> &'static PathBuf {
|
||||
static KEYMAP_FILE: OnceLock<PathBuf> = OnceLock::new();
|
||||
KEYMAP_FILE.get_or_init(|| config_dir().join("keymap.json"))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
//! Persisted application settings for Signed.
|
||||
//!
|
||||
//! The [`Settings`] model holds the user-configurable values that survive
|
||||
//! restarts, and [`SettingsStore`] loads them from and saves them to a JSON
|
||||
//! file on disk (see [`paths::settings_file`]).
|
||||
|
||||
mod settings;
|
||||
mod store;
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The default grasp servers offered when the user hasn't published a
|
||||
/// grasp list (kind `10317`) yet.
|
||||
/// The default grasp servers,
|
||||
/// offered while the user has not published a grasp list.
|
||||
pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [
|
||||
"wss://relay.ngit.dev",
|
||||
"wss://gitnostr.com",
|
||||
@@ -14,7 +14,7 @@ pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AppearanceMode {
|
||||
/// Follow the system appearance (light/dark) at runtime.
|
||||
/// Follow the system appearance, light or dark, at runtime.
|
||||
#[default]
|
||||
System,
|
||||
/// Always use the light theme.
|
||||
@@ -23,12 +23,8 @@ pub enum AppearanceMode {
|
||||
Dark,
|
||||
}
|
||||
|
||||
/// Theme configuration.
|
||||
///
|
||||
/// The fields mirror the gpui-component `Theme` surface the application
|
||||
/// customizes at startup, so applying the settings is a plain field-for-field
|
||||
/// copy. The theme names identify entries in the gpui-component theme
|
||||
/// registry.
|
||||
/// Theme configuration,
|
||||
/// fields mirror the gpui-component `Theme` surface customized at startup.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ThemeSettings {
|
||||
@@ -42,7 +38,7 @@ pub struct ThemeSettings {
|
||||
pub mono_font_size: f32,
|
||||
/// Corner radius for general elements in pixels.
|
||||
pub radius: f32,
|
||||
/// Corner radius for large elements (dialogs, notifications) in pixels.
|
||||
/// Corner radius for large elements, dialogs and notifications, in pixels.
|
||||
pub radius_lg: f32,
|
||||
/// Whether focused controls draw a ring outside their border.
|
||||
pub focus_ring: bool,
|
||||
@@ -69,8 +65,7 @@ impl Default for ThemeSettings {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct GraspServersSettings {
|
||||
/// The servers offered when the user hasn't published a grasp list
|
||||
/// (kind `10317`) yet.
|
||||
/// Servers offered while the user has not published a grasp list, kind `10317`.
|
||||
pub default_servers: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -89,9 +84,8 @@ impl Default for GraspServersSettings {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct LocalReposSettings {
|
||||
/// The directories scanned for local git repositories.
|
||||
///
|
||||
/// Defaults to the user's Desktop and Documents folders.
|
||||
/// The directories scanned for local git repositories,
|
||||
/// defaults to the user's Desktop and Documents folders.
|
||||
pub scan_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
@@ -107,12 +101,35 @@ impl Default for LocalReposSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// A remembered association between a local checkout folder and an announced repository,
|
||||
/// recorded when the user clones a repository or picks a folder in the New PR panel.
|
||||
///
|
||||
/// The panel can then prefill the folder later without asking again.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct CheckoutRecord {
|
||||
/// Local folder of the checkout.
|
||||
pub path: PathBuf,
|
||||
/// Repository address as a string, `30617:<pubkey>:<id>`.
|
||||
pub addr: String,
|
||||
/// Unix seconds of the last use, for freshest-first ordering.
|
||||
pub last_used: u64,
|
||||
}
|
||||
|
||||
/// Remembered local checkouts, see [`CheckoutRecord`].
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CheckoutsSettings {
|
||||
/// The remembered records.
|
||||
/// The latest use of a path and repo pair replaces the older record.
|
||||
pub records: Vec<CheckoutRecord>,
|
||||
}
|
||||
|
||||
/// The create-repository dialog.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CreateRepositorySettings {
|
||||
/// The folder the create-repository dialog defaults to; the user's
|
||||
/// Desktop when unset.
|
||||
/// The folder the create-repository dialog defaults to, the user's Desktop when unset.
|
||||
pub default_folder: Option<PathBuf>,
|
||||
}
|
||||
|
||||
@@ -128,6 +145,8 @@ pub struct Settings {
|
||||
pub grasp_servers: GraspServersSettings,
|
||||
/// Local repository scanning.
|
||||
pub local_repos: LocalReposSettings,
|
||||
/// Remembered local checkouts.
|
||||
pub checkouts: CheckoutsSettings,
|
||||
/// The create-repository dialog.
|
||||
pub create_repository: CreateRepositorySettings,
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ struct GlobalSettingsStore(Entity<SettingsStore>);
|
||||
|
||||
impl Global for GlobalSettingsStore {}
|
||||
|
||||
/// The application settings, loaded from disk at startup and persisted whenever they change.
|
||||
/// The application settings,
|
||||
/// loaded from disk at startup and saved whenever they change.
|
||||
///
|
||||
/// Installed as a global by the app so any part of the UI can read and edit them.
|
||||
pub struct SettingsStore {
|
||||
path: PathBuf,
|
||||
@@ -17,7 +19,7 @@ pub struct SettingsStore {
|
||||
}
|
||||
|
||||
impl SettingsStore {
|
||||
/// Retrieve the global settings store (created at startup by the app).
|
||||
/// Retrieve the global settings store, created at startup by the app.
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalSettingsStore>().0.clone()
|
||||
}
|
||||
@@ -27,9 +29,11 @@ impl SettingsStore {
|
||||
cx.set_global(GlobalSettingsStore(entity));
|
||||
}
|
||||
|
||||
/// Load the settings from `path`, falling back to the defaults when the
|
||||
/// file is missing or unreadable. Missing keys merge with the defaults,
|
||||
/// so older settings files keep working as new settings are added.
|
||||
/// Load the settings from `path`,
|
||||
/// falls back to defaults when the file is missing or unreadable.
|
||||
///
|
||||
/// Missing keys merge with the defaults,
|
||||
/// older settings files keep working as new settings are added.
|
||||
pub fn new(path: impl AsRef<Path>, _cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
path: path.as_ref().to_path_buf(),
|
||||
@@ -78,8 +82,7 @@ impl SettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the settings to disk, replacing the file atomically
|
||||
/// so a crash mid-write cannot corrupt the settings.
|
||||
/// Write the settings to disk, replacing the file atomically.
|
||||
fn save(&self) -> Result<()> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
|
||||
@@ -5,5 +5,4 @@ edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
nostr.workspace = true
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Address of a NIP-34 repository announcement: `30617:<owner-pubkey>:<repo-id>`.
|
||||
/// Address of a NIP-34 repository announcement, `30617:<owner-pubkey>:<repo-id>`.
|
||||
///
|
||||
/// The Rust Nostr SDK's [`Coordinate`] already provides parsing, formatting and hashing for this.
|
||||
/// The alias keeps the repository-specific vocabulary while reusing the SDK type.
|
||||
/// The Rust Nostr SDK's [`Coordinate`] parses, formats and hashes this,
|
||||
/// the alias reuses the SDK type while keeping repository-specific vocabulary.
|
||||
pub type RepoAddr = Coordinate;
|
||||
|
||||
/// Build the address of a NIP-34 repository announcement.
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// ngit / GitWorkshop cover-note extension (kind 1624): a markdown note
|
||||
/// attached to an issue, patch or PR by its author or a repository
|
||||
/// maintainer. Not part of the NIP-34 draft; read support for interop.
|
||||
/// GitWorkshop and `ngit` cover-note extension, kind 1624.
|
||||
///
|
||||
/// A markdown note attached to an issue, patch or PR by its author or a maintainer,
|
||||
/// not part of the NIP-34 draft, read support for interop.
|
||||
pub const COVER_NOTE_KIND: Kind = Kind::Custom(1624);
|
||||
|
||||
/// Whether a kind-1985 label event is a valid annotation of `root`: it
|
||||
/// references the root via a lowercase `e` tag and was authored by the root
|
||||
/// author or a maintainer.
|
||||
/// Whether a kind-1985 label event is a valid annotation of `root`.
|
||||
///
|
||||
/// The event references the root with a lowercase `e` tag,
|
||||
/// its author must be the root author or a maintainer.
|
||||
fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) -> bool {
|
||||
if event.kind != Kind::Label {
|
||||
return false;
|
||||
@@ -22,8 +24,8 @@ fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) ->
|
||||
.any(|tag| tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id))
|
||||
}
|
||||
|
||||
/// Whether a kind-1985 label event declares the `#t` namespace and carries at
|
||||
/// least one `["l", "<value>", "#t"]` label.
|
||||
/// Whether a kind-1985 label event declares the `#t` namespace,
|
||||
/// it must also carry at least one `["l", "<value>", "#t"]` label.
|
||||
fn has_hashtag_labels(event: &Event) -> bool {
|
||||
event.tags.iter().any(|tag| tag.as_slice() == ["L", "#t"])
|
||||
&& event.tags.iter().any(|tag| {
|
||||
@@ -32,10 +34,12 @@ fn has_hashtag_labels(event: &Event) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// The effective hashtag labels of `root`: the `t` tags on the event itself
|
||||
/// (self-reported by its author) plus all labels attached via authorized
|
||||
/// NIP-32 kind-1985 events in the `#t` namespace. Labels are additive — all
|
||||
/// valid label events contribute (no latest-wins semantics).
|
||||
/// Effective hashtag labels of `root`,
|
||||
/// the `t` tags on the event itself, self-reported by its author,
|
||||
/// authorized NIP-32 kind-1985 events in the `#t` namespace add more.
|
||||
///
|
||||
/// Labels are additive, so all valid label events contribute,
|
||||
/// there is no latest-wins semantics.
|
||||
pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -> Vec<String> {
|
||||
let mut labels: Vec<String> = root
|
||||
.tags
|
||||
@@ -61,10 +65,10 @@ pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -
|
||||
labels
|
||||
}
|
||||
|
||||
/// The effective subject/title override of `root`, from authorized kind-1985
|
||||
/// label events in the `#subject` namespace. Only the latest event wins
|
||||
/// (tiebreak: lexicographically larger event id, per NIP-01 replaceable
|
||||
/// semantics). Returns `None` when no valid override exists.
|
||||
/// Subject or title override of `root` from authorized kind-1985 label events,
|
||||
/// only label events in the `#subject` namespace count.
|
||||
///
|
||||
/// Returns `None` when no valid override exists.
|
||||
pub fn subject_override(
|
||||
root: &Event,
|
||||
label_events: &[Event],
|
||||
@@ -103,8 +107,8 @@ pub fn subject_override(
|
||||
})
|
||||
}
|
||||
|
||||
/// The effective hashtag labels and subject override of `root` in one pass
|
||||
/// (mirrors ngit's `get_labels_and_subject`).
|
||||
/// Effective hashtag labels and subject override of `root` in one pass,
|
||||
/// mirrors ngit's `get_labels_and_subject`.
|
||||
pub fn labels_and_subject(
|
||||
root: &Event,
|
||||
label_events: &[Event],
|
||||
@@ -116,9 +120,9 @@ pub fn labels_and_subject(
|
||||
)
|
||||
}
|
||||
|
||||
/// The effective cover note of `root`: the latest authorized kind-1624 event
|
||||
/// (tiebreak: lexicographically larger event id, per NIP-01 replaceable
|
||||
/// semantics). Returns `None` when no valid cover note exists.
|
||||
/// Effective cover note of `root`.
|
||||
///
|
||||
/// Returns `None` when no valid cover note exists.
|
||||
pub fn cover_note<'a>(
|
||||
root: &Event,
|
||||
cover_notes: &'a [Event],
|
||||
|
||||
@@ -2,10 +2,10 @@ use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
|
||||
/// Target of a `nostr://` clone URL (NIP-34 "Nostr Clone URL format").
|
||||
/// Target of a `nostr://` clone URL, as defined by NIP-34.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CloneTarget {
|
||||
/// `nostr://<naddr1...>` — direct repository address.
|
||||
/// `nostr://<naddr1...>` encodes a direct repository address.
|
||||
Addr(RepoAddr),
|
||||
/// `nostr://<npub|nip05>/[relay-hint/]<identifier>`
|
||||
UserRepo {
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// A NIP-22 comment thread: a top-level comment on the root event and its
|
||||
/// nested replies (oldest first at every level).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CommentThread {
|
||||
/// The thread's top-level comment.
|
||||
pub comment: Event,
|
||||
/// Replies to [`Self::comment`], nested recursively.
|
||||
pub replies: Vec<CommentThread>,
|
||||
}
|
||||
|
||||
/// The direct parent of a comment (NIP-22 lowercase `e` tag), or `None` for
|
||||
/// comments without one.
|
||||
fn comment_parent(event: &Event) -> Option<EventId> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "e")
|
||||
.and_then(Tag::content)
|
||||
.and_then(|id| EventId::parse(id).ok())
|
||||
}
|
||||
|
||||
/// Group the comments on a root event (issue / patch / PR) into NIP-22
|
||||
/// threads. A comment whose parent is the root itself starts a thread; other
|
||||
/// comments nest under their parent comment. Threads and replies are ordered
|
||||
/// oldest-first. Replies whose parent comment is missing (e.g. not fetched)
|
||||
/// are placed as top-level threads so they are not dropped.
|
||||
pub fn comment_threads(root: &Event, comments: &[Event]) -> Vec<CommentThread> {
|
||||
// Index comments by their parent id. Comments without a parent tag are
|
||||
// treated as replying to the root event itself.
|
||||
let mut children: HashMap<EventId, Vec<&Event>> = HashMap::new();
|
||||
for comment in comments {
|
||||
let parent = comment_parent(comment).unwrap_or(root.id);
|
||||
children.entry(parent).or_default().push(comment);
|
||||
}
|
||||
for list in children.values_mut() {
|
||||
list.sort_by_key(|event| event.created_at);
|
||||
}
|
||||
|
||||
let mut visited: HashSet<EventId> = HashSet::new();
|
||||
|
||||
fn build(
|
||||
id: EventId,
|
||||
children: &HashMap<EventId, Vec<&Event>>,
|
||||
visited: &mut HashSet<EventId>,
|
||||
) -> Vec<CommentThread> {
|
||||
let Some(list) = children.get(&id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut threads = Vec::new();
|
||||
for event in list {
|
||||
// Guards against malformed reply cycles.
|
||||
if visited.insert(event.id) {
|
||||
threads.push(CommentThread {
|
||||
comment: (*event).clone(),
|
||||
replies: build(event.id, children, visited),
|
||||
});
|
||||
}
|
||||
}
|
||||
threads
|
||||
}
|
||||
|
||||
let mut threads = build(root.id, &children, &mut visited);
|
||||
|
||||
// Orphan replies: their parent comment is unknown, so they never appear
|
||||
// in the tree rooted at the root event; surface them as top-level threads.
|
||||
let mut orphans: Vec<&Event> = comments
|
||||
.iter()
|
||||
.filter(|event| !visited.contains(&event.id))
|
||||
.collect();
|
||||
orphans.sort_by_key(|event| event.created_at);
|
||||
for comment in orphans {
|
||||
if visited.insert(comment.id) {
|
||||
threads.push(CommentThread {
|
||||
comment: comment.clone(),
|
||||
replies: build(comment.id, &children, &mut visited),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
threads
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn comment(keys: &Keys, parent: Option<&Event>, content: &str, created_at: u64) -> Event {
|
||||
let tags = parent
|
||||
.map(|parent| vec![Tag::parse(["e", &parent.id.to_hex()]).expect("valid e tag")])
|
||||
.unwrap_or_default();
|
||||
EventBuilder::new(Kind::Comment, content)
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.finalize(keys)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
fn flatten(threads: &[CommentThread]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for thread in threads {
|
||||
out.push(thread.comment.content.clone());
|
||||
out.extend(flatten(&thread.replies));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nests_replies_under_their_parents() {
|
||||
let keys = Keys::generate();
|
||||
let root = EventBuilder::new(Kind::GitIssue, "issue")
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
|
||||
let a = comment(&keys, Some(&root), "a", 100);
|
||||
let a1 = comment(&keys, Some(&a), "a1", 200);
|
||||
let a2 = comment(&keys, Some(&a), "a2", 300);
|
||||
let b = comment(&keys, Some(&root), "b", 150);
|
||||
|
||||
let threads = comment_threads(&root, &[a2, b, a, a1]);
|
||||
|
||||
assert_eq!(flatten(&threads), vec!["a", "a1", "a2", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_without_a_parent_tag_attach_to_the_root() {
|
||||
let keys = Keys::generate();
|
||||
let root = EventBuilder::new(Kind::GitIssue, "issue")
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
|
||||
// Old-style comments carried no `e` tag at all.
|
||||
let orphan = comment(&keys, None, "no parent", 100);
|
||||
|
||||
let threads = comment_threads(&root, &[orphan]);
|
||||
|
||||
assert_eq!(flatten(&threads), vec!["no parent"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_replies_are_surfaced_as_top_level_threads() {
|
||||
let keys = Keys::generate();
|
||||
let root = EventBuilder::new(Kind::GitIssue, "issue")
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
let a = comment(&keys, Some(&root), "a", 100);
|
||||
|
||||
// `missing` is not in the comment set; its reply should still show up.
|
||||
let missing = EventBuilder::new(Kind::Comment, "missing")
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
let reply_to_missing = comment(&keys, Some(&missing), "reply to missing", 200);
|
||||
|
||||
let threads = comment_threads(&root, &[a, reply_to_missing]);
|
||||
|
||||
assert_eq!(flatten(&threads), vec!["a", "reply to missing"]);
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,17 @@ use std::collections::HashSet;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// NIP-09 deletion requests and NIP-62 vanish requests, used to hide
|
||||
/// deleted events before they reach the UI.
|
||||
/// NIP-09 deletion requests and NIP-62 vanish requests,
|
||||
/// built from the kind-5 and kind-62 events in the local database.
|
||||
///
|
||||
/// Built from the kind-5 / kind-62 events stored in the local database;
|
||||
/// pass any event through [`Deletions::is_deleted`] before displaying it.
|
||||
/// Deleted events are hidden before they reach the UI.
|
||||
///
|
||||
/// Pass any event through [`Deletions::is_deleted`] before showing it.
|
||||
pub struct Deletions {
|
||||
/// `(deleted event id, expected author)` from `e` tags of kind-5 events.
|
||||
ids: HashSet<(EventId, PublicKey)>,
|
||||
/// `(coordinate, expected author, cutoff)` from `a` tags of kind-5 events.
|
||||
///
|
||||
/// All versions of the addressable event up to `cutoff` are deleted.
|
||||
coords: Vec<(Coordinate, PublicKey, Timestamp)>,
|
||||
/// `(author, cutoff)` from kind-62 vanish requests.
|
||||
@@ -34,8 +36,8 @@ impl Deletions {
|
||||
.map(|c| (c, event.pubkey, event.created_at)),
|
||||
);
|
||||
} else if event.kind == Kind::RequestToVanish {
|
||||
// Client-side we can't verify which relay the request targeted,
|
||||
// so any vanish request is honored for the author's events.
|
||||
// Client-side we can't verify which relay the request targeted.
|
||||
// Any vanish request is then honored for the author's events.
|
||||
vanished.push((event.pubkey, event.created_at));
|
||||
}
|
||||
}
|
||||
@@ -48,10 +50,9 @@ impl Deletions {
|
||||
}
|
||||
|
||||
/// Whether the event is covered by a valid deletion or vanish request.
|
||||
/// A request is valid when its author matches the deleted event's author, per NIP-09.
|
||||
///
|
||||
/// A request is only valid when its author matches the deleted event's
|
||||
/// author (NIP-09); addressable events are deleted up to the request's
|
||||
/// `created_at`.
|
||||
/// Addressable events are deleted up to the request's `created_at`.
|
||||
pub fn is_deleted(&self, event: &Event) -> bool {
|
||||
if self
|
||||
.vanished
|
||||
|
||||
@@ -25,7 +25,7 @@ pub fn announcement(addr: &RepoAddr) -> Filter {
|
||||
.identifier(addr.identifier.clone())
|
||||
}
|
||||
|
||||
/// Latest state event (refs / HEAD) for a repository.
|
||||
/// Latest state event for a repository, carrying refs and HEAD.
|
||||
pub fn state(addr: &RepoAddr) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::RepoState)
|
||||
@@ -33,18 +33,18 @@ pub fn state(addr: &RepoAddr) -> Filter {
|
||||
.identifier(addr.identifier.clone())
|
||||
}
|
||||
|
||||
/// All NIP-34 activity addressed to a repository (`#a` tag): issues, PRs,
|
||||
/// patches, statuses and comments (kind 1111).
|
||||
///
|
||||
/// Note: the `a` tag on status events is optional per NIP-34, so statuses
|
||||
/// published without it won't be matched here.
|
||||
/// All NIP-34 activity addressed to a repository via its `#a` tag.
|
||||
/// Covers issues, PRs, patches, statuses and kind-1111 comments.
|
||||
/// The `a` tag is optional on status events per NIP-34.
|
||||
/// Statuses published without it are not matched here.
|
||||
pub fn activity(addr: &RepoAddr) -> Filter {
|
||||
Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr)
|
||||
}
|
||||
|
||||
/// Status events (`1630..=1633`) referencing any of the given root events
|
||||
/// (`#e` tag). Batched: one filter covers all roots, so a negentropy sync
|
||||
/// reconciles them in a single session instead of one per root.
|
||||
/// Status events, kinds `1630..=1633`, referencing any of the given root events.
|
||||
/// They are matched via the `#e` tag. One filter covers all roots.
|
||||
///
|
||||
/// A negentropy sync reconciles them in a single session, not one per root.
|
||||
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||
Filter::new()
|
||||
.kinds([
|
||||
@@ -56,33 +56,29 @@ pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||
.events(roots)
|
||||
}
|
||||
|
||||
/// Cover notes (kind 1624) and NIP-32 label events (kind 1985) referencing
|
||||
/// any of the given root events (`#e` tag), fetched per root like comments
|
||||
/// and statuses because they carry no repository `a` tag. Batched, like
|
||||
/// [`statuses_for`].
|
||||
/// Cover notes and NIP-32 label events referencing any of the given root events.
|
||||
/// These are kinds 1624 and 1985, matched via the `#e` tag.
|
||||
///
|
||||
/// Because they carry no repository `a` tag, they are fetched by root like comments.
|
||||
///
|
||||
/// Batched, like [`statuses_for`].
|
||||
pub fn annotations_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||
Filter::new()
|
||||
.kinds([crate::COVER_NOTE_KIND, Kind::Label])
|
||||
.events(roots)
|
||||
}
|
||||
|
||||
/// A user's grasp list (kind `10317`).
|
||||
/// A user's grasp list, kind `10317`.
|
||||
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::GitUserGraspList)
|
||||
.author(public_key)
|
||||
}
|
||||
|
||||
/// NIP-22 comments (kind `1111`) referencing any of the given root events
|
||||
/// (issues, patches, PRs).
|
||||
/// NIP-22 comments, kind `1111`, referencing any of the given root events.
|
||||
/// The roots are issues, patches and PRs.
|
||||
///
|
||||
/// Comments carry no repository `a` tag, so they must be fetched by their
|
||||
/// root reference. NIP-22 defines the uppercase `E` tag as the thread root
|
||||
/// (used by ngit), but some clients (including Signed) use a lowercase `e`
|
||||
/// tag, so both are matched.
|
||||
///
|
||||
/// Returns two filters because `#E` and `#e` conditions would be ANDed if
|
||||
/// combined into one.
|
||||
/// Returns two filters, since combining `#E` and `#e` would AND the conditions.
|
||||
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
||||
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
|
||||
if roots.is_empty() {
|
||||
@@ -98,49 +94,38 @@ pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
||||
]
|
||||
}
|
||||
|
||||
/// All repositories announced by an author.
|
||||
pub fn announcements_by(public_key: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::GitRepoAnnouncement)
|
||||
.author(public_key)
|
||||
}
|
||||
|
||||
/// All repository announcements (for global discovery).
|
||||
///
|
||||
/// Unbounded: intended for negentropy sync, which reconciles sets
|
||||
/// efficiently regardless of size. Local database queries with this
|
||||
/// filter are served by LMDB, so they stay fast as the database grows.
|
||||
/// All repository announcements, for global discovery.
|
||||
pub fn all_announcements() -> Filter {
|
||||
Filter::new().kind(Kind::GitRepoAnnouncement)
|
||||
}
|
||||
|
||||
/// How far back deletion requests are fetched and stored.
|
||||
///
|
||||
/// A deletion request can only target events created before it, and NIP-34
|
||||
/// events are all far younger than this window, so older requests can never
|
||||
/// match anything shown. Bounding the window keeps the kind-5/62 set (one of
|
||||
/// the largest on public relays) from being fully reconciled on every sync.
|
||||
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
||||
|
||||
/// `now` minus [`DELETIONS_LOOKBACK`], quantized to whole days so identical
|
||||
/// filters hash the same and the backend's sync dedup can match them.
|
||||
/// `now` minus [`DELETIONS_LOOKBACK`].
|
||||
/// Quantized to whole days so identical filters hash the same.
|
||||
///
|
||||
/// This lets the backend's sync dedup match identical filters.
|
||||
fn deletions_since() -> Timestamp {
|
||||
let now = Timestamp::now().as_secs();
|
||||
Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK
|
||||
}
|
||||
|
||||
/// All deletion-related events (NIP-09 kind `5`, NIP-62 kind `62`) within
|
||||
/// [`DELETIONS_LOOKBACK`]. Deletion requests must be known before any other
|
||||
/// event can be shown.
|
||||
/// All deletion-related events within [`DELETIONS_LOOKBACK`].
|
||||
/// These are NIP-09 kind `5` and NIP-62 kind `62`.
|
||||
///
|
||||
/// Deletion requests must be known before any other event is shown.
|
||||
pub fn deletions() -> Filter {
|
||||
Filter::new()
|
||||
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||
.since(deletions_since())
|
||||
}
|
||||
|
||||
/// Deletion events relevant to a single repository: requests authored by
|
||||
/// the repository owner and requests addressed to the repository
|
||||
/// coordinate (`#a` tag).
|
||||
/// Deletion events relevant to a single repository.
|
||||
///
|
||||
/// Requests authored by the repository owner.
|
||||
///
|
||||
/// Requests addressed to the repository coordinate via its `#a` tag.
|
||||
pub fn deletions_for_repo(addr: &RepoAddr) -> Vec<Filter> {
|
||||
vec![
|
||||
Filter::new()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
pub mod addr;
|
||||
pub mod annotations;
|
||||
pub mod clone_url;
|
||||
pub mod comments;
|
||||
pub mod deletions;
|
||||
pub mod filters;
|
||||
pub mod model;
|
||||
@@ -11,7 +10,6 @@ pub mod status;
|
||||
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
||||
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
|
||||
pub use clone_url::{CloneTarget, parse_clone_url};
|
||||
pub use comments::{CommentThread, comment_threads};
|
||||
pub use deletions::Deletions;
|
||||
pub use model::{Announcement, activity_subject, pull_request_patch, pull_request_patches};
|
||||
pub use state::{build_state, parse_state};
|
||||
|
||||
+159
-83
@@ -1,59 +1,52 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use gpui::SharedString;
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
use crate::{RepoAddr, repo_addr};
|
||||
|
||||
/// Parsed NIP-34 repository announcement (plain data, ready for the UI).
|
||||
/// Parsed NIP-34 repository announcement, plain data ready for the UI.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Announcement {
|
||||
/// ID of the announcement event itself.
|
||||
pub event_id: EventId,
|
||||
/// Repository ID (`d` tag).
|
||||
/// Repository ID, the `d` tag.
|
||||
pub id: String,
|
||||
/// Author of the announcement event.
|
||||
pub owner: PublicKey,
|
||||
/// When the announcement was published (for latest-wins resolution).
|
||||
/// When the announcement was published, used for latest-wins resolution.
|
||||
pub created_at: Timestamp,
|
||||
pub name: Option<SharedString>,
|
||||
pub description: Option<SharedString>,
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
/// Webpage URLs for browsing.
|
||||
pub web: Vec<Url>,
|
||||
/// URLs for `git clone`.
|
||||
pub clone: Vec<Url>,
|
||||
/// Relays the repository monitors for patches and issues.
|
||||
pub relays: Vec<RelayUrl>,
|
||||
/// Earliest unique commit ID (`r` tag with `euc` marker).
|
||||
/// Earliest unique commit ID, the `r` tag with `euc` marker.
|
||||
pub euc: Option<String>,
|
||||
/// Other recognized maintainers.
|
||||
pub maintainers: Vec<PublicKey>,
|
||||
/// Value of a `u` tag, if any: this repository is a subordinate fork of
|
||||
/// the referenced upstream (NIP-34).
|
||||
/// Marks the repository as a subordinate fork of the upstream, per NIP-34.
|
||||
pub upstream: Option<Upstream>,
|
||||
/// Hashtags labelling the repository (`t` tags).
|
||||
/// Hashtags labelling the repository, the `t` tags.
|
||||
pub hashtags: Vec<String>,
|
||||
}
|
||||
|
||||
/// The `u` tag of a fork announcement (NIP-34)
|
||||
/// the repository this one is a subordinate fork of. The first value is
|
||||
/// the upstream coordinate (`30617:<pubkey>:<id>`) or a git URL.
|
||||
/// The second is an optional relay hint for the upstream.
|
||||
/// The `u` tag of a fork announcement, per NIP-34.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Upstream {
|
||||
/// Raw first value of the `u` tag (coordinate or git URL).
|
||||
/// Raw first value of the `u` tag, a coordinate or git URL.
|
||||
pub raw: String,
|
||||
/// The upstream `30617:<pubkey>:<id>` coordinate, when the `u` tag
|
||||
/// references a NIP-34 repository; `None` for the git-URL form.
|
||||
/// Upstream repository coordinate when the `u` tag names a NIP-34 repository.
|
||||
/// `None` for the git-URL form.
|
||||
pub addr: Option<RepoAddr>,
|
||||
/// Relay hint for the upstream, if the `u` tag carries one.
|
||||
pub relay_hint: Option<RelayUrl>,
|
||||
}
|
||||
|
||||
impl Upstream {
|
||||
/// Parse the `u` tag values. The first is the upstream coordinate or a
|
||||
/// git URL (the coordinate form may append `|git-url`; the coordinate is
|
||||
/// the part before the first `|`), the second an optional relay hint.
|
||||
/// Parse the `u` tag values.
|
||||
fn parse(raw: &str, relay_hint: Option<&str>) -> Self {
|
||||
let coordinate = raw.split('|').next().unwrap_or(raw);
|
||||
let addr = coordinate
|
||||
@@ -67,19 +60,18 @@ impl Upstream {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text for display: the upstream coordinate when it is a NIP-34
|
||||
/// repository, otherwise the raw `u` value (git-URL form).
|
||||
pub fn display(&self) -> SharedString {
|
||||
/// Text for display.
|
||||
pub fn display(&self) -> String {
|
||||
match &self.addr {
|
||||
Some(addr) => SharedString::from(addr.to_string()),
|
||||
None => SharedString::from(self.raw.clone()),
|
||||
Some(addr) => addr.to_string(),
|
||||
None => self.raw.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Subject of a NIP-34 issue or pull request event: the `subject` tag,
|
||||
/// falling back to the first non-empty line of the content.
|
||||
pub fn activity_subject(event: &Event) -> SharedString {
|
||||
/// Subject of a NIP-34 issue or pull request event.
|
||||
/// Taken from the `subject` tag, else the first non-empty line of the content.
|
||||
pub fn activity_subject(event: &Event) -> String {
|
||||
let subject = event
|
||||
.tags
|
||||
.iter()
|
||||
@@ -89,24 +81,18 @@ pub fn activity_subject(event: &Event) -> SharedString {
|
||||
});
|
||||
|
||||
subject
|
||||
.map(SharedString::from)
|
||||
.or_else(|| {
|
||||
event
|
||||
.content
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.map(SharedString::from)
|
||||
.map(|value| value.to_string())
|
||||
})
|
||||
.unwrap_or(SharedString::from("Untitled"))
|
||||
.unwrap_or("Untitled".to_string())
|
||||
}
|
||||
|
||||
/// The patch set of a pull request: the root patch event (kind `1617`) the
|
||||
/// PR references via its `e` tag, plus every patch of the set chained to it
|
||||
/// with NIP-10 `e` reply tags, in series order (oldest first). When the PR
|
||||
/// has no `e` tag, falls back to the patch producing the PR's tip commit
|
||||
/// (its `commit`/`r` tag, per NIP-34) and walks the reply chain backward to
|
||||
/// the root.
|
||||
/// The patch set of a pull request.
|
||||
///
|
||||
/// Returns an empty list when no patch event can be linked to the PR.
|
||||
pub fn pull_request_patches<'a>(
|
||||
@@ -115,17 +101,18 @@ pub fn pull_request_patches<'a>(
|
||||
) -> Vec<&'a Event> {
|
||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||
|
||||
// The PR references its root patch via an `e` tag; follow the NIP-10
|
||||
// reply chain forward from there (each patch of the set replies to the
|
||||
// previous one). Among several replies (a revision), the newest wins.
|
||||
// The PR references its root patch via an `e` tag.
|
||||
// Follow the NIP-10 reply chain forward from there.
|
||||
// Each patch replies to the previous one, and among several replies the newest wins.
|
||||
if let Some(root_id) = pr.tags.event_ids().next()
|
||||
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
|
||||
{
|
||||
return forward_series(root, &patches);
|
||||
}
|
||||
|
||||
// No `e` tag: the last patch of the set carries the PR's tip commit in
|
||||
// its `commit`/`r` tag; walk the reply chain backward to the root.
|
||||
// The PR has no `e` tag.
|
||||
// The last patch of the set carries the tip commit in its `commit` or `r` tag.
|
||||
// Walk the reply chain backward to the root.
|
||||
let Some(tip) = current_commit_of(pr) else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -156,10 +143,7 @@ pub fn pull_request_patches<'a>(
|
||||
series
|
||||
}
|
||||
|
||||
/// The patch content of a pull request: the contents of every patch event of
|
||||
/// its patch set (see [`pull_request_patches`]) joined in series order,
|
||||
/// falling back to the PR's own content for older PRs that carried the
|
||||
/// patch inline.
|
||||
/// The patch content of a pull request.
|
||||
pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a Event>) -> String {
|
||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||
let series = pull_request_patches(pr, patches.iter().copied());
|
||||
@@ -173,7 +157,7 @@ pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// The chain of patches replying to `root` (NIP-10 `e` tags), oldest first.
|
||||
/// The chain of patches replying to `root` via NIP-10 `e` tags, oldest first.
|
||||
fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
|
||||
let mut series = vec![root];
|
||||
loop {
|
||||
@@ -195,7 +179,7 @@ fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event>
|
||||
series
|
||||
}
|
||||
|
||||
/// The `c` tag of an event (tip of the proposed branch), as hex.
|
||||
/// The `c` tag of an event, the tip of the proposed branch, as hex.
|
||||
fn current_commit_of(event: &Event) -> Option<String> {
|
||||
event
|
||||
.tags
|
||||
@@ -206,8 +190,9 @@ fn current_commit_of(event: &Event) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `patch` produces `commit` (its `commit` or `r` tag), so clients
|
||||
/// can find existing patches for a specific commit.
|
||||
/// Whether `patch` produces `commit`, found via its `commit` or `r` tag.
|
||||
///
|
||||
/// It lets clients find existing patches for a specific commit.
|
||||
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
||||
patch
|
||||
.tags
|
||||
@@ -219,7 +204,9 @@ fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
||||
}
|
||||
|
||||
impl Announcement {
|
||||
/// Parse a kind `30617` event. Returns `None` if the kind is wrong or the `d` tag is missing.
|
||||
/// Parse a kind `30617` event.
|
||||
///
|
||||
/// Returns `None` when the kind is wrong or the `d` tag is missing.
|
||||
pub fn from_event(event: &Event) -> Option<Self> {
|
||||
if event.kind != Kind::GitRepoAnnouncement {
|
||||
return None;
|
||||
@@ -230,8 +217,8 @@ impl Announcement {
|
||||
let mut hashtags: Vec<String> = Vec::new();
|
||||
hashtags.extend(event.tags.hashtags().map(|t| t.to_string()));
|
||||
|
||||
let mut name: Option<SharedString> = None;
|
||||
let mut description: Option<SharedString> = None;
|
||||
let mut name: Option<String> = None;
|
||||
let mut description: Option<String> = None;
|
||||
let mut web: Vec<Url> = Vec::new();
|
||||
let mut clone: Vec<Url> = Vec::new();
|
||||
let mut relays: Vec<RelayUrl> = Vec::new();
|
||||
@@ -241,8 +228,8 @@ impl Announcement {
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Name(value)) => name = Some(value.into()),
|
||||
Ok(Nip34Tag::Description(value)) => description = Some(value.into()),
|
||||
Ok(Nip34Tag::Name(value)) => name = Some(value),
|
||||
Ok(Nip34Tag::Description(value)) => description = Some(value),
|
||||
Ok(Nip34Tag::Web(urls)) => web.extend(urls),
|
||||
Ok(Nip34Tag::Clone(urls)) => clone.extend(urls),
|
||||
Ok(Nip34Tag::Relays(urls)) => relays.extend(urls),
|
||||
@@ -251,8 +238,8 @@ impl Announcement {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// The `u` tag is not modelled by the SDK's `Nip34Tag`; parse it
|
||||
// manually (first wins).
|
||||
// The SDK's `Nip34Tag` does not model the `u` tag, so parse it manually.
|
||||
// Only the first `u` tag is used.
|
||||
if upstream.is_none() && tag.kind() == "u" {
|
||||
let values = tag.as_slice();
|
||||
let raw = values.get(1).map(String::as_str).unwrap_or_default();
|
||||
@@ -280,21 +267,40 @@ impl Announcement {
|
||||
}
|
||||
|
||||
/// The repository address of this announcement.
|
||||
pub fn addr(&self) -> crate::RepoAddr {
|
||||
crate::repo_addr(self.owner, self.id.clone())
|
||||
pub fn addr(&self) -> RepoAddr {
|
||||
repo_addr(self.owner, self.id.clone())
|
||||
}
|
||||
|
||||
/// The name of the repository, or a default if none is provided.
|
||||
pub fn name(&self) -> String {
|
||||
self.name.clone().unwrap_or("Untitled".into())
|
||||
}
|
||||
|
||||
/// Whether this announcement is a fork of the repository at `base`.
|
||||
/// Its `u` tag points at `base`, which also covers permanent forks whose EUC diverged.
|
||||
///
|
||||
/// Or it shares `base`'s earliest unique commit and is not the base itself.
|
||||
pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool {
|
||||
if self.addr() == *base {
|
||||
return false;
|
||||
}
|
||||
if self.upstream.as_ref().and_then(|u| u.addr.as_ref()) == Some(base) {
|
||||
return true;
|
||||
}
|
||||
base_euc.is_some_and(|euc| self.euc.as_deref() == Some(euc))
|
||||
}
|
||||
|
||||
/// The description of the repository, or a default if none is provided.
|
||||
pub fn description(&self) -> SharedString {
|
||||
pub fn description(&self) -> String {
|
||||
self.description
|
||||
.clone()
|
||||
.unwrap_or(SharedString::from("No description"))
|
||||
.unwrap_or("No description".to_string())
|
||||
}
|
||||
|
||||
/// The effective maintainers of this repository: the announced
|
||||
/// `maintainers` plus the announcement author, who asserts themselves as
|
||||
/// a maintainer of the primary project unless a `u` tag marks this
|
||||
/// repository as a subordinate fork (NIP-34).
|
||||
/// The effective maintainers of this repository,
|
||||
/// the announced `maintainers` plus the announcement author.
|
||||
///
|
||||
/// A `u` tag that marks the repository as a subordinate fork excludes them, per NIP-34.
|
||||
pub fn effective_maintainers(&self) -> Vec<PublicKey> {
|
||||
let mut maintainers = self.maintainers.clone();
|
||||
if self.upstream.is_none() && !maintainers.contains(&self.owner) {
|
||||
@@ -303,13 +309,12 @@ impl Announcement {
|
||||
maintainers
|
||||
}
|
||||
|
||||
/// The `git clone` URLs for this repository, deduplicated while
|
||||
/// preserving the announced order (deterministic across calls).
|
||||
pub fn clone_urls(&self) -> Vec<SharedString> {
|
||||
/// The `git clone` URLs for this repository, deduplicated.
|
||||
pub fn clone_urls(&self) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
self.clone
|
||||
.iter()
|
||||
.map(|url| SharedString::from(format!("git clone {url}")))
|
||||
.map(|url| format!("git clone {url}"))
|
||||
.filter(|command| seen.insert(command.clone()))
|
||||
.collect()
|
||||
}
|
||||
@@ -449,8 +454,8 @@ mod tests {
|
||||
let announcement = Announcement::from_event(&event).expect("parses");
|
||||
let upstream = announcement.upstream.expect("parses the u tag");
|
||||
|
||||
// The coordinate part resolves to a repository address; the raw
|
||||
// value keeps the `|git-url` suffix.
|
||||
// The coordinate part resolves to a repository address.
|
||||
// The raw value keeps the `|git-url` suffix.
|
||||
assert_eq!(
|
||||
upstream.addr,
|
||||
Some(crate::repo_addr(
|
||||
@@ -474,8 +479,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_git_url_upstream() {
|
||||
// The `u` tag may reference a non-nostr upstream by git URL only;
|
||||
// there is no repository address to navigate to.
|
||||
// The `u` tag may reference a non-nostr upstream by git URL only.
|
||||
// There is no repository address to navigate to.
|
||||
let event = announcement_event(&[
|
||||
&["d", "my-fork"],
|
||||
&["u", "https://example.com/upstream.git"],
|
||||
@@ -491,6 +496,77 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_fork_of_matches_the_u_tag_coordinate() {
|
||||
// The base repository, announced by the `u` tag's owner.
|
||||
let base = crate::repo_addr(
|
||||
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
||||
"upstream",
|
||||
);
|
||||
let event = announcement_event(&[&["d", "my-fork"], &["u", &base.to_string()]]);
|
||||
let fork = Announcement::from_event(&event).expect("parses");
|
||||
|
||||
// A `u` tag pointing at the base address marks a fork.
|
||||
// This holds even when neither side announces an EUC.
|
||||
assert!(fork.is_fork_of(&base, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_fork_of_matches_a_shared_euc() {
|
||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
// The base repo has no `u` tag. It announces the family EUC.
|
||||
let base_event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]);
|
||||
let base = Announcement::from_event(&base_event).expect("parses");
|
||||
let base_addr = base.addr();
|
||||
|
||||
// A fork with no `u` tag, a pure mirror or cross-hosted clone, shares the EUC.
|
||||
// Clients of the family can then find it.
|
||||
let fork_event = announcement_event(&[&["d", "mirror"], &["r", euc, "euc"]]);
|
||||
let fork = Announcement::from_event(&fork_event).expect("parses");
|
||||
assert!(fork.is_fork_of(&base_addr, base.euc.as_deref()));
|
||||
|
||||
// An unrelated repository with a different EUC is not a fork.
|
||||
let other_event = announcement_event(&[
|
||||
&["d", "other"],
|
||||
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
||||
]);
|
||||
let other = Announcement::from_event(&other_event).expect("parses");
|
||||
assert!(!other.is_fork_of(&base_addr, base.euc.as_deref()));
|
||||
|
||||
// Without a base EUC there is nothing to compare against.
|
||||
assert!(!fork.is_fork_of(&base_addr, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() {
|
||||
// A permanent fork re-announces its EUC, the first commit after the fork.
|
||||
// Only the `u` tag still relates it to the base.
|
||||
let base = crate::repo_addr(
|
||||
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
||||
"upstream",
|
||||
);
|
||||
let base_euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
let event = announcement_event(&[
|
||||
&["d", "my-fork"],
|
||||
&["u", &base.to_string()],
|
||||
&["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
||||
]);
|
||||
let fork = Announcement::from_event(&event).expect("parses");
|
||||
|
||||
assert!(fork.is_fork_of(&base, Some(base_euc)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_fork_of_excludes_the_base_itself() {
|
||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
let event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]);
|
||||
let base = Announcement::from_event(&event).expect("parses");
|
||||
let base_addr = base.addr();
|
||||
|
||||
// The base announcement matches its own EUC but is not a fork of itself.
|
||||
assert!(!base.is_fork_of(&base_addr, base.euc.as_deref()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_maintainers_include_owner_for_primary_repos() {
|
||||
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]);
|
||||
@@ -498,8 +574,8 @@ mod tests {
|
||||
let announcement = Announcement::from_event(&event).expect("parses");
|
||||
let maintainers = announcement.effective_maintainers();
|
||||
|
||||
// The owner asserts themselves as a maintainer of the primary
|
||||
// project (NIP-34), alongside the announced co-maintainers.
|
||||
// The owner asserts themselves as a maintainer of the primary project, per NIP-34.
|
||||
// Announced co-maintainers are included too.
|
||||
assert_eq!(maintainers.len(), 2);
|
||||
assert!(maintainers.contains(&announcement.owner));
|
||||
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")));
|
||||
@@ -516,8 +592,8 @@ mod tests {
|
||||
let announcement = Announcement::from_event(&event).expect("parses");
|
||||
let maintainers = announcement.effective_maintainers();
|
||||
|
||||
// A `u` tag marks the repository as a subordinate fork: the author
|
||||
// is not a maintainer of the primary project (NIP-34).
|
||||
// A `u` tag marks the repository as a subordinate fork.
|
||||
// The author is then not a maintainer of the primary project, per NIP-34.
|
||||
assert!(!maintainers.contains(&announcement.owner));
|
||||
assert_eq!(
|
||||
maintainers,
|
||||
@@ -545,7 +621,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pull_request_patch_falls_back_to_inline_content() {
|
||||
// Older PRs carried the patch in the content; no linked patch event.
|
||||
// Older PRs carried the patch in the content and link no patch event.
|
||||
let pr = pr_event("patch-inline", vec![]);
|
||||
|
||||
assert_eq!(pull_request_patch(&pr, [] as [&Event; 0]), "patch-inline");
|
||||
@@ -572,8 +648,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pull_request_patch_joins_the_whole_patch_set() {
|
||||
// NIP-34: a PR references the root patch; later patches of the set
|
||||
// reply to the previous one (NIP-10 `e` tags).
|
||||
// A PR references the root patch, per NIP-34.
|
||||
// Later patches of the set reply to the previous one via NIP-10 `e` tags.
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
||||
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
||||
@@ -625,8 +701,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
|
||||
// PRs without an `e` tag: the last patch of the set carries the tip
|
||||
// commit in its `r` tag; walk the reply chain backward to the root.
|
||||
// PRs without an `e` tag fall back to the patch producing the tip commit.
|
||||
// Walk the reply chain backward to the root.
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let tip = "1111111111111111111111111111111111111111";
|
||||
let last = patch_event(
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Build a kind `30618` repository state event from refs and HEAD.
|
||||
/// Build a kind `30618` repository state event from refs and HEAD,
|
||||
/// it is published as `ref: refs/heads/<branch>`.
|
||||
///
|
||||
/// `refs` are `(refname, commit-id)` pairs (e.g. `refs/heads/main`); `head`
|
||||
/// is the short branch name HEAD points to, published as
|
||||
/// `ref: refs/heads/<branch>`. The `d` tag matches the repository id.
|
||||
/// The `d` tag matches the repository id.
|
||||
pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
|
||||
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
|
||||
for (name, commit) in refs {
|
||||
@@ -20,8 +19,8 @@ pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> E
|
||||
|
||||
/// Parse a kind `30618` repository state event into refs and HEAD.
|
||||
///
|
||||
/// `refs` are `(refname, commit-id)` pairs; `head` is the branch pointed to
|
||||
/// by the `HEAD` tag, if any.
|
||||
/// `refs` are `(refname, commit-id)` pairs.
|
||||
/// `head` is the branch pointed to by the `HEAD` tag, if any.
|
||||
pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option<String>) {
|
||||
let mut refs = Vec::new();
|
||||
let mut head = None;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Status of a root patch, pull request or issue (kinds `1630..=1633`).
|
||||
/// Status of a root patch, pull request or issue, kinds `1630..=1633`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum RepoStatus {
|
||||
Open,
|
||||
@@ -30,9 +30,9 @@ impl RepoStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether an event references the given root event via an `e` or `E`
|
||||
/// tag. NIP-10 / NIP-34 use the lowercase `e` tag; NIP-22 comments (kind
|
||||
/// `1111`) use the uppercase `E` tag for the root of the thread.
|
||||
/// NIP-10 and NIP-34 use the lowercase `e` tag.
|
||||
///
|
||||
/// NIP-22 comments, kind `1111`, use the uppercase `E` tag for the thread root.
|
||||
pub fn references_root(event: &Event, root: &EventId) -> bool {
|
||||
let root = root.to_hex();
|
||||
event
|
||||
@@ -41,8 +41,8 @@ pub fn references_root(event: &Event, root: &EventId) -> bool {
|
||||
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
|
||||
}
|
||||
|
||||
/// Resolve the status of a root event per NIP-34:
|
||||
/// the most recent status event from the root author or a maintainer wins.
|
||||
/// Resolve the status of a root event per NIP-34.
|
||||
///
|
||||
/// Defaults to [`RepoStatus::Open`].
|
||||
pub fn resolve_status<'a, I>(
|
||||
status_events: I,
|
||||
|
||||
+803
-468
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,6 @@ edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
signed_core = { path = "../signed_core" }
|
||||
|
||||
nostr.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
nostr-connect.workspace = true
|
||||
nostr-gossip-memory.workspace = true
|
||||
|
||||
@@ -12,11 +12,6 @@ use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::signer::UniversalSigner;
|
||||
|
||||
/// Open (or create) the LMDB database at `db_path` and build a client
|
||||
/// configured for Signed, together with a fresh signer.
|
||||
///
|
||||
/// The SDK manages its own internal tokio runtime.
|
||||
/// the returned client can be driven by GPUI's executors.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, UniversalSigner)> {
|
||||
let signer = UniversalSigner::new(Keys::generate());
|
||||
@@ -26,7 +21,7 @@ pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, Universal
|
||||
Ok(with_database(signer, database))
|
||||
}
|
||||
|
||||
/// In-memory database on wasm (no LMDB available).
|
||||
/// In-memory database on wasm, LMDB is unavailable there.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn new_backend() -> Result<(Client, UniversalSigner)> {
|
||||
let signer = UniversalSigner::new(Keys::generate());
|
||||
|
||||
@@ -31,8 +31,7 @@ impl UniversalSignerError {
|
||||
}
|
||||
}
|
||||
|
||||
/// A type-erased signer whose inner signer can be swapped in-place
|
||||
/// (e.g. after login/logout). All clones see the swap.
|
||||
/// A type-erased signer whose inner signer can be swapped in-place.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UniversalSigner {
|
||||
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// A lightweight "something changed" signal for the UI.
|
||||
///
|
||||
/// Heavy data stays in the database; consumers re-query on receipt.
|
||||
/// A lightweight change notification for the UI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Update {
|
||||
pub kind: Kind,
|
||||
/// First `a` tag value of the event, if any (e.g. the repository coordinate).
|
||||
/// First `a` tag value of the event, if any, for example the repository coordinate.
|
||||
pub coordinate: Option<Coordinate>,
|
||||
pub author: PublicKey,
|
||||
pub event_id: EventId,
|
||||
}
|
||||
|
||||
impl Update {
|
||||
@@ -21,7 +18,6 @@ impl Update {
|
||||
kind: event.kind,
|
||||
coordinate,
|
||||
author: event.pubkey,
|
||||
event_id: event.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ publish.workspace = true
|
||||
signed_core = { path = "../signed_core" }
|
||||
signed_git = { path = "../signed_git" }
|
||||
signed_nostr = { path = "../signed_nostr" }
|
||||
settings = { path = "../settings" }
|
||||
utils = { path = "../utils" }
|
||||
|
||||
nostr.workspace = true
|
||||
@@ -24,3 +25,6 @@ log.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rustls = "0.23"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
+424
-356
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,17 +7,12 @@ struct GlobalGitStore(GitCache);
|
||||
|
||||
impl Global for GlobalGitStore {}
|
||||
|
||||
/// Global access to the on-disk git clone cache (grasp mirrors).
|
||||
///
|
||||
/// Installed at startup via [`GitStore::set_global`]; see also
|
||||
/// [`signed_state::init`].
|
||||
/// Global access to the on-disk git clone cache, the grasp mirrors.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitStore(GitCache);
|
||||
|
||||
impl GitStore {
|
||||
/// Register the clone cache rooted at `root` as an app-wide global.
|
||||
/// Replaces any previously installed store (see [`signed_state::init`], which
|
||||
/// installs an empty one).
|
||||
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
|
||||
let store = Self::new(root);
|
||||
cx.set_global(GlobalGitStore(store.0.clone()));
|
||||
@@ -25,10 +20,6 @@ impl GitStore {
|
||||
}
|
||||
|
||||
/// The app-wide clone cache.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if [`GitStore::set_global`] was never called.
|
||||
pub fn global(cx: &App) -> Self {
|
||||
Self(cx.global::<GlobalGitStore>().0.clone())
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
mod backend;
|
||||
mod checkouts;
|
||||
mod git_store;
|
||||
mod local_repos;
|
||||
mod profile;
|
||||
mod refresh;
|
||||
mod repo;
|
||||
mod repo_list;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use backend::{Backend, BackendEvent};
|
||||
pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
|
||||
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
|
||||
pub use git_store::GitStore;
|
||||
use gpui::{App, AppContext, Entity};
|
||||
pub use local_repos::LocalReposStore;
|
||||
@@ -16,16 +19,18 @@ pub use profile::{Profile, ProfileStore};
|
||||
pub use repo::RepoStore;
|
||||
pub use repo_list::{RepoActivityCounts, RepoListStore};
|
||||
use signed_nostr::new_backend;
|
||||
pub use utils::shorten_pubkey;
|
||||
|
||||
/// Initialize the backend and stores, and install them as globals.
|
||||
/// Call once at startup, before opening any window that uses the stores.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -> Entity<Backend> {
|
||||
// rustls uses the `aws_lc_rs` provider by default; ignore if already installed.
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.install_default()
|
||||
.ok();
|
||||
pub fn init(
|
||||
db_path: impl AsRef<Path>,
|
||||
repos_root: impl Into<PathBuf>,
|
||||
scan_paths: Vec<PathBuf>,
|
||||
cx: &mut App,
|
||||
) -> Entity<Backend> {
|
||||
// rustls uses the `aws_lc_rs` provider by default.
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
|
||||
let (client, signer) = cx.foreground_executor().block_on(async move {
|
||||
let path = db_path.as_ref().to_path_buf();
|
||||
@@ -37,16 +42,10 @@ pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -
|
||||
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
||||
Backend::set_global(entity.clone(), cx);
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
|
||||
// Seed the explore list from the local database; relay syncs continue
|
||||
// in the background.
|
||||
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
|
||||
|
||||
// The clone cache is native-only; wasm registers an empty store so
|
||||
// `GitStore::global` still works.
|
||||
GitStore::set_global(PathBuf::new(), cx);
|
||||
|
||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||
GitStore::set_global(repos_root, cx);
|
||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
||||
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
||||
|
||||
entity
|
||||
}
|
||||
@@ -55,13 +54,12 @@ pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn init(cx: &mut App) -> Entity<Backend> {
|
||||
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
|
||||
|
||||
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
||||
Backend::set_global(entity.clone(), cx);
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
|
||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||
GitStore::set_global(PathBuf::new(), cx);
|
||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
|
||||
|
||||
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
|
||||
entity
|
||||
}
|
||||
|
||||
@@ -45,9 +45,7 @@ impl LocalReposStore {
|
||||
store
|
||||
}
|
||||
|
||||
/// Forget a repository that has just been published to NIP-34,
|
||||
/// so it leaves the local list immediately. A later rescan re-discovers it from disk,
|
||||
/// the sidebar additionally hides published repositories by identifier.
|
||||
/// Forget a repository that has just been published to NIP-34.
|
||||
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
|
||||
self.repos = Arc::new(
|
||||
self.repos
|
||||
@@ -95,8 +93,7 @@ impl LocalReposStore {
|
||||
dirty
|
||||
})?;
|
||||
|
||||
// Scans requested while this one was running are coalesced into
|
||||
// a single follow-up scan.
|
||||
// Scans requested while this one ran are coalesced into one follow-up scan.
|
||||
if again {
|
||||
this.update(cx, |this, cx| this.rescan(cx))?;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Error;
|
||||
use flume::{Receiver, RecvTimeoutError, Sender};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Task};
|
||||
use flume::{Receiver, Sender};
|
||||
use gpui::{
|
||||
App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, Task,
|
||||
WeakEntity,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use utils::shorten_pubkey;
|
||||
|
||||
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
|
||||
|
||||
/// A user profile (kind `0` metadata), as plain data for the UI.
|
||||
/// A user profile as plain data for the UI, from the kind-0 metadata.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Profile {
|
||||
public_key: PublicKey,
|
||||
@@ -60,20 +63,15 @@ impl Profile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Message from the fetch task to the main thread.
|
||||
enum Dispatch {
|
||||
/// A batched sync finished; re-read seen profiles from the database.
|
||||
Synced,
|
||||
}
|
||||
|
||||
/// How long to wait for more requests before firing a batched sync.
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Global profile cache. Profiles are fetched in batches and kept as plain
|
||||
/// data; the whole store notifies on change.
|
||||
/// Global profile cache.
|
||||
///
|
||||
/// Profiles are fetched in batches and kept as plain data.
|
||||
pub struct ProfileStore {
|
||||
profiles: HashMap<PublicKey, Profile>,
|
||||
/// Public keys we've already requested this session (main thread only).
|
||||
/// Public keys requested this session, main thread only.
|
||||
seen: RefCell<HashSet<PublicKey>>,
|
||||
/// Sender for queuing fetch requests, batched by a background task.
|
||||
sender: Sender<PublicKey>,
|
||||
@@ -111,24 +109,15 @@ impl ProfileStore {
|
||||
_ => {}
|
||||
});
|
||||
|
||||
// Fetch requests are queued on a channel and synced in batches by a
|
||||
// background task.
|
||||
// Fetch requests are queued on a channel, batched into one sync per debounce window.
|
||||
let client = backend.read(cx).client();
|
||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||
let (dispatch_tx, dispatch_rx) = flume::unbounded::<Dispatch>();
|
||||
let entity = cx.entity().downgrade();
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
tasks.push(cx.background_spawn(async move {
|
||||
Self::handle_requests(&client, &dispatch_tx, &receiver).await
|
||||
}));
|
||||
|
||||
// Re-read seen profiles from the database after each batch sync.
|
||||
tasks.push(cx.spawn(async move |this, cx| {
|
||||
while let Ok(Dispatch::Synced) = dispatch_rx.recv_async().await {
|
||||
this.update(cx, |this, cx| this.apply_seen(cx)).ok();
|
||||
}
|
||||
Ok(())
|
||||
tasks.push(cx.spawn(async move |_this, cx| {
|
||||
Self::handle_requests(entity, &client, &receiver, cx).await
|
||||
}));
|
||||
|
||||
let mut store = Self {
|
||||
@@ -143,8 +132,17 @@ impl ProfileStore {
|
||||
store
|
||||
}
|
||||
|
||||
/// Get a profile. Returns a placeholder (default metadata) and queues a
|
||||
/// fetch if the profile isn't cached yet.
|
||||
/// Track a spawned task, pruning finished tasks first.
|
||||
///
|
||||
/// Keeps the store's task list bounded by the number of in-flight tasks.
|
||||
fn push_task(&mut self, task: Task<Result<(), Error>>) {
|
||||
self.tasks.retain(|task| !task.is_ready());
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Get a profile.
|
||||
///
|
||||
/// Returns a placeholder with default metadata. Queues a fetch when the profile is not cached yet.
|
||||
pub fn get(&self, public_key: &PublicKey) -> Profile {
|
||||
if let Some(profile) = self.profiles.get(public_key) {
|
||||
return profile.clone();
|
||||
@@ -170,7 +168,8 @@ impl ProfileStore {
|
||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
// Parse off the main thread; only plain profiles cross back.
|
||||
// Parse off the main thread.
|
||||
// Only plain profiles cross back.
|
||||
let profiles: Vec<Profile> = events
|
||||
.into_iter()
|
||||
.map(|event| {
|
||||
@@ -182,7 +181,7 @@ impl ProfileStore {
|
||||
Ok::<_, Error>(profiles)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
let profiles = work.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -205,7 +204,8 @@ impl ProfileStore {
|
||||
let filter = Filter::new().kind(Kind::Metadata).author(public_key);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
// Parse off the main thread; only the profile crosses back.
|
||||
// Parse off the main thread.
|
||||
// Only the profile crosses back.
|
||||
let profile = events
|
||||
.into_iter()
|
||||
.max_by_key(|e| e.created_at)
|
||||
@@ -217,7 +217,7 @@ impl ProfileStore {
|
||||
Ok::<_, Error>(profile)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
let profile = work.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -231,8 +231,9 @@ impl ProfileStore {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Re-read the latest metadata of every requested author from the local
|
||||
/// database (used after a sync, which produces no NostrUpdate events).
|
||||
/// Re-read the latest metadata of every requested author from the local database.
|
||||
///
|
||||
/// Used after a sync, which produces no NostrUpdate events.
|
||||
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
||||
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
||||
|
||||
@@ -272,7 +273,7 @@ impl ProfileStore {
|
||||
Ok::<_, Error>(profiles)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
let profiles = work.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -286,44 +287,57 @@ impl ProfileStore {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Sync metadata for requested authors in batches, debounced to collect
|
||||
/// requests. Runs on a background thread; results are dispatched to the
|
||||
/// main thread, which re-reads the database.
|
||||
/// Sync metadata for requested authors in batches, debounced to collect requests.
|
||||
///
|
||||
/// After each batch, the seen profiles are re-read from the database on the main thread.
|
||||
async fn handle_requests(
|
||||
this: WeakEntity<ProfileStore>,
|
||||
client: &Client,
|
||||
dispatch: &Sender<Dispatch>,
|
||||
receiver: &Receiver<PublicKey>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<(), Error> {
|
||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||
|
||||
loop {
|
||||
// Wait for the first request of a batch.
|
||||
match receiver.recv_timeout(BATCH_TIMEOUT) {
|
||||
match receiver.recv_async().await {
|
||||
Ok(public_key) => {
|
||||
batch.insert(public_key);
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => return Ok(()),
|
||||
Err(RecvTimeoutError::Timeout) => continue,
|
||||
};
|
||||
Err(_) => return Ok(()),
|
||||
}
|
||||
|
||||
// Collect everything that arrives within the debounce window.
|
||||
// The channel has no async timeout, race the receive against a timer.
|
||||
let deadline = Instant::now() + BATCH_TIMEOUT;
|
||||
while let Ok(public_key) = receiver.recv_deadline(deadline) {
|
||||
batch.insert(public_key);
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
if now >= deadline {
|
||||
break;
|
||||
}
|
||||
let timer = cx.background_executor().timer(deadline - now);
|
||||
futures::pin_mut!(timer);
|
||||
let recv = receiver.recv_async();
|
||||
futures::pin_mut!(recv);
|
||||
match futures::future::select(recv, timer).await {
|
||||
futures::future::Either::Left((Ok(public_key), _)) => {
|
||||
batch.insert(public_key);
|
||||
}
|
||||
futures::future::Either::Left((Err(_), _)) => return Ok(()),
|
||||
futures::future::Either::Right(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Metadata)
|
||||
.authors(batch.drain().collect::<Vec<PublicKey>>());
|
||||
|
||||
// Negentropy-sync with the bootstrap relays. Synced events are
|
||||
// written to the database directly (no NostrUpdate), so re-apply
|
||||
// from the database afterwards.
|
||||
// Negentropy-sync with the bootstrap relays.
|
||||
// Synced events are written to the database directly, no NostrUpdate.
|
||||
// Re-apply from the database afterwards.
|
||||
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
|
||||
Ok(_) => {
|
||||
if dispatch.send(Dispatch::Synced).is_err() {
|
||||
log::warn!("profile dispatch channel closed, dropping sync result");
|
||||
}
|
||||
let _ = this.update(cx, |this, cx| this.apply_seen(cx));
|
||||
}
|
||||
Err(e) => log::warn!("profile sync failed: {e}"),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/// Refresh coalescing shared by the event stores.
|
||||
///
|
||||
/// [`crate::RepoStore`], [`crate::RepoListStore`] and [`crate::CheckoutsStore`]
|
||||
/// re-query their inputs on a debounce timer with the same policy:
|
||||
/// a request arriving while a run is in flight is folded into a follow-up run,
|
||||
/// a request arriving while the debounce timer is pending is dropped by it.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RefreshGate {
|
||||
/// A run is in flight.
|
||||
running: bool,
|
||||
/// A request arrived while a run was in flight.
|
||||
dirty: bool,
|
||||
/// The debounce timer is pending.
|
||||
debouncing: bool,
|
||||
}
|
||||
|
||||
/// What a refresh request decided.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RefreshRequest {
|
||||
/// No run or timer covers the request, start the debounce timer.
|
||||
Schedule,
|
||||
/// A run or pending timer already covers the request.
|
||||
Fold,
|
||||
}
|
||||
|
||||
impl RefreshGate {
|
||||
/// Whether a run is in flight.
|
||||
pub fn running(&self) -> bool {
|
||||
self.running
|
||||
}
|
||||
|
||||
/// Whether the debounce timer is pending.
|
||||
pub fn debouncing(&self) -> bool {
|
||||
self.debouncing
|
||||
}
|
||||
|
||||
/// A new refresh request arrived.
|
||||
///
|
||||
/// Folded into a follow-up run while one is in flight, dropped while the
|
||||
/// debounce timer is pending, otherwise starts the timer.
|
||||
pub fn request(&mut self) -> RefreshRequest {
|
||||
if self.running {
|
||||
self.dirty = true;
|
||||
RefreshRequest::Fold
|
||||
} else if self.debouncing {
|
||||
RefreshRequest::Fold
|
||||
} else {
|
||||
self.debouncing = true;
|
||||
RefreshRequest::Schedule
|
||||
}
|
||||
}
|
||||
|
||||
/// The debounce timer fired and the run starts now.
|
||||
pub fn begin(&mut self) {
|
||||
self.debouncing = false;
|
||||
self.running = true;
|
||||
}
|
||||
|
||||
/// The run ended. Whether a request arrived while it ran.
|
||||
pub fn finish(&mut self) -> bool {
|
||||
self.running = false;
|
||||
std::mem::take(&mut self.dirty)
|
||||
}
|
||||
|
||||
/// The run was abandoned, e.g. on error. Pending follow-up requests survive.
|
||||
pub fn abort(&mut self) {
|
||||
self.running = false;
|
||||
}
|
||||
}
|
||||
+456
-363
File diff suppressed because it is too large
Load Diff
@@ -8,9 +8,11 @@ use nostr_sdk::prelude::*;
|
||||
use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
|
||||
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||
|
||||
/// Delay between a refresh request and the actual re-query, so bursts of
|
||||
/// events (e.g. sync progress ticks) collapse into one query.
|
||||
/// Delay between a refresh request and the actual re-query.
|
||||
///
|
||||
/// Bursts of events, e.g. sync progress ticks, collapse into one query.
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// How far back activity events count toward a repository's last activity.
|
||||
@@ -20,53 +22,45 @@ struct GlobalRepoListStore(Entity<RepoListStore>);
|
||||
|
||||
impl Global for GlobalRepoListStore {}
|
||||
|
||||
/// Counts of NIP-34 activity events per repository, used to rank the
|
||||
/// explore list by popularity. Each patch event is a pushed commit (or a
|
||||
/// small series), the closest proxy for commit count in the event data.
|
||||
/// NIP-34 activity event counts per repository, ranking the explore list by popularity.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct RepoActivityCounts {
|
||||
/// Root `30611` issue events addressed to the repository.
|
||||
pub issues: u32,
|
||||
/// Root `3063` pull request events addressed to the repository
|
||||
/// (updates to a PR are not new PRs and don't count).
|
||||
/// Root `3063` pull request events addressed to the repository.
|
||||
///
|
||||
/// PR updates are not new PRs and do not count.
|
||||
pub pull_requests: u32,
|
||||
/// `1617` patch events addressed to the repository.
|
||||
pub commits: u32,
|
||||
}
|
||||
|
||||
impl RepoActivityCounts {
|
||||
/// Total issues + pull requests + commits; the popularity ranking key.
|
||||
/// Total issues, pull requests and commits, the popularity ranking key.
|
||||
pub fn score(self) -> u32 {
|
||||
self.issues + self.pull_requests + self.commits
|
||||
}
|
||||
}
|
||||
|
||||
/// Store listing repository announcements (global discovery or per-author).
|
||||
///
|
||||
/// The all-repos store (`author: None`) is created at startup by
|
||||
/// [`crate::init`] and installed as a global, so the explore panel renders
|
||||
/// what's in the local database without waiting for relays.
|
||||
/// Store listing the discovered repository announcements, newest first.
|
||||
pub struct RepoListStore {
|
||||
/// Shared so views can clone the list per frame without a deep copy.
|
||||
pub announcements: Arc<Vec<Announcement>>,
|
||||
/// Latest known activity timestamp per repository
|
||||
/// (announcements, state updates, patches, PRs, issues, statuses).
|
||||
/// Latest known activity timestamp per repository.
|
||||
/// Covers announcements, state updates, patches, PRs, issues and statuses.
|
||||
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
|
||||
/// Issues + pull requests + commits per repository, for the Popular
|
||||
/// ranking of the explore list.
|
||||
/// Issues, pull requests and commits per repository.
|
||||
///
|
||||
/// Used for the Popular ranking of the explore list.
|
||||
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
|
||||
author: Option<PublicKey>,
|
||||
refreshing: bool,
|
||||
refresh_dirty: bool,
|
||||
/// A refresh is waiting out [`REFRESH_DEBOUNCE`].
|
||||
debouncing: bool,
|
||||
/// Refresh coalescing, see [`RefreshGate`].
|
||||
refresh: RefreshGate,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl RepoListStore {
|
||||
/// Retrieve the global explore store (all announcements, created at
|
||||
/// startup by [`crate::init`]).
|
||||
/// Retrieve the global repository list store.
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalRepoListStore>().0.clone()
|
||||
}
|
||||
@@ -75,35 +69,34 @@ impl RepoListStore {
|
||||
cx.set_global(GlobalRepoListStore(entity));
|
||||
}
|
||||
|
||||
/// Create a store. If `author` is `None`, all announcements are listed.
|
||||
pub fn new(author: Option<PublicKey>, cx: &mut Context<Self>) -> Self {
|
||||
/// Create the store listing all announcements.
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let relevant = match event {
|
||||
BackendEvent::NostrUpdate(update) => {
|
||||
// Deletions may target anything we list; always refresh.
|
||||
// Deletions may target anything we list, always refresh.
|
||||
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
|
||||
true
|
||||
} else if filters::ACTIVITY_KINDS.contains(&update.kind) {
|
||||
// Activity (patches, issues, ...) is addressed to repos via
|
||||
// `a` tags, so its author isn't the repo owner; always refresh.
|
||||
// Activity events are addressed to repos via `a` tags.
|
||||
// Their author is not the repo owner, always refresh.
|
||||
true
|
||||
} else {
|
||||
let is_announcement = update.kind == Kind::GitRepoAnnouncement;
|
||||
let is_repo_state = update.kind == Kind::RepoState;
|
||||
let tracked = is_announcement || is_repo_state;
|
||||
tracked && this.author.is_none_or(|a| a == update.author)
|
||||
is_announcement || is_repo_state
|
||||
}
|
||||
}
|
||||
BackendEvent::Published(event) => {
|
||||
let announcement = event.kind == Kind::GitRepoAnnouncement
|
||||
&& this.author.is_none_or(|a| a == event.pubkey);
|
||||
// Locally published deletions (e.g. deleting a repo)
|
||||
// are already in the local database; refresh so they
|
||||
// take effect immediately, like relay deletions.
|
||||
let announcement = event.kind == Kind::GitRepoAnnouncement;
|
||||
|
||||
// Locally published deletions are already in the local database.
|
||||
// Refresh so they take effect immediately, like relay deletions.
|
||||
let deletion =
|
||||
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
|
||||
|
||||
announcement || deletion
|
||||
}
|
||||
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
|
||||
@@ -119,105 +112,90 @@ impl RepoListStore {
|
||||
announcements: Arc::new(Vec::new()),
|
||||
last_activity: Arc::new(HashMap::new()),
|
||||
counts: Arc::new(HashMap::new()),
|
||||
author,
|
||||
refreshing: false,
|
||||
refresh_dirty: false,
|
||||
debouncing: false,
|
||||
refresh: RefreshGate::default(),
|
||||
_subscription: subscription,
|
||||
tasks: Vec::new(),
|
||||
};
|
||||
|
||||
store.subscribe_remote(cx);
|
||||
// Query the local database right away; the list never waits for the
|
||||
// relay syncs started above to finish.
|
||||
// Query the local database right away.
|
||||
// The list never waits for the relay syncs started above to finish.
|
||||
store.refresh_initial(cx);
|
||||
store
|
||||
}
|
||||
|
||||
/// Scope the list to an author (or clear the scope with `None`).
|
||||
pub fn set_author(&mut self, author: Option<PublicKey>, cx: &mut Context<Self>) {
|
||||
self.author = author;
|
||||
self.subscribe_remote(cx);
|
||||
self.refresh(cx);
|
||||
/// The announcements of `user`, newest first.
|
||||
pub fn announcements_of(&self, user: &PublicKey) -> Vec<Announcement> {
|
||||
self.announcements
|
||||
.iter()
|
||||
.filter(|a| a.owner == *user)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Track a spawned task, pruning finished tasks first.
|
||||
///
|
||||
/// Keeps the store's task list bounded by the number of in-flight tasks.
|
||||
fn push_task(&mut self, task: Task<Result<(), Error>>) {
|
||||
self.tasks.retain(|task| !task.is_ready());
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Negentropy-sync announcements with the bootstrap relays.
|
||||
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
let author = self.author;
|
||||
|
||||
backend.update(cx, |backend, cx| {
|
||||
let filter = match author {
|
||||
Some(a) => filters::announcements_by(a),
|
||||
None => filters::all_announcements(),
|
||||
};
|
||||
backend.sync_bootstrap(filter, cx);
|
||||
// Deletion requests (NIP-09/62) must be known before any
|
||||
// announcement can be shown.
|
||||
backend.sync_bootstrap(filters::all_announcements(), cx);
|
||||
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
|
||||
backend.sync_bootstrap(filters::deletions(), cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// One-shot initial load: query the local database immediately (no
|
||||
/// debounce), so stored announcements appear as soon as the app opens.
|
||||
/// Only called from [`Self::new`], before any refresh can be pending.
|
||||
/// One-shot initial load.
|
||||
///
|
||||
/// Query the local database immediately, no debounce.
|
||||
/// Stored announcements appear as soon as the app opens.
|
||||
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
||||
debug_assert!(!self.debouncing);
|
||||
if self.refreshing {
|
||||
self.refresh_dirty = true;
|
||||
debug_assert!(!self.refresh.debouncing());
|
||||
if self.refresh.running() {
|
||||
self.refresh.request();
|
||||
return;
|
||||
}
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
/// Re-query the local database. Latest announcement per repository wins.
|
||||
///
|
||||
/// Debounced: a short delay collapses bursts of requests (e.g. sync
|
||||
/// progress ticks), and requests that arrive while a query is running
|
||||
/// are folded into one follow-up query. The query and processing run on
|
||||
/// a background thread; only the results are applied on the main thread.
|
||||
/// Re-query the local database.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if self.refreshing {
|
||||
self.refresh_dirty = true;
|
||||
if self.refresh.request() != RefreshRequest::Schedule {
|
||||
return;
|
||||
}
|
||||
if self.debouncing {
|
||||
return;
|
||||
}
|
||||
self.debouncing = true;
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.debouncing = false;
|
||||
this.run_refresh(cx);
|
||||
})
|
||||
this.update(cx, |this, cx| this.run_refresh(cx))
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
self.push_task(task);
|
||||
}
|
||||
|
||||
/// One query + apply cycle (debounced entry point).
|
||||
/// One query and apply cycle, the debounced entry point.
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.refreshing = true;
|
||||
self.refresh.begin();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
let author = self.author;
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let filter = match author {
|
||||
Some(a) => filters::announcements_by(a),
|
||||
None => filters::all_announcements(),
|
||||
};
|
||||
|
||||
let filter = filters::all_announcements();
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
let deletion_events = client.database().query(filters::deletions()).await?;
|
||||
let deletions = Deletions::from_events(deletion_events);
|
||||
|
||||
// Dedup and sort off the main thread; only the final list
|
||||
// crosses back into the entity.
|
||||
// Dedup and sort off the main thread.
|
||||
// Only the final list crosses back into the entity.
|
||||
let mut by_repo: HashMap<RepoAddr, Announcement> = HashMap::new();
|
||||
|
||||
for event in events {
|
||||
@@ -242,8 +220,9 @@ impl RepoListStore {
|
||||
let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
|
||||
announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at));
|
||||
|
||||
// Last activity per repository: state updates plus all NIP-34
|
||||
// activity events (patches, PRs, issues, statuses).
|
||||
// Last activity per repository.
|
||||
// State updates count, and all NIP-34 activity events.
|
||||
// The activity events are patches, PRs, issues and statuses.
|
||||
let mut last_activity: HashMap<RepoAddr, Timestamp> = announcements
|
||||
.iter()
|
||||
.map(|a| (a.addr(), a.created_at))
|
||||
@@ -264,8 +243,8 @@ impl RepoListStore {
|
||||
*entry = (*entry).max(event.created_at);
|
||||
}
|
||||
|
||||
// Bound the activity query to a recent window; older repos fall
|
||||
// back to their announcement / state timestamps.
|
||||
// Bound the activity query to a recent window.
|
||||
// Older repos fall back to their announcement or state timestamps.
|
||||
let activity_filter = Filter::new()
|
||||
.kinds(filters::ACTIVITY_KINDS)
|
||||
.since(Timestamp::now() - ACTIVITY_WINDOW);
|
||||
@@ -277,8 +256,8 @@ impl RepoListStore {
|
||||
if addr.kind != Kind::GitRepoAnnouncement {
|
||||
continue;
|
||||
}
|
||||
// Skip events for repos we don't list, so the map can't
|
||||
// grow beyond the number of announcements.
|
||||
// Skip events for repos we do not list.
|
||||
// The map cannot grow beyond the number of announcements.
|
||||
let Some(entry) = last_activity.get_mut(&addr) else {
|
||||
continue;
|
||||
};
|
||||
@@ -286,9 +265,8 @@ impl RepoListStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Popularity counts per repository (issues, pull requests and
|
||||
// patches). Unbounded, unlike the windowed activity query
|
||||
// above, so totals are exact.
|
||||
// Popularity counts per repository, issues, pull requests and patches.
|
||||
// Unbounded, unlike the windowed activity query above, so totals are exact.
|
||||
let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new();
|
||||
let count_filter =
|
||||
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
|
||||
@@ -297,8 +275,8 @@ impl RepoListStore {
|
||||
continue;
|
||||
}
|
||||
for addr in event.tags.coordinates() {
|
||||
// Skip events for repos we don't list, so the map can't
|
||||
// grow beyond the number of announcements.
|
||||
// Skip events for repos we do not list.
|
||||
// The map cannot grow beyond the number of announcements.
|
||||
if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr)
|
||||
{
|
||||
continue;
|
||||
@@ -316,13 +294,13 @@ impl RepoListStore {
|
||||
Ok::<_, Error>((announcements, last_activity, counts))
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.push_task(cx.spawn(async move |this, cx| {
|
||||
let (announcements, last_activity, counts) = match work.await {
|
||||
Ok(results) => results,
|
||||
// Database errors are transient; keep the last list.
|
||||
// Database errors are transient, keep the last list.
|
||||
Err(_) => {
|
||||
return this.update(cx, |this, _cx| {
|
||||
this.refreshing = false;
|
||||
this.refresh.abort();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -333,17 +311,11 @@ impl RepoListStore {
|
||||
this.counts = Arc::new(counts);
|
||||
cx.notify();
|
||||
|
||||
this.refreshing = false;
|
||||
if this.refresh_dirty {
|
||||
this.refresh_dirty = false;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
this.refresh.finish()
|
||||
})?;
|
||||
|
||||
// Requests that arrived while the refresh was running are
|
||||
// coalesced into one follow-up refresh.
|
||||
// Requests that arrived while the refresh was running.
|
||||
// They are coalesced into one follow-up refresh.
|
||||
if again {
|
||||
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ use gpui_component::clipboard::Clipboard;
|
||||
use gpui_component::menu::PopupMenuItem;
|
||||
use gpui_component::{ActiveTheme, StyledExt, h_flex};
|
||||
|
||||
/// A muted command row with a copy button: the value in a mono-friendly,
|
||||
/// truncated line, with a [`Clipboard`] button copying the full value.
|
||||
pub fn copy_row<E>(copy_id: E, command: &SharedString, cx: &App) -> Div
|
||||
/// A muted command row with a copy button.
|
||||
pub fn copy_row<E, T>(copy_id: E, command: T, cx: &App) -> Div
|
||||
where
|
||||
E: Into<ElementId>,
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
let command = command.into();
|
||||
|
||||
h_flex()
|
||||
.h_8()
|
||||
.w_full()
|
||||
@@ -34,10 +36,7 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
/// One row of a copy menu: a small title above the compact label, with
|
||||
/// a copy button that flips to a check while the value is on the clipboard.
|
||||
/// Clicking the row copies and dismisses the menu; the copy button stops
|
||||
/// propagation so the menu stays open. Both copy `copy`, never the label.
|
||||
/// One row of a copy menu, with a small title above the compact label.
|
||||
pub fn menu_copy_row(
|
||||
id: &'static str,
|
||||
title: &'static str,
|
||||
|
||||
@@ -7,11 +7,10 @@ use gpui_base::{Button as BaseButton, Popover, Selectable, StyledExt};
|
||||
use gpui_component::menu::PopupMenu;
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex};
|
||||
|
||||
/// A split dropdown button built on `gpui_base::Popover`: an action element
|
||||
/// with a separate caret trigger that opens a [`PopupMenu`].
|
||||
///
|
||||
/// The action and the caret are caller-supplied elements, so the look stays
|
||||
/// in the application; this component only owns the popover wiring.
|
||||
/// A split dropdown button built on `gpui_base::Popover`.
|
||||
/// An action element with a separate caret trigger that opens a [`PopupMenu`].
|
||||
/// The action and the caret are caller-supplied elements, so the look stays in the app.
|
||||
/// This component only owns the popover wiring.
|
||||
#[derive(IntoElement)]
|
||||
pub struct DropdownButton {
|
||||
id: ElementId,
|
||||
@@ -38,15 +37,16 @@ impl DropdownButton {
|
||||
}
|
||||
}
|
||||
|
||||
/// The action half of the button. It keeps its own icon, label, tooltip
|
||||
/// and click handler.
|
||||
/// The action half of the button.
|
||||
/// It keeps its own icon, label, tooltip and click handler.
|
||||
pub fn action(mut self, action: impl IntoElement + 'static) -> Self {
|
||||
self.action = Some(action.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// The menu built by `builder` — the same signature as gpui-component's
|
||||
/// `DropdownButton::dropdown_menu`, so existing menu code keeps working.
|
||||
/// The menu built by `builder`.
|
||||
/// Matches gpui-component's `DropdownButton::dropdown_menu` signature.
|
||||
/// Existing menu code keeps working.
|
||||
pub fn dropdown_menu(
|
||||
mut self,
|
||||
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
@@ -55,10 +55,9 @@ impl DropdownButton {
|
||||
self
|
||||
}
|
||||
|
||||
/// Which corner of the caret the menu anchors to. Defaults to
|
||||
/// [`Anchor::TopRight`], so the menu's right edge lines up with the
|
||||
/// caret's.
|
||||
#[allow(dead_code)] // API knob; current call sites use the default anchor.
|
||||
/// Which corner of the caret the menu anchors to.
|
||||
/// Defaults to [`Anchor::TopRight`], lining the menu's right edge up with the caret's.
|
||||
#[allow(dead_code)] // API knob, current call sites use the default anchor.
|
||||
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
|
||||
self.anchor = anchor.into();
|
||||
self
|
||||
@@ -71,8 +70,8 @@ impl Styled for DropdownButton {
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the [`PopupMenu`] entity of one popover between renders. Dismissal
|
||||
/// drops it, so the menu is rebuilt with fresh items on the next open.
|
||||
/// Holds the [`PopupMenu`] entity of one popover between renders.
|
||||
/// Dismissal drops it, so the menu is rebuilt with fresh items on the next open.
|
||||
#[derive(Default)]
|
||||
struct DropdownMenuState {
|
||||
menu: Option<Entity<PopupMenu>>,
|
||||
@@ -85,7 +84,8 @@ impl RenderOnce for DropdownButton {
|
||||
"a DropdownButton needs a `dropdown_menu`"
|
||||
);
|
||||
|
||||
// The popover needs its own id: both the container and the popover register keyed state on this window.
|
||||
// The popover needs its own id.
|
||||
// The container and the popover both register keyed state on this window.
|
||||
let popover_id = SharedString::from(format!("{}-popover", self.id));
|
||||
let anchor = self.anchor;
|
||||
let menu_state =
|
||||
@@ -109,8 +109,8 @@ impl RenderOnce for DropdownButton {
|
||||
this.child(
|
||||
Popover::new(popover_id)
|
||||
.anchor(anchor)
|
||||
// The menu dismisses itself on outside click or Escape;
|
||||
// the subscription below closes the popover along with it.
|
||||
// The menu dismisses itself on outside click or Escape.
|
||||
// The subscription below closes the popover along with it.
|
||||
.overlay_closable(false)
|
||||
.trigger_with(caret)
|
||||
.content(
|
||||
@@ -148,8 +148,8 @@ impl RenderOnce for DropdownButton {
|
||||
}
|
||||
}
|
||||
|
||||
/// The default caret: a chevron button the height of a medium button, tinted
|
||||
/// by the theme, with hover and menu-open states.
|
||||
/// The default caret, a chevron button the height of a medium button.
|
||||
/// It is tinted by the theme and styled for hover and menu-open states.
|
||||
fn default_caret(id: impl Into<ElementId>, cx: &App) -> BaseButton {
|
||||
BaseButton::new(id)
|
||||
.h(px(32.))
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::mem::take;
|
||||
|
||||
use futures::FutureExt;
|
||||
use gpui::{
|
||||
App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache,
|
||||
ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash,
|
||||
};
|
||||
|
||||
/// Default number of images each view's cache retains. Loading a new image
|
||||
/// evicts the least recently used entry once this is reached.
|
||||
pub const MAX_IMAGES: usize = 128;
|
||||
|
||||
pub fn image_cache(id: impl Into<ElementId>, max_items: usize) -> AppImageCacheProvider {
|
||||
AppImageCacheProvider {
|
||||
id: id.into(),
|
||||
max_items,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppImageCacheProvider {
|
||||
id: ElementId,
|
||||
max_items: usize,
|
||||
}
|
||||
|
||||
impl ImageCacheProvider for AppImageCacheProvider {
|
||||
fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache {
|
||||
window
|
||||
.with_global_id(self.id.clone(), |id, window| {
|
||||
window.with_element_state(id, |cache, _| {
|
||||
let cache = cache.unwrap_or_else(|| AppImageCache::new(self.max_items, cx));
|
||||
(cache.clone(), cache)
|
||||
})
|
||||
})
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppImageCache {
|
||||
max_items: usize,
|
||||
usage_list: VecDeque<u64>,
|
||||
cache: HashMap<u64, (ImageCacheItem, Resource)>,
|
||||
}
|
||||
|
||||
impl AppImageCache {
|
||||
pub fn new(max_items: usize, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
log::info!("Creating AppImageCacheProvider");
|
||||
cx.on_release(|this: &mut Self, cx| {
|
||||
for (ix, (mut image, resource)) in take(&mut this.cache) {
|
||||
if let Some(Ok(image)) = image.get() {
|
||||
log::info!("Dropping image {ix}");
|
||||
cx.drop_image(image, None);
|
||||
}
|
||||
ImageSource::Resource(resource).remove_asset(cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
AppImageCache {
|
||||
max_items,
|
||||
usage_list: VecDeque::with_capacity(max_items),
|
||||
cache: HashMap::with_capacity(max_items),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageCache for AppImageCache {
|
||||
fn load(
|
||||
&mut self,
|
||||
resource: &Resource,
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut gpui::App,
|
||||
) -> Option<Result<std::sync::Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
|
||||
let hash = hash(resource);
|
||||
|
||||
if let Some(item) = self.cache.get_mut(&hash) {
|
||||
let current_idx = self
|
||||
.usage_list
|
||||
.iter()
|
||||
.position(|item| *item == hash)
|
||||
.expect("cache has an item usage_list doesn't");
|
||||
|
||||
self.usage_list.remove(current_idx);
|
||||
self.usage_list.push_front(hash);
|
||||
|
||||
return item.0.get();
|
||||
}
|
||||
|
||||
let load_future = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
|
||||
let task = cx.background_executor().spawn(load_future).shared();
|
||||
|
||||
if self.usage_list.len() >= self.max_items {
|
||||
log::info!("Image cache is full, evicting oldest item");
|
||||
|
||||
if let Some(oldest) = self.usage_list.pop_back() {
|
||||
let mut image = self
|
||||
.cache
|
||||
.remove(&oldest)
|
||||
.expect("usage_list has an item cache doesn't");
|
||||
|
||||
if let Some(Ok(image)) = image.0.get() {
|
||||
log::info!("requesting image to be dropped");
|
||||
cx.drop_image(image, Some(window));
|
||||
}
|
||||
|
||||
ImageSource::Resource(image.1).remove_asset(cx);
|
||||
}
|
||||
}
|
||||
|
||||
self.cache.insert(
|
||||
hash,
|
||||
(
|
||||
gpui::ImageCacheItem::Loading(task.clone()),
|
||||
resource.clone(),
|
||||
),
|
||||
);
|
||||
self.usage_list.push_front(hash);
|
||||
|
||||
let entity = window.current_view();
|
||||
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
let result = task.await;
|
||||
|
||||
if let Err(err) = result {
|
||||
log::error!("error loading image into cache: {:?}", err);
|
||||
}
|
||||
|
||||
cx.on_next_frame(move |_, cx| {
|
||||
cx.notify(entity);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,3 @@
|
||||
//! Reusable UI components and elements for Signed.
|
||||
//!
|
||||
//! Everything here is presentation-only: the components read theme tokens
|
||||
//! through [`gpui_component::ActiveTheme`] and render with plain GPUI
|
||||
//! elements, so any view in the app can use them without depending on app
|
||||
//! state. They are built on the unstyled `gpui-base` primitives and the
|
||||
//! styled `gpui-component` library.
|
||||
//!
|
||||
//! The crate is organized by component:
|
||||
//!
|
||||
//! - [`PixelAvatar`] — deterministic, offline "pixel art" avatar
|
||||
//! - [`NavItem`] — sidebar navigation row (leading element + label + suffix)
|
||||
//! - [`DropdownButton`] — split button: an action plus a caret that opens a
|
||||
//! [`PopupMenu`], wired through `gpui_base::Popover`
|
||||
//! - [`SegmentButton`] / [`CountBadge`] — segmented filter/tab button with an
|
||||
//! optional count badge
|
||||
//! - [`UserAvatar`] — user picture avatar with a name-initials fallback
|
||||
//! - [`status_badge`] — NIP-34 issue/PR status badge
|
||||
//! - [`placeholder`] — centered muted placeholder message
|
||||
//! - [`copy_row`] / [`menu_copy_row`] — rows with a copy-to-clipboard button
|
||||
//! - [`tree_row`] — one row of a file tree
|
||||
//! - [`setting_row`] / [`setting_block`] — label + description rows for a
|
||||
//! settings dialog, with the control on the right (row) or below (block)
|
||||
//! - [`SelectOption`] — dropdown option with a display label and a stored
|
||||
//! value
|
||||
//! - [`title_bar_drag_handlers`] — make an element behave like a window
|
||||
//! title bar (drag moves the window, double-click zooms)
|
||||
//! - [`image_cache`] — per-view LRU image cache provider
|
||||
//! - [`middle_truncate`] — `[head]...[tail]` string truncation
|
||||
|
||||
mod dropdown_button;
|
||||
mod nav_item;
|
||||
mod pixel_avatar;
|
||||
@@ -40,12 +10,10 @@ mod tree_row;
|
||||
mod user_avatar;
|
||||
|
||||
pub mod copy_row;
|
||||
pub mod image_cache;
|
||||
pub mod util;
|
||||
|
||||
pub use copy_row::{copy_row, menu_copy_row};
|
||||
pub use dropdown_button::DropdownButton;
|
||||
pub use image_cache::{MAX_IMAGES, image_cache};
|
||||
pub use nav_item::NavItem;
|
||||
pub use pixel_avatar::PixelAvatar;
|
||||
pub use placeholder::placeholder;
|
||||
|
||||
@@ -2,9 +2,10 @@ use gpui::prelude::*;
|
||||
use gpui::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, div};
|
||||
use gpui_component::{ActiveTheme, StyledExt, h_flex};
|
||||
|
||||
/// A single navigation entry in a sidebar: an arbitrary leading element
|
||||
/// (an icon, avatar, ...) and a text label with a hover highlight,
|
||||
/// an optional trailing suffix (e.g. a status icon) and an optional click handler.
|
||||
/// A single navigation entry in a sidebar.
|
||||
/// It has an arbitrary leading element, such as an icon or avatar, and a text label.
|
||||
/// Hover highlights the row.
|
||||
/// It can carry a trailing suffix, such as a status icon, and an optional click handler.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[derive(IntoElement)]
|
||||
pub struct NavItem {
|
||||
@@ -12,7 +13,7 @@ pub struct NavItem {
|
||||
style: StyleRefinement,
|
||||
icon: gpui::AnyElement,
|
||||
label: SharedString,
|
||||
/// Trailing element rendered at the right edge of the row, after the (ellipsized) label.
|
||||
/// Trailing element at the right edge of the row, after the ellipsized label.
|
||||
suffix: Option<gpui::AnyElement>,
|
||||
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||
}
|
||||
|
||||
@@ -9,27 +9,30 @@ const GRID_SIZE: usize = 8;
|
||||
const FILL_PROBABILITY: f32 = 0.42;
|
||||
/// Probability that a filled cell uses the accent shade instead of the main color.
|
||||
const ACCENT_PROBABILITY: f32 = 0.25;
|
||||
/// Minimum number of filled left-half cells, so a sparse roll still yields a
|
||||
/// recognizable shape (each left-half cell is mirrored to a right-half one).
|
||||
/// Minimum number of filled left-half cells.
|
||||
/// A sparse roll still yields a recognizable shape.
|
||||
/// Each left-half cell is mirrored to a right-half one.
|
||||
const MIN_FILLED: usize = 5;
|
||||
|
||||
/// A deterministic, offline "pixel art" avatar: an 8×8 grid with horizontal
|
||||
/// mirror symmetry, seeded from a stable string such as the repository id and
|
||||
/// owner public key. The same seed always renders the same avatar.
|
||||
/// Side length of the avatar in pixels, no setter.
|
||||
const AVATAR_SIZE: Pixels = px(16.);
|
||||
|
||||
/// A deterministic, offline pixel-art avatar.
|
||||
/// An 8×8 grid with horizontal mirror symmetry.
|
||||
/// Seeded from a stable string such as the repository id and owner public key.
|
||||
/// The same seed always renders the same avatar.
|
||||
#[derive(IntoElement)]
|
||||
pub struct PixelAvatar {
|
||||
seed: u64,
|
||||
size: Pixels,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl PixelAvatar {
|
||||
/// Create an avatar seeded from `seed`. The seed should be a stable string
|
||||
/// unique to the entity the avatar represents.
|
||||
/// Create an avatar seeded from `seed`.
|
||||
/// The seed should be a stable string unique to the entity the avatar represents.
|
||||
pub fn new(seed: impl AsRef<str>) -> Self {
|
||||
Self {
|
||||
seed: fnv1a(seed.as_ref().as_bytes()),
|
||||
size: px(16.),
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
@@ -77,7 +80,7 @@ impl RenderOnce for PixelAvatar {
|
||||
.grid()
|
||||
.grid_cols(GRID_SIZE as u16)
|
||||
.grid_rows(GRID_SIZE as u16)
|
||||
.size(self.size)
|
||||
.size(AVATAR_SIZE)
|
||||
.flex_shrink_0()
|
||||
.overflow_hidden()
|
||||
.bg(main.opacity(0.16))
|
||||
@@ -85,8 +88,9 @@ impl RenderOnce for PixelAvatar {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the 8×8 cell pattern for `seed`. Cells are `0` (empty), `1`
|
||||
/// (main color) or `2` (accent shade); the right half mirrors the left half.
|
||||
/// Generate the 8×8 cell pattern for `seed`.
|
||||
/// Cells are `0` for empty, `1` for main color and `2` for accent shade.
|
||||
/// The right half mirrors the left half.
|
||||
fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
||||
let mut rng = PixelRng::new(seed);
|
||||
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
|
||||
@@ -102,8 +106,8 @@ fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
||||
}
|
||||
}
|
||||
|
||||
// Sparse rolls can come out nearly empty; top the pattern up to the
|
||||
// minimum fill, scanning from a seeded starting cell.
|
||||
// Sparse rolls can come out nearly empty.
|
||||
// Top the pattern up to the minimum fill, scanning from a seeded starting cell.
|
||||
if filled < MIN_FILLED {
|
||||
let half = GRID_SIZE * GRID_SIZE / 2;
|
||||
let start = (rng.next() % half as u64) as usize;
|
||||
@@ -130,7 +134,7 @@ fn set_cell(pattern: &mut [u8; GRID_SIZE * GRID_SIZE], row: usize, col: usize, v
|
||||
pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)] = value;
|
||||
}
|
||||
|
||||
/// FNV-1a 64-bit hash; stable across platforms and runs.
|
||||
/// FNV-1a 64-bit hash, stable across platforms and runs.
|
||||
fn fnv1a(bytes: &[u8]) -> u64 {
|
||||
let mut hash = 0xcbf2_9ce4_8422_2325u64;
|
||||
for &byte in bytes {
|
||||
|
||||
@@ -3,9 +3,9 @@ use gpui::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, di
|
||||
use gpui_base::{Button as BaseButton, StyledExt};
|
||||
use gpui_component::ActiveTheme;
|
||||
|
||||
/// A small count badge shown after a label, e.g. on a segmented filter
|
||||
/// button ("All 12") or a tab. Rendered from theme tokens; sized for the
|
||||
/// compact header buttons it lives on.
|
||||
/// A small count badge shown after a label.
|
||||
/// Used on segmented filter buttons, like `All 12`, and on tabs.
|
||||
/// Rendered from theme tokens and sized for the compact header buttons it lives on.
|
||||
#[derive(IntoElement)]
|
||||
pub struct CountBadge {
|
||||
count: usize,
|
||||
@@ -46,12 +46,11 @@ impl RenderOnce for CountBadge {
|
||||
}
|
||||
}
|
||||
|
||||
/// A segmented filter/tab button: an icon, a label, an optional [`CountBadge`]
|
||||
/// and a selected (pressed) state, styled from the theme's button tokens.
|
||||
///
|
||||
/// Built on the unstyled `gpui_base::Button`, like the app's other custom
|
||||
/// controls; the `primary` variant uses the primary button tokens for
|
||||
/// call-to-action buttons ("New issue", "New PR").
|
||||
/// A segmented filter/tab button with an icon, a label and an optional [`CountBadge`].
|
||||
/// The selected state renders the button pressed, styled from theme button tokens.
|
||||
/// Built on the unstyled `gpui_base::Button`, like the app's other custom controls.
|
||||
/// The `primary` variant uses the primary button tokens.
|
||||
/// It suits call-to-action buttons such as `New issue` and `New PR`.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[derive(IntoElement)]
|
||||
pub struct SegmentButton {
|
||||
@@ -101,7 +100,7 @@ impl SegmentButton {
|
||||
self
|
||||
}
|
||||
|
||||
/// Use the primary button tokens (for call-to-action buttons).
|
||||
/// Use the primary button tokens, for call-to-action buttons.
|
||||
pub fn primary(mut self) -> Self {
|
||||
self.primary = true;
|
||||
self
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
//! Reusable settings UI: rows and blocks for building a settings dialog, plus
|
||||
//! a labeled dropdown option.
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, SharedString, div};
|
||||
use gpui_component::searchable_list::SearchableListItem;
|
||||
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
||||
|
||||
/// A dropdown option with a display label and a stored value.
|
||||
///
|
||||
/// Renders the `label` in the trigger and the menu, while `value` is what a
|
||||
/// [`gpui_component::select::SelectState`] reports as the selection.
|
||||
/// The trigger and menu render the `label`.
|
||||
/// The `value` is what [`gpui_component::select::SelectState`] reports as the selection.
|
||||
#[derive(Clone)]
|
||||
pub struct SelectOption {
|
||||
value: SharedString,
|
||||
@@ -48,7 +44,7 @@ impl SearchableListItem for SelectOption {
|
||||
}
|
||||
}
|
||||
|
||||
/// A settings row: label + description on the left, control on the right.
|
||||
/// A settings row with the label and description on the left and the control on the right.
|
||||
pub fn setting_row(
|
||||
cx: &App,
|
||||
title: impl Into<SharedString>,
|
||||
@@ -85,8 +81,8 @@ pub fn setting_row(
|
||||
)
|
||||
}
|
||||
|
||||
/// A full-width settings block: title + subtitle in one header, `gap_3`
|
||||
/// between the header and the control below.
|
||||
/// A full-width settings block with title and subtitle in one header.
|
||||
/// `gap_3` separates the header from the control below.
|
||||
pub fn setting_block(
|
||||
cx: &App,
|
||||
title: impl Into<SharedString>,
|
||||
|
||||
@@ -5,8 +5,8 @@ use gpui_component::tooltip::Tooltip;
|
||||
use gpui_component::{ActiveTheme, Icon, Sizable, v_flex};
|
||||
use signed_core::RepoStatus;
|
||||
|
||||
/// The status badge shown next to an issue or pull request: icon + colored square,
|
||||
/// with a tooltip describing the status.
|
||||
/// The status badge shown next to an issue or pull request.
|
||||
/// It has an icon and a colored square, with a tooltip describing the status.
|
||||
pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
|
||||
let (icon, label, tooltip, bg, fg) = match status {
|
||||
RepoStatus::Open => (
|
||||
|
||||
@@ -8,12 +8,12 @@ struct WindowDragState {
|
||||
should_move: bool,
|
||||
}
|
||||
|
||||
/// Make an element behave like a window title bar: dragging it moves the
|
||||
/// window, and double-clicking zooms the window (or performs the platform's
|
||||
/// default title-bar double-click action on macOS).
|
||||
///
|
||||
/// Only the bar's non-interactive areas should get this — tabs are draggable
|
||||
/// (to reorder panels) and must not move the window.
|
||||
/// Make an element behave like a window title bar.
|
||||
/// Dragging it moves the window.
|
||||
/// Double-clicking zooms the window.
|
||||
/// On macOS it runs the platform's default title-bar double-click action.
|
||||
/// Only the bar's non-interactive areas should get this.
|
||||
/// Tabs are draggable to reorder panels and must not move the window.
|
||||
pub fn title_bar_drag_handlers(
|
||||
this: Stateful<Div>,
|
||||
window: &mut Window,
|
||||
|
||||
@@ -4,8 +4,9 @@ use gpui_component::list::ListItem;
|
||||
use gpui_component::tree::TreeEntry;
|
||||
use gpui_component::{Icon, IconName, Sizable, h_flex};
|
||||
|
||||
/// One row of a file tree: icon + name, indented by depth.
|
||||
/// Clicking a file runs `on_click`; folders expand/collapse via the tree itself.
|
||||
/// One row of a file tree, an icon and a name indented by depth.
|
||||
/// Clicking a file runs `on_click`.
|
||||
/// Folders expand and collapse via the tree itself.
|
||||
pub fn tree_row<F>(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem
|
||||
where
|
||||
F: Fn(&mut Window, &mut App) + 'static,
|
||||
|
||||
@@ -3,8 +3,8 @@ use gpui::{App, SharedString, StyleRefinement, Window};
|
||||
use gpui_component::avatar::Avatar;
|
||||
use gpui_component::{ActiveTheme, Sizable, StyledExt};
|
||||
|
||||
/// A user avatar: the gpui-component [`Avatar`] sized small and rounded with
|
||||
/// the theme radius, showing the user's picture or a name-initials fallback.
|
||||
/// A small user avatar from gpui-component [`Avatar`], rounded with the theme radius.
|
||||
/// It shows the user's picture or falls back to name initials.
|
||||
#[derive(IntoElement)]
|
||||
pub struct UserAvatar {
|
||||
name: SharedString,
|
||||
@@ -13,8 +13,8 @@ pub struct UserAvatar {
|
||||
}
|
||||
|
||||
impl UserAvatar {
|
||||
/// Create an avatar for `name`; the name seeds the initials fallback
|
||||
/// shown when no picture is set.
|
||||
/// Create an avatar for `name`.
|
||||
/// The name seeds the initials fallback shown when no picture is set.
|
||||
pub fn new(name: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/// `[head chars]...[tail chars]` middle truncation; the value is left alone
|
||||
/// when it is too short for the ellipsis to save space.
|
||||
/// `[head chars]...[tail chars]` middle truncation.
|
||||
///
|
||||
/// Values too short for the ellipsis to save space are left alone.
|
||||
pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String {
|
||||
let len = value.chars().count();
|
||||
if len <= head + tail + 3 {
|
||||
@@ -32,7 +33,7 @@ mod tests {
|
||||
),
|
||||
"30617:a008...3564d:ngit"
|
||||
);
|
||||
// Too short to save space with the ellipsis: left alone.
|
||||
// Too short to save space with the ellipsis, left alone.
|
||||
assert_eq!(middle_truncate("short", 10, 10), "short");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Format a timestamp as a short relative time (e.g. "3h ago").
|
||||
/// Format a timestamp as a short relative time, e.g. `3h ago`.
|
||||
pub fn relative_time(timestamp: Timestamp) -> String {
|
||||
let now = Timestamp::now().as_secs();
|
||||
let secs = now.saturating_sub(timestamp.as_secs());
|
||||
@@ -20,7 +20,7 @@ pub fn relative_time(timestamp: Timestamp) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a unix timestamp in seconds as a short relative time (e.g. "3h ago").
|
||||
/// Format a unix timestamp in seconds as a short relative time, e.g. `3h ago`.
|
||||
pub fn relative_time_secs(secs: i64) -> String {
|
||||
relative_time(Timestamp::from_secs(secs.max(0) as u64))
|
||||
}
|
||||
|
||||
@@ -23,6 +23,5 @@ gix.workspace = true
|
||||
nostr.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
@@ -3,7 +3,6 @@ mod workspace;
|
||||
|
||||
use gpui::{App, AppContext, Entity, Window};
|
||||
use gpui_component::Root;
|
||||
pub use signed_ui::image_cache;
|
||||
pub use views::{RepoListView, SidebarPanel};
|
||||
pub use workspace::Workspace;
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, SharedString, div};
|
||||
use gpui_component::ActiveTheme;
|
||||
|
||||
/// Progress of an async dialog action: a busy flag disabling the form,
|
||||
/// and an error line shown under it.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DialogProgress {
|
||||
pub busy: bool,
|
||||
pub error: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl DialogProgress {
|
||||
/// An action started, disable the form and clear the previous error.
|
||||
pub fn begin(&mut self) {
|
||||
self.busy = true;
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
/// An action failed, re-enable the form and surface `message`.
|
||||
pub fn fail(&mut self, message: impl Into<SharedString>) {
|
||||
self.busy = false;
|
||||
self.error = Some(message.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared error line under a dialog form, `None` when there is no error.
|
||||
pub fn error_row(error: &Option<SharedString>, cx: &App) -> Option<AnyElement> {
|
||||
error.as_ref().map(|message| {
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(message.clone())
|
||||
.into_any_element()
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod dialog_state;
|
||||
mod repo_detail;
|
||||
mod repo_list;
|
||||
pub(crate) mod sidebar;
|
||||
|
||||
@@ -7,8 +7,7 @@ use signed_core::Announcement;
|
||||
use signed_state::ProfileStore;
|
||||
use signed_ui::{UserAvatar, middle_truncate};
|
||||
|
||||
/// Open the "About" dialog: every field of the repository's announcement
|
||||
/// event (NIP-34, kind 30617), as parsed into [`Announcement`].
|
||||
/// Open the About dialog showing every field of the announcement event.
|
||||
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
|
||||
window.open_dialog(cx, move |dialog, _window, cx| {
|
||||
let announcement = announcement.clone();
|
||||
@@ -22,8 +21,7 @@ pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window,
|
||||
});
|
||||
}
|
||||
|
||||
/// The announcement's fields as labeled rows; hex identifiers carry a copy
|
||||
/// button, multi-value tags one line per value.
|
||||
/// The announcement's fields as labeled rows.
|
||||
fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
||||
let mut rows: Vec<AnyElement> = Vec::new();
|
||||
|
||||
@@ -32,8 +30,9 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
||||
text(
|
||||
announcement
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| SharedString::from("—")),
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from("-")),
|
||||
),
|
||||
cx,
|
||||
));
|
||||
@@ -43,8 +42,9 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
||||
text(
|
||||
announcement
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_else(|| SharedString::from("—")),
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from("-")),
|
||||
),
|
||||
cx,
|
||||
));
|
||||
@@ -116,7 +116,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
||||
v_flex().gap_3().w_full().children(rows).into_any_element()
|
||||
}
|
||||
|
||||
/// One info row: a small muted label above the value.
|
||||
/// One info row with a small muted label above the value.
|
||||
fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
@@ -133,7 +133,12 @@ fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
|
||||
}
|
||||
|
||||
/// Plain text value, wrapping within the dialog.
|
||||
fn text(value: SharedString) -> AnyElement {
|
||||
fn text<T>(value: T) -> AnyElement
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
let value = value.into();
|
||||
|
||||
div()
|
||||
.text_sm()
|
||||
.w_full()
|
||||
@@ -159,8 +164,10 @@ fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// One row per maintainer: avatar and display name (falling back to a
|
||||
/// shortened npub), with a copy button for the full pubkey.
|
||||
/// One row per maintainer with avatar and display name.
|
||||
/// The display name falls back to a shortened npub.
|
||||
///
|
||||
/// A copy button copies the full pubkey.
|
||||
fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
v_flex()
|
||||
@@ -190,8 +197,9 @@ fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// One row per item of a multi-value tag: the value is truncated to a single
|
||||
/// line, with a copy button that copies the full value.
|
||||
/// One row per item of a multi-value tag.
|
||||
///
|
||||
/// The value is truncated to a single line, with a copy button for the full value.
|
||||
fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
|
||||
@@ -16,8 +16,9 @@ use super::helpers::{code_language, is_markdown_path};
|
||||
const TREE_WIDTH: f32 = 240.;
|
||||
/// Files larger than this are not previewed.
|
||||
pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
|
||||
/// Preview cache caps: at most this many files (or this many text bytes)
|
||||
/// are kept in memory at once; the oldest previews are evicted beyond that.
|
||||
/// Preview cache caps, a file count and a text byte count.
|
||||
///
|
||||
/// The oldest previews are evicted beyond the caps.
|
||||
pub(super) const MAX_PREVIEWED_FILES: usize = 32;
|
||||
pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
@@ -34,19 +35,13 @@ pub(super) enum FileContent {
|
||||
}
|
||||
|
||||
/// A markdown document loaded into a persistent [`TextViewState`].
|
||||
///
|
||||
/// The state is owned by the view rather than created per render: GPUI
|
||||
/// drops keyed element state after one absent frame, which would re-parse
|
||||
/// the whole document on every pane switch (README / file / spinner).
|
||||
pub(super) struct MarkdownView {
|
||||
/// Source path; `None` means the repository README.
|
||||
/// Source path, `None` means the repository README.
|
||||
pub(super) path: Option<SharedString>,
|
||||
pub(super) state: Entity<TextViewState>,
|
||||
}
|
||||
|
||||
/// A code file loaded into a persistent [`InputState`], rendered as a
|
||||
/// disabled (read-only) code editor with syntax highlighting, line numbers
|
||||
/// and search. Persistent for the same reason as [`MarkdownView`].
|
||||
/// A code file loaded into a persistent [`InputState`].
|
||||
pub(super) struct CodeView {
|
||||
/// Source path, relative to the worktree root.
|
||||
pub(super) path: SharedString,
|
||||
@@ -64,7 +59,7 @@ fn preview_spinner() -> AnyElement {
|
||||
}
|
||||
|
||||
impl RepoDetailView {
|
||||
/// One row of the file tree: icon + name, indented by depth.
|
||||
/// One row of the file tree with icon and name, indented by depth.
|
||||
fn render_tree_item(
|
||||
ix: usize,
|
||||
entry: &TreeEntry,
|
||||
@@ -81,7 +76,7 @@ impl RepoDetailView {
|
||||
})
|
||||
}
|
||||
|
||||
/// Left column: the file tree.
|
||||
/// Left column showing the file tree.
|
||||
pub(super) fn render_tree_column(
|
||||
tree_state: Entity<TreeState>,
|
||||
view: WeakEntity<Self>,
|
||||
@@ -102,7 +97,7 @@ impl RepoDetailView {
|
||||
)))
|
||||
}
|
||||
|
||||
/// Right column: README, selected file preview, or status text.
|
||||
/// Right column, README, selected file preview or status text.
|
||||
pub(super) fn render_content_column(
|
||||
&self,
|
||||
pane_title: SharedString,
|
||||
@@ -148,7 +143,7 @@ impl RepoDetailView {
|
||||
self.code_element(path.as_ref(), cx)
|
||||
}
|
||||
}
|
||||
Some(FileContent::Binary) => placeholder("Binary file — preview not supported", cx),
|
||||
Some(FileContent::Binary) => placeholder("Binary file - preview not supported", cx),
|
||||
Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx),
|
||||
Some(FileContent::Failed(message)) => placeholder(message, cx),
|
||||
None => preview_spinner(),
|
||||
@@ -159,9 +154,8 @@ impl RepoDetailView {
|
||||
placeholder("No README found", cx)
|
||||
};
|
||||
|
||||
// Latest commit for the current pane: the selected file, or the README
|
||||
// while nothing is selected. Computed after the body above, which
|
||||
// needs `&mut self`.
|
||||
// Latest commit for the current pane, the selected file or the README.
|
||||
// Computed after the body above, which needs `&mut self`.
|
||||
let commit = match &self.selected_file {
|
||||
Some(path) => self.commits.get(path.as_ref()),
|
||||
None => self
|
||||
@@ -217,9 +211,6 @@ impl RepoDetailView {
|
||||
}
|
||||
|
||||
/// Load `text` into the persistent markdown TextView state.
|
||||
///
|
||||
/// The state is created empty and fed via `push_str`, which parses on a
|
||||
/// background task, so switching files never blocks the main thread.
|
||||
pub(super) fn set_markdown(
|
||||
&mut self,
|
||||
path: Option<SharedString>,
|
||||
@@ -231,16 +222,19 @@ impl RepoDetailView {
|
||||
self.md = Some(MarkdownView { path, state });
|
||||
}
|
||||
|
||||
/// The persistent markdown TextView for `path` (`None` = README), or a
|
||||
/// spinner while the document is being loaded/parsed.
|
||||
/// The persistent markdown TextView for `path`, where `None` is the README.
|
||||
///
|
||||
/// Shows a spinner while the document is being loaded or parsed.
|
||||
fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(md) = &self.md else {
|
||||
return preview_spinner();
|
||||
};
|
||||
|
||||
let ready = match path {
|
||||
Some(path) => md.path.as_deref() == Some(path),
|
||||
None => md.path.is_none(),
|
||||
};
|
||||
|
||||
if !ready {
|
||||
return preview_spinner();
|
||||
}
|
||||
@@ -255,9 +249,7 @@ impl RepoDetailView {
|
||||
|
||||
/// Load `text` into the persistent code editor state for `path`.
|
||||
///
|
||||
/// The state is created in code editor mode so the Input renders it as
|
||||
/// a syntax-highlighted, read-only editor; the tree-sitter parse runs
|
||||
/// on a background task like [`set_markdown`]'s.
|
||||
/// Code editor mode makes the Input render it read-only and highlighted.
|
||||
pub(super) fn set_code(
|
||||
&mut self,
|
||||
path: SharedString,
|
||||
@@ -276,8 +268,7 @@ impl RepoDetailView {
|
||||
self.code = Some(CodeView { path, state });
|
||||
}
|
||||
|
||||
/// The persistent code editor for `path`, or a spinner while the file is
|
||||
/// being loaded/parsed.
|
||||
/// The persistent code editor for `path`, or a spinner while the file loads or parses.
|
||||
fn code_element(&self, path: &str, _cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(code) = &self.code else {
|
||||
return preview_spinner();
|
||||
|
||||
@@ -70,8 +70,6 @@ pub(super) fn commit_row(
|
||||
}
|
||||
|
||||
impl RepoDetailView {
|
||||
/// Full-height body of the Commits tab: all commits in a virtual
|
||||
/// list, or a status message while loading / when there are none.
|
||||
pub(super) fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(list) = self.all_commits.as_ref() else {
|
||||
return if self.loading_all_commits {
|
||||
@@ -90,9 +88,9 @@ impl RepoDetailView {
|
||||
return placeholder("No commits found", cx);
|
||||
}
|
||||
|
||||
// Copy only the values the element tree needs; the list itself is
|
||||
// borrowed inside the renderer below instead of being cloned per
|
||||
// frame (a full history can be tens of thousands of commits).
|
||||
// Copy only the values the element tree needs.
|
||||
// The list is borrowed by the renderer below instead of cloned per frame.
|
||||
// A full history can be tens of thousands of commits.
|
||||
let view = cx.entity().clone();
|
||||
let sizes = self.item_sizes.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
@@ -137,7 +135,8 @@ impl RepoDetailView {
|
||||
.size_full(),
|
||||
)
|
||||
.when(shown < total, |this| {
|
||||
// The history is capped; tell the user the list is truncated.
|
||||
// The history is capped.
|
||||
// Tell the user the list is truncated.
|
||||
this.child(
|
||||
div()
|
||||
.py_2()
|
||||
|
||||
@@ -28,19 +28,15 @@ use super::helpers::{
|
||||
/// Width of the changed-files column.
|
||||
const TREE_WIDTH: f32 = 260.;
|
||||
|
||||
/// The tree + per-file diff body shared by the commit diff panel and the
|
||||
/// compare view of the new-pull-request panel. Owns the changed-files
|
||||
/// explorer and the virtual list of the selected file's hunks; the host
|
||||
/// feeds it a [`CommitDiff`] via [`DiffPane::set_diff`].
|
||||
/// Tree and per-file diff body, shared by the commit diff and compare views.
|
||||
pub struct DiffPane {
|
||||
/// Loaded diff; `None` until [`Self::set_diff`] is called.
|
||||
/// Loaded diff, `None` until [`Self::set_diff`] is called.
|
||||
diff: Option<CommitDiff>,
|
||||
/// Changed-files explorer state.
|
||||
tree_state: Entity<TreeState>,
|
||||
/// Path of the file whose diff is shown in the detail column.
|
||||
selected_file: Option<SharedString>,
|
||||
/// Rows of the selected file's diff (hunk headers + lines), backing the
|
||||
/// virtual list in the detail column.
|
||||
/// Rows of the selected file's diff, hunk headers and lines.
|
||||
rows: Vec<DiffRow>,
|
||||
/// Per-row heights of [`Self::rows`].
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
@@ -90,8 +86,9 @@ impl DiffPane {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget the diff (e.g. when the compared branches changed): clear the
|
||||
/// tree, the selection and the diff rows.
|
||||
/// Forget the diff, e.g. when the compared branches changed.
|
||||
///
|
||||
/// Clears the tree, the selection and the diff rows.
|
||||
pub fn clear(&mut self, cx: &mut Context<Self>) {
|
||||
self.diff = None;
|
||||
self.selected_file = None;
|
||||
@@ -102,15 +99,14 @@ impl DiffPane {
|
||||
});
|
||||
}
|
||||
|
||||
/// Show the diff of the file at `path` (selected in the tree).
|
||||
/// Show the diff of the file at `path`, selected in the tree.
|
||||
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||
self.selected_file = Some(path.into());
|
||||
self.set_diff_rows(path);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Rebuild the virtual list state for the file at `path` and scroll back
|
||||
/// to the top.
|
||||
/// Rebuild the virtual list state for `path` and scroll back to the top.
|
||||
fn set_diff_rows(&mut self, path: &str) {
|
||||
let Some(diff) = self.diff.as_ref() else {
|
||||
return;
|
||||
@@ -123,7 +119,7 @@ impl DiffPane {
|
||||
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
||||
}
|
||||
|
||||
/// One row of the changed-files tree: icon + name, indented by depth.
|
||||
/// One row of the changed-files tree, icon and name, indented by depth.
|
||||
fn render_tree_item(
|
||||
ix: usize,
|
||||
entry: &TreeEntry,
|
||||
@@ -140,7 +136,7 @@ impl DiffPane {
|
||||
})
|
||||
}
|
||||
|
||||
/// Left column: the changed-files tree.
|
||||
/// Left column showing the changed-files tree.
|
||||
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let tree_state = self.tree_state.clone();
|
||||
let view = cx.entity().downgrade();
|
||||
@@ -170,7 +166,7 @@ impl DiffPane {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Right column: header of the selected file plus its diff.
|
||||
/// Right column, header of the selected file plus its diff.
|
||||
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(diff) = self.diff.as_ref() else {
|
||||
return placeholder("No changes", cx);
|
||||
@@ -188,8 +184,7 @@ impl DiffPane {
|
||||
self.render_file_diff(file, cx.entity(), cx)
|
||||
}
|
||||
|
||||
/// The diff of one file: a header with status and stats, then the hunks
|
||||
/// in a virtual list (a large diff is never materialized per frame).
|
||||
/// The diff of one file, with a header showing status and stats.
|
||||
fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
|
||||
let status_label = match file.status {
|
||||
DiffStatus::Added => "A",
|
||||
@@ -316,25 +311,21 @@ impl Render for DiffPane {
|
||||
}
|
||||
}
|
||||
|
||||
/// Detail panel showing the diff of one commit: a metadata header plus the
|
||||
/// shared [`DiffPane`] body.
|
||||
/// Detail panel showing the diff of one commit.
|
||||
pub struct CommitDiffView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Local clone the commit lives in.
|
||||
worktree: PathBuf,
|
||||
/// Display name of the repository the commit belongs to.
|
||||
repo_name: SharedString,
|
||||
/// The commit being shown (header and tab title). Starts as an id-only
|
||||
/// stub; [`Self::load`] replaces it with the full metadata, which the
|
||||
/// history list intentionally omits.
|
||||
/// The commit being shown in the header and tab title.
|
||||
commit: FileCommit,
|
||||
/// The diff is being computed on a background task.
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
/// Changed-files explorer and per-file diff, shared with the compare
|
||||
/// view of the new-pull-request panel.
|
||||
/// Changed-files explorer and per-file diff, also used by the new PR panel's compare view.
|
||||
pane: Entity<DiffPane>,
|
||||
/// In-flight tasks; pruned on every push (see [`helpers::track`]).
|
||||
/// In-flight tasks, pruned on every push.
|
||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||
}
|
||||
|
||||
@@ -371,8 +362,7 @@ impl CommitDiffView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the commit diff (and the full commit metadata) on a background
|
||||
/// task and populate the tree.
|
||||
/// Load the commit diff and the full commit metadata.
|
||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
@@ -419,7 +409,7 @@ impl CommitDiffView {
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Header: commit id, summary, author/time and overall change stats.
|
||||
/// Header with the commit id, summary, author/time and overall change stats.
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let commit = &self.commit;
|
||||
let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| {
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, SharedString, div, px};
|
||||
use gpui::{AnyElement, App, Entity, SharedString, div, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::combobox::{Caret, ComboboxTriggerContext};
|
||||
use gpui_component::input::{Textarea, TextareaState};
|
||||
use gpui_component::menu::PopupMenu;
|
||||
use gpui_component::searchable_list::SearchableVec;
|
||||
use gpui_component::tag::Tag;
|
||||
use gpui_component::tree::TreeItem;
|
||||
use gpui_component::{ActiveTheme, h_flex};
|
||||
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
|
||||
use nostr::nips::nip19::{Nip19Coordinate, ToBech32};
|
||||
use nostr::prelude::{Event, EventId, PublicKey};
|
||||
use signed_core::Announcement;
|
||||
use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff};
|
||||
use signed_ui::{menu_copy_row, middle_truncate};
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{UserAvatar, menu_copy_row, middle_truncate};
|
||||
use utils::relative_time;
|
||||
|
||||
/// A `Send` file-tree node: the tree is built on a background thread and
|
||||
/// converted into [`TreeItem`]s (which hold `Rc` state,
|
||||
/// so they cannot cross threads) on the main thread.
|
||||
pub(super) struct TreeItemSeed {
|
||||
/// Path of the node, relative to the worktree root.
|
||||
id: String,
|
||||
@@ -22,12 +28,6 @@ pub(super) struct TreeItemSeed {
|
||||
children: Vec<TreeItemSeed>,
|
||||
}
|
||||
|
||||
/// Convert tree seeds into [`TreeItem`]s, expanding every folder
|
||||
/// when `expand_folders` is set.
|
||||
///
|
||||
/// The commit diff explorer shows only changed files,
|
||||
/// which is typically a handful of paths, so its folders start expanded;
|
||||
/// the worktree explorer starts collapsed instead.
|
||||
pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
|
||||
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
|
||||
let mut item = TreeItem::new(seed.id, seed.label);
|
||||
@@ -48,14 +48,9 @@ pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build nested tree items from a flat, sorted (dirs-first) entry list.
|
||||
///
|
||||
/// Returns [`TreeItemSeed`]s so the build can run off the main thread; a
|
||||
/// worktree walk can yield tens of thousands of entries. Nodes live in an
|
||||
/// arena and parents are found via a path -> index map, which keeps the
|
||||
/// build linear in the number of path components.
|
||||
/// Build nested tree items from a flat entry list sorted dirs-first.
|
||||
pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
||||
// Node indices by full path, for O(1) parent lookup while inserting.
|
||||
// Node indices by full path, so parents resolve in constant time while inserting.
|
||||
let mut index: HashMap<String, usize> = HashMap::new();
|
||||
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
|
||||
let mut roots: Vec<usize> = Vec::new();
|
||||
@@ -99,9 +94,6 @@ pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
||||
}
|
||||
|
||||
/// The markdown fence language for a file path, or `None` for plain text.
|
||||
///
|
||||
/// Names are chosen so `gpui_component`'s highlighter can resolve them
|
||||
/// (`highlighter::Language::from_name` accepts short aliases such as `rs` and `js`).
|
||||
pub(super) fn code_language(path: &str) -> Option<&'static str> {
|
||||
let name = Path::new(path)
|
||||
.file_name()
|
||||
@@ -166,7 +158,7 @@ pub(super) fn is_markdown_path(path: &str) -> bool {
|
||||
}
|
||||
|
||||
pub(super) struct ShareTargets {
|
||||
/// NIP-19 `naddr1...` of the announcement (with its announced relays).
|
||||
/// NIP-19 `naddr1...` of the announcement, with its announced relays.
|
||||
pub(super) naddr: String,
|
||||
/// Hex ID of the announcement event itself.
|
||||
pub(super) event_id: String,
|
||||
@@ -195,8 +187,9 @@ impl ShareTargets {
|
||||
}
|
||||
}
|
||||
|
||||
/// The share dropdown menu: one row per target, each showing a compact
|
||||
/// label while the copy button (and row click) copy the full value.
|
||||
/// The share dropdown menu, one row per target.
|
||||
///
|
||||
/// Each shows a compact label, the copy button and row click copy the full value.
|
||||
pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu {
|
||||
menu.min_w(px(340.))
|
||||
.item(menu_copy_row(
|
||||
@@ -226,9 +219,7 @@ impl ShareTargets {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shorten an naddr link to `<url>/naddr1...[last tail chars]`, e.g.
|
||||
/// `https://gitworkshop.dev/naddr1...abcd`. Only the label is shortened;
|
||||
/// the value to be copied stays the full URL.
|
||||
/// Shorten an naddr link to `<url>/naddr1...[last tail chars]`.
|
||||
fn truncate_naddr_link(url: &str, tail: usize) -> String {
|
||||
let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else {
|
||||
return url.to_string();
|
||||
@@ -244,7 +235,8 @@ pub(super) const GUTTER_WIDTH: f32 = 44.;
|
||||
/// Height of one row in a virtual diff list.
|
||||
pub(super) const DIFF_ROW_HEIGHT: f32 = 20.;
|
||||
|
||||
/// One row of a virtual diff list: a hunk header, or a line of a hunk.
|
||||
/// One row of a virtual diff list, a hunk header or a line of a hunk.
|
||||
///
|
||||
/// Shared by the commit diff and pull request diff viewers.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum DiffRow {
|
||||
@@ -258,7 +250,7 @@ pub(super) enum DiffRow {
|
||||
Line { hunk: usize, line: usize },
|
||||
}
|
||||
|
||||
/// The rows of `file`'s diff: one header row per hunk, then its lines.
|
||||
/// The rows of `file`'s diff, one header row per hunk then its lines.
|
||||
pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
|
||||
let mut rows = Vec::new();
|
||||
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
|
||||
@@ -276,7 +268,7 @@ pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
|
||||
rows
|
||||
}
|
||||
|
||||
/// One row of the virtual diff list: a hunk header or a single line.
|
||||
/// One row of the virtual diff list, a hunk header or a single line.
|
||||
pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
|
||||
match row {
|
||||
DiffRow::Hunk {
|
||||
@@ -303,8 +295,9 @@ pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> Any
|
||||
}
|
||||
}
|
||||
|
||||
/// One diff line: old and new line numbers in gutters, then the content,
|
||||
/// tinted by kind (addition / deletion / context).
|
||||
/// One diff line, old and new line numbers in the gutters.
|
||||
///
|
||||
/// The content is tinted by kind, addition, deletion or context.
|
||||
pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
||||
let bg = match line.kind {
|
||||
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
|
||||
@@ -313,8 +306,8 @@ pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
||||
};
|
||||
let gutter = cx.theme().muted_foreground;
|
||||
|
||||
// Fixed height and nowrap: the virtual list assumes every row has
|
||||
// the same height, so long lines are clipped instead of wrapped.
|
||||
// Fixed height and nowrap, the virtual list assumes every row has the same height.
|
||||
// Long lines are clipped instead of wrapped.
|
||||
h_flex()
|
||||
.w_full()
|
||||
.h(px(DIFF_ROW_HEIGHT))
|
||||
@@ -364,6 +357,257 @@ pub(super) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&
|
||||
})
|
||||
}
|
||||
|
||||
/// The root issue events of a repo store, for the shared detail sections.
|
||||
pub(super) fn issue_roots(store: &RepoStore) -> &[Event] {
|
||||
&store.issues
|
||||
}
|
||||
|
||||
/// The root pull request events of a repo store, for the shared detail sections.
|
||||
pub(super) fn pr_roots(store: &RepoStore) -> &[Event] {
|
||||
&store.pull_requests
|
||||
}
|
||||
|
||||
/// The trigger body of the branch/tag selectors.
|
||||
///
|
||||
/// The kind icon, the selection or placeholder, and the caret.
|
||||
/// `Combobox` replaces its default trigger entirely,
|
||||
/// the only way to show an icon inside it.
|
||||
pub(super) fn ref_selector_trigger(
|
||||
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
|
||||
icon: CustomIconName,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let muted = cx.theme().muted_foreground;
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(Icon::new(icon).small().flex_shrink_0())
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.when(ctx.selection().is_empty(), |this| this.text_color(muted))
|
||||
.child(
|
||||
ctx.selection()
|
||||
.first()
|
||||
.map(|(_, item)| item.clone())
|
||||
.or_else(|| ctx.placeholder().cloned())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
)
|
||||
.child(Caret::new(ctx.size()).text_color(muted))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Section heading of a detail sidebar, shared by the issue and PR panels.
|
||||
pub(super) fn sidebar_title(text: &str, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(text.to_string())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Right sidebar with participants and labels of a root event, issue or PR.
|
||||
pub(super) fn sidebar_section(
|
||||
store: &Entity<RepoStore>,
|
||||
id: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
top_gap: bool,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let store = store.read(cx);
|
||||
let Some(root) = roots(store).iter().find(|event| event.id == id) else {
|
||||
// The caller bails out when the root is missing.
|
||||
return div().into_any_element();
|
||||
};
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
|
||||
// Participants, the root author plus everyone who commented.
|
||||
let mut participants: Vec<PublicKey> = vec![root.pubkey];
|
||||
participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey));
|
||||
participants.sort_by_key(PublicKey::to_hex);
|
||||
participants.dedup();
|
||||
|
||||
// Labels are NIP-34 `t` hashtag tags on the event.
|
||||
let labels: Vec<String> = root.tags.hashtags().map(|tag| tag.to_string()).collect();
|
||||
|
||||
v_flex()
|
||||
.w(px(240.))
|
||||
.h_full()
|
||||
.flex_none()
|
||||
.px_4()
|
||||
.gap_4()
|
||||
.border_l(px(1.))
|
||||
.border_color(cx.theme().sidebar_border)
|
||||
.child(
|
||||
v_flex()
|
||||
.when(top_gap, |this| this.mt_4())
|
||||
.gap_2()
|
||||
.child(sidebar_title("Participants", cx))
|
||||
.children(participants.iter().map(|pubkey| {
|
||||
let profile = profile_store.read(cx).get(pubkey);
|
||||
let name = profile.name();
|
||||
let picture = profile.picture();
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(UserAvatar::new(name.clone()).picture(picture))
|
||||
.child(div().text_sm().truncate().text_ellipsis().child(name))
|
||||
.into_any_element()
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(sidebar_title("Labels", cx))
|
||||
.map(|this| {
|
||||
if labels.is_empty() {
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("None yet."),
|
||||
)
|
||||
} else {
|
||||
this.child(h_flex().gap_1().children({
|
||||
let mut items = vec![];
|
||||
|
||||
for label in labels.iter() {
|
||||
items.push(
|
||||
Tag::secondary()
|
||||
.outline()
|
||||
.xsmall()
|
||||
.child(SharedString::from(label)),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The comments on a root event, issue or PR, one card per comment.
|
||||
pub(super) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &App) -> AnyElement {
|
||||
let store = store.read(cx);
|
||||
let comments: Vec<&Event> = store.comments_of(&root).collect();
|
||||
let title = SharedString::from(format!("Discussions {}", comments.len()));
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(div().text_xs().font_semibold().child(title))
|
||||
.children(comments.iter().map(|comment| {
|
||||
let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey);
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
let age = relative_time(comment.created_at);
|
||||
let content = SharedString::from(comment.content.as_str());
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(UserAvatar::new(author.clone()).picture(picture))
|
||||
.child(author),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("commented"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.child(div().text_sm().child(content))
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The comment form posting to an issue or PR root event.
|
||||
///
|
||||
/// `roots` selects the root's list within the store, issues or pull requests.
|
||||
pub(super) fn comment_form(
|
||||
store: &Entity<RepoStore>,
|
||||
root: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
comment_input: &Entity<TextareaState>,
|
||||
button_id: &'static str,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let comment_input = comment_input.clone();
|
||||
let store = store.clone();
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Textarea::new(&comment_input)
|
||||
.h_24()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.bg(cx.theme().muted),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Icon::new(CustomIconName::Markdown).small())
|
||||
.child("Markdown is supported"),
|
||||
)
|
||||
.child(
|
||||
Button::new(button_id)
|
||||
.primary()
|
||||
.label("Comment")
|
||||
.tooltip("Post comment")
|
||||
.on_click(move |_event, window, cx| {
|
||||
let content = comment_input.read(cx).value().trim().to_string();
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(root) = roots(store.read(cx))
|
||||
.iter()
|
||||
.find(|event| event.id == root)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
store.update(cx, |store, cx| {
|
||||
store.comment(&root, content, cx);
|
||||
});
|
||||
comment_input.update(cx, |input, cx| {
|
||||
input.set_value("", window, cx);
|
||||
});
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -379,7 +623,7 @@ mod tests {
|
||||
|
||||
let items = build_tree_items(&entries);
|
||||
|
||||
// Input order is preserved (dirs-first, as produced by worktree_entries).
|
||||
// Input order is preserved, dirs-first as produced by worktree_entries.
|
||||
assert_eq!(items.len(), 3);
|
||||
assert_eq!(items[0].label, "src");
|
||||
assert_eq!(items[0].id, "src");
|
||||
@@ -411,9 +655,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tree_builder_merges_shared_prefixes() {
|
||||
// File children of a directory arrive after other directories'
|
||||
// entries (the worktree list is dirs-first globally); the shared
|
||||
// prefix must still resolve to one node.
|
||||
// File children of a directory arrive after other directories' entries.
|
||||
// The worktree list is dirs-first globally.
|
||||
// The shared prefix must still resolve to one node.
|
||||
let entries = vec![
|
||||
PathBuf::from("a/x.txt"),
|
||||
PathBuf::from("b/y.txt"),
|
||||
@@ -461,7 +705,7 @@ mod tests {
|
||||
truncate_naddr_link("https://gitworkshop.dev/naddr1qqqxyzabc1234", 4),
|
||||
"https://gitworkshop.dev/naddr1...1234"
|
||||
);
|
||||
// No naddr1 prefix: unchanged.
|
||||
// Without the naddr1 prefix, unchanged.
|
||||
assert_eq!(
|
||||
truncate_naddr_link("https://example.com/x", 4),
|
||||
"https://example.com/x"
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, SharedString, WeakEntity, Window, div, px};
|
||||
use gpui::{App, Entity, WeakEntity, Window, px};
|
||||
use gpui_base::h_flex;
|
||||
use gpui_base::input::TextareaState;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
@@ -14,22 +14,15 @@ use settings::SettingsStore;
|
||||
use signed_state::Backend;
|
||||
|
||||
use super::RepoDetailView;
|
||||
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||
use crate::views::sidebar::grasp_servers::{
|
||||
GraspServersState, grasp_servers_field, load_user_grasp_servers,
|
||||
};
|
||||
|
||||
/// Shared state for the Init dialog, so async results can be rendered.
|
||||
#[derive(Default)]
|
||||
pub struct InitRepoState {
|
||||
pub busy: bool,
|
||||
pub error: Option<SharedString>,
|
||||
}
|
||||
pub type InitRepoState = DialogProgress;
|
||||
|
||||
/// Open the Init dialog for the local repository at `local_path`.
|
||||
///
|
||||
/// The dialog loads the user's default grasp servers (kind `10317` grasp
|
||||
/// list) and falls back to the shared defaults when none are set. On
|
||||
/// success the dialog closes and `view` switches into NIP-34 mode.
|
||||
pub fn open(
|
||||
local_path: PathBuf,
|
||||
view: WeakEntity<RepoDetailView>,
|
||||
@@ -40,23 +33,25 @@ pub fn open(
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
let grasp_settings = SettingsStore::global(cx)
|
||||
.read(cx)
|
||||
.settings()
|
||||
.grasp_servers
|
||||
.clone();
|
||||
|
||||
let state = cx.new(|_| InitRepoState::default());
|
||||
let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings));
|
||||
|
||||
let relay_input = cx.new(|cx| InputState::new(window, cx).placeholder("relay.example.com"));
|
||||
let name_input = cx.new(|cx| InputState::new(window, cx).default_value(default_name));
|
||||
let desc_input = cx.new(|cx| {
|
||||
TextareaState::new(window, cx)
|
||||
.auto_grow(3, 5)
|
||||
.placeholder("Short description")
|
||||
});
|
||||
let relay_input = cx.new(|cx| {
|
||||
InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com")
|
||||
});
|
||||
let state = cx.new(|_| InitRepoState::default());
|
||||
let grasp_settings = SettingsStore::global(cx)
|
||||
.read(cx)
|
||||
.settings()
|
||||
.grasp_servers
|
||||
.clone();
|
||||
let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings));
|
||||
|
||||
// Load the user's grasp servers.
|
||||
load_user_grasp_servers(grasp_state.clone(), window, cx);
|
||||
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
@@ -115,9 +110,7 @@ pub fn open(
|
||||
)
|
||||
.child(grasp_servers_field(&grasp_state, &relay_input, cx)),
|
||||
)
|
||||
.children(error.map(|message| {
|
||||
div().text_sm().text_color(cx.theme().danger).child(message)
|
||||
}))
|
||||
.children(error_row(&error, cx))
|
||||
.child(
|
||||
DialogFooter::new().justify_end().child(
|
||||
Button::new("init")
|
||||
@@ -153,8 +146,9 @@ pub fn open(
|
||||
});
|
||||
}
|
||||
|
||||
/// Run the init flow; closes the dialog and switches the repository into
|
||||
/// its NIP-34 mode on success.
|
||||
/// Run the init flow.
|
||||
///
|
||||
/// Closes the dialog and switches the repository into NIP-34 mode on success.
|
||||
fn init_repository(
|
||||
local_path: PathBuf,
|
||||
inputs: (Entity<InputState>, Entity<TextareaState>),
|
||||
@@ -170,23 +164,16 @@ fn init_repository(
|
||||
let servers = grasp_state.read(cx).grasp_servers.clone();
|
||||
|
||||
if name.is_empty() {
|
||||
state.update(cx, |state, _| {
|
||||
state.error = Some("Repository name is required".into());
|
||||
});
|
||||
state.update(cx, |state, _| state.fail("Repository name is required"));
|
||||
return;
|
||||
}
|
||||
|
||||
if servers.is_empty() {
|
||||
state.update(cx, |state, _| {
|
||||
state.error = Some("Add at least one grasp server".into());
|
||||
});
|
||||
state.update(cx, |state, _| state.fail("Add at least one grasp server"));
|
||||
return;
|
||||
}
|
||||
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = true;
|
||||
state.error = None;
|
||||
});
|
||||
state.update(cx, |state, _| state.begin());
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
@@ -211,10 +198,7 @@ fn init_repository(
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update_window(handle, |_, _window, cx| {
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = false;
|
||||
state.error = Some(e.to_string().into());
|
||||
});
|
||||
state.update(cx, |state, _| state.fail(e.to_string()));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
@@ -1,31 +1,26 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use dock::{BasePanel, Panel, PanelEvent};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
|
||||
Window, div, px, relative,
|
||||
App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Window, div,
|
||||
relative,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::{Textarea, TextareaState};
|
||||
use gpui_component::input::TextareaState;
|
||||
use gpui_component::scroll::ScrollableElement;
|
||||
use gpui_component::tag::Tag;
|
||||
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
|
||||
use nostr::prelude::{Event, EventId, PublicKey};
|
||||
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
||||
use nostr::prelude::EventId;
|
||||
use signed_core::activity_subject;
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{UserAvatar, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::helpers::{comment_form, comments_section, issue_roots, sidebar_section};
|
||||
|
||||
/// Detail panel of a single issue.
|
||||
pub struct IssueDetailView {
|
||||
/// Repo store holding the issues and their statuses.
|
||||
store: Entity<RepoStore>,
|
||||
issue_id: EventId,
|
||||
contents: HashMap<EventId, SharedString>,
|
||||
/// Input state of the "leave a comment" textarea.
|
||||
/// Input state of the comment textarea.
|
||||
comment_input: Entity<TextareaState>,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
@@ -45,193 +40,8 @@ impl IssueDetailView {
|
||||
store,
|
||||
issue_id,
|
||||
comment_input,
|
||||
contents: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
let store = self.store.read(cx);
|
||||
|
||||
let Some(issue) = store.issues.iter().find(|issue| issue.id == self.issue_id) else {
|
||||
// `render` already bails out when the issue is missing.
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
// Participants: the issue author plus everyone who commented.
|
||||
let mut participants: Vec<PublicKey> = vec![issue.pubkey];
|
||||
participants.extend(store.comments_of(&issue.id).map(|comment| comment.pubkey));
|
||||
participants.sort_by_key(PublicKey::to_hex);
|
||||
participants.dedup();
|
||||
|
||||
// Issue labels are NIP-34 `t` hashtag tags on the event.
|
||||
let labels: Vec<String> = issue.tags.hashtags().map(|tag| tag.to_string()).collect();
|
||||
|
||||
v_flex()
|
||||
.w(px(240.))
|
||||
.h_full()
|
||||
.flex_none()
|
||||
.px_4()
|
||||
.gap_4()
|
||||
.border_l(px(1.))
|
||||
.border_color(cx.theme().sidebar_border)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(sidebar_title("Participants", cx))
|
||||
.children(participants.iter().map(|pubkey| {
|
||||
let profile = profile_store.read(cx).get(pubkey);
|
||||
let name = profile.name();
|
||||
let picture = profile.picture();
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(UserAvatar::new(name.clone()).picture(picture))
|
||||
.child(div().text_sm().truncate().text_ellipsis().child(name))
|
||||
.into_any_element()
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(sidebar_title("Labels", cx))
|
||||
.map(|this| {
|
||||
if labels.is_empty() {
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("None yet."),
|
||||
)
|
||||
} else {
|
||||
this.child(h_flex().gap_1().children({
|
||||
let mut items = vec![];
|
||||
|
||||
for label in labels.iter() {
|
||||
items.push(
|
||||
Tag::secondary()
|
||||
.outline()
|
||||
.xsmall()
|
||||
.child(SharedString::from(label)),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_comments(&mut self, id: &EventId, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let store = self.store.read(cx);
|
||||
let comments: Vec<&Event> = store.comments_of(id).collect();
|
||||
let title = SharedString::from(format!("Discussions {}", comments.len()));
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(div().text_xs().font_semibold().child(title))
|
||||
.children(comments.iter().map(|comment| {
|
||||
let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey);
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
let age = relative_time(comment.created_at);
|
||||
// Comment bodies are cloned into shared strings once per
|
||||
// comment, not on every render.
|
||||
let content = self
|
||||
.contents
|
||||
.entry(comment.id)
|
||||
.or_insert_with(|| SharedString::from(comment.content.clone()))
|
||||
.clone();
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(UserAvatar::new(author.clone()).picture(picture))
|
||||
.child(author),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("commented"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.child(div().text_sm().child(content))
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_form(&mut self, id: &EventId, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let comment_input = self.comment_input.clone();
|
||||
let store = self.store.clone();
|
||||
let id = id.to_owned();
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Textarea::new(&self.comment_input)
|
||||
.h_24()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.bg(cx.theme().muted),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Icon::new(CustomIconName::Markdown).small())
|
||||
.child("Markdown is supported"),
|
||||
)
|
||||
.child(
|
||||
Button::new("comment")
|
||||
.primary()
|
||||
.label("Comment")
|
||||
.tooltip("Post comment")
|
||||
.on_click(move |_event, window, cx| {
|
||||
let content = comment_input.read(cx).value().trim().to_string();
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(root) = store
|
||||
.read(cx)
|
||||
.issues
|
||||
.iter()
|
||||
.find(|issue| issue.id == id)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
store.update(cx, |store, cx| {
|
||||
store.comment(&root, content, cx);
|
||||
});
|
||||
comment_input.update(cx, |input, cx| {
|
||||
input.set_value("", window, cx);
|
||||
});
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl BasePanel for IssueDetailView {
|
||||
@@ -277,17 +87,11 @@ impl Render for IssueDetailView {
|
||||
let (title, author, picture, status, age, issue_id, content) = {
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
let profile = profile_store.read(cx).get(&issue.pubkey);
|
||||
let content = self
|
||||
.contents
|
||||
.entry(issue.id)
|
||||
.or_insert_with(|| {
|
||||
if issue.content.is_empty() {
|
||||
SharedString::from("No description provided.")
|
||||
} else {
|
||||
SharedString::from(&issue.content)
|
||||
}
|
||||
})
|
||||
.clone();
|
||||
let content = if issue.content.is_empty() {
|
||||
SharedString::from("No description provided.")
|
||||
} else {
|
||||
SharedString::from(&issue.content)
|
||||
};
|
||||
|
||||
(
|
||||
activity_subject(issue),
|
||||
@@ -301,7 +105,7 @@ impl Render for IssueDetailView {
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.image_cache(image_cache("issue-detail", MAX_IMAGES))
|
||||
.image_cache(gpui::retain_all("issue-detail"))
|
||||
.id("issue-detail")
|
||||
.size_full()
|
||||
.child(
|
||||
@@ -358,20 +162,24 @@ impl Render for IssueDetailView {
|
||||
)
|
||||
.child(div().text_sm().child(content)),
|
||||
)
|
||||
.child(self.render_comments(&issue_id, cx))
|
||||
.child(self.render_form(&issue_id, cx)),
|
||||
.child(comments_section(&self.store, issue_id, cx))
|
||||
.child(comment_form(
|
||||
&self.store,
|
||||
issue_id,
|
||||
issue_roots,
|
||||
&self.comment_input,
|
||||
"comment",
|
||||
cx,
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(self.render_sidebar(cx))
|
||||
.child(sidebar_section(
|
||||
&self.store,
|
||||
issue_id,
|
||||
issue_roots,
|
||||
false,
|
||||
cx,
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
fn sidebar_title(text: &str, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(text.to_string())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
@@ -18,14 +18,12 @@ use gpui_component::{
|
||||
use nostr::prelude::EventId;
|
||||
use signed_core::{RepoStatus, activity_subject};
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::issue_detail::IssueDetailView;
|
||||
|
||||
/// Height of one issue row in the virtual list: `py_2` padding, a 32px
|
||||
/// title line (`h_8`), a 24px meta line (`h_6`) and the 1px bottom border.
|
||||
/// Height of one issue row in the virtual list.
|
||||
const ISSUE_ROW_HEIGHT: f32 = 73.;
|
||||
|
||||
/// Status filter of the issues list, chosen via the header's filter buttons.
|
||||
@@ -35,8 +33,7 @@ enum IssueFilter {
|
||||
All,
|
||||
/// Issues whose resolved status is [`RepoStatus::Open`].
|
||||
Open,
|
||||
/// Issues whose resolved status is [`RepoStatus::Closed`] or
|
||||
/// [`RepoStatus::Applied`] (both are "done" states).
|
||||
/// Issues whose resolved status is [`RepoStatus::Closed`].
|
||||
Closed,
|
||||
}
|
||||
|
||||
@@ -63,14 +60,11 @@ pub struct IssuesView {
|
||||
filter: IssueFilter,
|
||||
/// Per-row heights of the virtual list.
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// Number of rows [`Self::item_sizes`] was built for (the filtered issue count).
|
||||
/// The filtered issue count [`Self::item_sizes`] was built for.
|
||||
issue_len: usize,
|
||||
/// Indices into the store's `issues` matching [`Self::filter`]; the
|
||||
/// virtual list renders this slice. Rebuilt only when the store
|
||||
/// version or the filter changes, keyed by [`Self::cache_key`].
|
||||
/// Indices into the store's `issues` matching [`Self::filter`].
|
||||
visible_issues: Vec<usize>,
|
||||
/// Header counts `(total, open, closed)`, rebuilt with
|
||||
/// [`Self::visible_issues`].
|
||||
/// Header counts `(total, open, closed)`, rebuilt with [`Self::visible_issues`].
|
||||
counts: (usize, usize, usize),
|
||||
/// Store version and filter the cached rows/counts were built from.
|
||||
cache_key: Option<(u64, IssueFilter)>,
|
||||
@@ -82,10 +76,11 @@ impl IssuesView {
|
||||
pub fn new(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
repo_name: SharedString,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let repo_name = store.read(cx).name();
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
@@ -101,7 +96,7 @@ impl IssuesView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the detail panel of `issue_id` at the bottom of the dock area.
|
||||
/// Open the detail panel of `issue_id` in the dock area.
|
||||
fn open_issue_detail(
|
||||
&mut self,
|
||||
issue_id: EventId,
|
||||
@@ -115,12 +110,10 @@ impl IssuesView {
|
||||
let panel = cx.new(|cx| IssueDetailView::new(self.store.clone(), issue_id, window, cx));
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Render one row of the issue list; `ix` is the row index and
|
||||
/// `issue_ix` the index of the issue in the store's `issues`.
|
||||
fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||
let issue = &self.store.read(cx).issues[issue_ix];
|
||||
let title = activity_subject(issue);
|
||||
@@ -183,8 +176,7 @@ impl IssuesView {
|
||||
}
|
||||
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
// Counts of the last list rebuild (`render` rebuilds first when the
|
||||
// store version or filter changed, so this is never stale).
|
||||
// Counts of the last list rebuild.
|
||||
let (total, open, closed) = self.counts;
|
||||
|
||||
h_flex()
|
||||
@@ -242,8 +234,7 @@ impl IssuesView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the "new issue" dialog: a title and a content input that submit
|
||||
/// through [`RepoStore::open_issue`] when confirmed.
|
||||
/// Open the new issue dialog, a title and a content input.
|
||||
pub(super) fn open_new_issue_dialog(store: Entity<RepoStore>, window: &mut Window, cx: &mut App) {
|
||||
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Issue title"));
|
||||
let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue..."));
|
||||
@@ -332,9 +323,9 @@ impl Render for IssuesView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let filter = self.filter;
|
||||
|
||||
// Rebuild the filtered rows and header counts only when the store
|
||||
// refreshed or the filter changed; other renders reuse the cache.
|
||||
// Rows and counts are rebuilt only when the store refreshed or filter changed.
|
||||
let version = self.store.read(cx).version();
|
||||
|
||||
if self.cache_key != Some((version, filter)) {
|
||||
let store = self.store.read(cx);
|
||||
let mut counts = (0usize, 0usize, 0usize);
|
||||
@@ -359,8 +350,8 @@ impl Render for IssuesView {
|
||||
|
||||
let count = self.visible_issues.len();
|
||||
|
||||
// The virtual list's item count comes from `item_sizes`; rebuild it
|
||||
// whenever the filtered issue count changes.
|
||||
// The virtual list's item count comes from `item_sizes`.
|
||||
// Rebuild it whenever the filtered issue count changes.
|
||||
if count != self.issue_len {
|
||||
self.issue_len = count;
|
||||
self.item_sizes = Rc::new(vec![size(px(0.), px(ISSUE_ROW_HEIGHT)); count]);
|
||||
@@ -371,7 +362,7 @@ impl Render for IssuesView {
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.image_cache(image_cache("issues", MAX_IMAGES))
|
||||
.image_cache(gpui::retain_all("issues"))
|
||||
.child(self.render_header(cx))
|
||||
.child(
|
||||
v_flex()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -7,97 +6,68 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
ScrollStrategy, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, relative,
|
||||
size,
|
||||
SharedString, Size, Task, WeakEntity, Window, div, px, relative, size,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::clipboard::Clipboard;
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Textarea, TextareaState};
|
||||
use gpui_component::list::ListItem;
|
||||
use gpui_component::scroll::{ScrollableElement, Scrollbar};
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::tab::{Tab, TabBar};
|
||||
use gpui_component::tag::Tag;
|
||||
use gpui_component::tree::{TreeEntry, TreeState, tree};
|
||||
use gpui_component::{
|
||||
ActiveTheme, Icon, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
|
||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
|
||||
v_virtual_list,
|
||||
};
|
||||
use nostr::prelude::{Event, EventId, Kind, Nip34Tag, PublicKey};
|
||||
use nostr::prelude::{Event, EventId, Kind, Nip34Tag};
|
||||
use signed_core::{activity_subject, pull_request_patch};
|
||||
use signed_git::{CommitDiff, FileCommit, FileDiff, patch_commits, patch_diffs};
|
||||
use signed_git::{FileCommit, patch_commits, patch_diffs};
|
||||
use signed_state::{Backend, GitStore, ProfileStore, RepoStore};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{UserAvatar, placeholder, status_badge, tree_row};
|
||||
use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge};
|
||||
use utils::{relative_time, relative_time_secs};
|
||||
|
||||
use super::diff::CommitDiffView;
|
||||
use super::helpers::{
|
||||
DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items,
|
||||
};
|
||||
use super::diff::{CommitDiffView, DiffPane};
|
||||
use super::helpers::{comment_form, comments_section, pr_roots, sidebar_section};
|
||||
|
||||
/// Width of the changed-files column.
|
||||
const TREE_WIDTH: f32 = 260.;
|
||||
|
||||
/// Height of one commit row in the commits tab's virtual list: a single
|
||||
/// text line plus the 1px bottom border.
|
||||
const PR_COMMIT_ROW_HEIGHT: f32 = 37.;
|
||||
/// Height of one commit row in the commits tab's virtual list.
|
||||
const ROW_HEIGHT: f32 = 37.;
|
||||
|
||||
/// Detail panel of a single pull request.
|
||||
pub struct PullRequestDetailView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Dock area new panels (commit diffs) are added to.
|
||||
/// Dock area where new panels, e.g. commit diffs, are added.
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
/// Repo store holding the PR, its status and comments.
|
||||
store: Entity<RepoStore>,
|
||||
/// Event id of the root PR event (kind 1618; updates are revisions).
|
||||
/// Event id of the root PR event, kind 1618.
|
||||
/// Updates are revisions.
|
||||
pr_id: EventId,
|
||||
/// Input state of the "leave a comment" textarea.
|
||||
/// Input state of the comment textarea.
|
||||
comment_input: Entity<TextareaState>,
|
||||
/// Display name of the repository, for panels opened from here.
|
||||
repo_name: SharedString,
|
||||
/// Local clone the PR's git changes come from; `None` while the diff is
|
||||
/// parsed from the nostr patch set (no commit diff viewer then).
|
||||
/// Local clone the PR's git changes come from.
|
||||
worktree: Option<PathBuf>,
|
||||
/// Root PR's content, shown as plain text.
|
||||
description: SharedString,
|
||||
/// Tip commit of the PR: the latest update's `c` tag, else the root's.
|
||||
/// Tip commit of the PR, the latest update's `c` tag or the root's.
|
||||
current_commit: Option<SharedString>,
|
||||
/// Commits of the patch series, in patch order (oldest first).
|
||||
/// Commits of the patch series, in patch order, oldest first.
|
||||
commits: Vec<FileCommit>,
|
||||
/// Parsed file changes of the patch; `None` while loading or on failure.
|
||||
diff: Option<CommitDiff>,
|
||||
/// The patch is being parsed on a background task.
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
/// Active header tab: 0 = Discussion, 1 = Files, 2 = Commits.
|
||||
/// Active header tab, 0 = Discussion, 1 = Files, 2 = Commits.
|
||||
active_tab: usize,
|
||||
/// Changed-files explorer state.
|
||||
tree_state: Entity<TreeState>,
|
||||
/// Path of the file whose diff is shown in the detail column.
|
||||
selected_file: Option<SharedString>,
|
||||
/// Rows of the selected file's diff (hunk headers + lines).
|
||||
rows: Vec<DiffRow>,
|
||||
/// Per-row heights of [`Self::rows`].
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// Virtual list state of the diff rows.
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
/// Per-row heights of the commits tab's virtual list, built when the
|
||||
/// patch series is loaded.
|
||||
/// Changed-files explorer and per-file diff, like the commit and compare views.
|
||||
pane: Entity<DiffPane>,
|
||||
/// Per-row heights of the commits tab's virtual list, built when the patch series loads.
|
||||
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// Virtual list state of the commits tab.
|
||||
commit_scroll_handle: VirtualListScrollHandle,
|
||||
/// Comment bodies as shared strings, keyed by comment event ID, so
|
||||
/// re-renders don't clone full contents again (events are immutable,
|
||||
/// so the cache never needs invalidation).
|
||||
contents: HashMap<EventId, SharedString>,
|
||||
/// In-flight tasks; finished tasks are pruned on every push, so the vec
|
||||
/// stays bounded by the number of concurrent loads.
|
||||
/// In-flight tasks, finished tasks are pruned on every push.
|
||||
tasks: Vec<Task<Result<(), anyhow::Error>>>,
|
||||
/// Subscriptions keeping the view live as the store refreshes.
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl PullRequestDetailView {
|
||||
@@ -108,26 +78,12 @@ impl PullRequestDetailView {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let tree_state = cx.new(|cx| TreeState::new(cx));
|
||||
let repo_name = store.read(cx).name();
|
||||
let pane = cx.new(DiffPane::new);
|
||||
|
||||
let comment_input =
|
||||
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
|
||||
|
||||
// Same display name as the repo detail panel's title.
|
||||
let repo_name = store
|
||||
.read(cx)
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|announcement| {
|
||||
announcement
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Re-render when the store refreshes (new comments, status changes).
|
||||
let subscriptions = vec![cx.observe(&store, |_this, _store, cx| cx.notify())];
|
||||
|
||||
// Defer loading until the window is ready, like the commit diff view.
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load(window, cx);
|
||||
@@ -144,29 +100,17 @@ impl PullRequestDetailView {
|
||||
description: SharedString::default(),
|
||||
current_commit: None,
|
||||
commits: Vec::new(),
|
||||
diff: None,
|
||||
loading: true,
|
||||
error: None,
|
||||
active_tab: 0,
|
||||
tree_state,
|
||||
selected_file: None,
|
||||
rows: Vec::new(),
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
pane,
|
||||
commit_item_sizes: Rc::new(Vec::new()),
|
||||
commit_scroll_handle: VirtualListScrollHandle::new(),
|
||||
contents: HashMap::new(),
|
||||
tasks: Vec::new(),
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the PR events from the store, then compute the file changes
|
||||
/// and commit list on a background task and populate the tree.
|
||||
///
|
||||
/// The changes come from the PR's patch set (NIP-34 `e`-linked patch
|
||||
/// events) when present; otherwise from the git repository (`c`,
|
||||
/// `clone` and `merge-base` tags), diffing the `merge-base..tip` range.
|
||||
/// Snapshot the PR events from the store.
|
||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
@@ -176,6 +120,7 @@ impl PullRequestDetailView {
|
||||
|
||||
let (description, patch, current_commit, merge_base, clone_urls, addr, has_patch_link) = {
|
||||
let store = self.store.read(cx);
|
||||
|
||||
let Some(root) = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
@@ -186,19 +131,24 @@ impl PullRequestDetailView {
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
|
||||
let update = latest_update(store.pull_requests.iter(), root);
|
||||
|
||||
let tip = update
|
||||
.and_then(current_commit_of)
|
||||
.or_else(|| current_commit_of(root));
|
||||
|
||||
let base = update
|
||||
.and_then(merge_base_of)
|
||||
.or_else(|| merge_base_of(root));
|
||||
|
||||
let clone_urls = clone_urls_of(root).or_else(|| {
|
||||
store
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|a| a.clone.iter().map(ToString::to_string).collect())
|
||||
});
|
||||
|
||||
(
|
||||
root.content.clone(),
|
||||
pull_request_patch(root, store.patches.iter()),
|
||||
@@ -209,16 +159,17 @@ impl PullRequestDetailView {
|
||||
root.tags.event_ids().next().is_some(),
|
||||
)
|
||||
};
|
||||
|
||||
self.description = description.into();
|
||||
|
||||
let task = cx.spawn_in(window, async move |this, cx| {
|
||||
// Parse the nostr patch set first.
|
||||
let nostr_diff = cx
|
||||
.background_spawn({
|
||||
let patch = patch.clone();
|
||||
async move { patch_diffs(&patch) }
|
||||
})
|
||||
.await;
|
||||
|
||||
let nostr_commits = cx
|
||||
.background_spawn({
|
||||
let patch = patch.clone();
|
||||
@@ -226,13 +177,13 @@ impl PullRequestDetailView {
|
||||
})
|
||||
.await;
|
||||
|
||||
// PRs without patch events (e.g. published by ngit) carry their
|
||||
// changes in the git repository: fetch the clone and diff the
|
||||
// `merge-base..tip` range.
|
||||
// PRs without patch events, e.g. published by ngit, carry their changes in git.
|
||||
// Fetch the clone and diff the `merge-base..tip` range.
|
||||
let use_nostr = match &nostr_diff {
|
||||
Ok(diff) => has_patch_link || !diff.files.is_empty(),
|
||||
Err(_) => true,
|
||||
};
|
||||
|
||||
let git = if use_nostr {
|
||||
None
|
||||
} else {
|
||||
@@ -241,19 +192,22 @@ impl PullRequestDetailView {
|
||||
let clone_urls = clone_urls.clone();
|
||||
let base = merge_base.clone();
|
||||
let tip = current_commit.clone();
|
||||
|
||||
Some(
|
||||
cx.background_spawn(async move {
|
||||
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
||||
|
||||
let workdir = repo
|
||||
.workdir()
|
||||
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
|
||||
.to_path_buf();
|
||||
|
||||
let tip =
|
||||
tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
|
||||
|
||||
let base = match base {
|
||||
Some(base) => base,
|
||||
// No `merge-base` tag: use the merge base of the
|
||||
// tip with the default branch.
|
||||
// No `merge-base` tag. Use the merge base of the tip and the default branch.
|
||||
None => {
|
||||
let head = repo
|
||||
.head_id()
|
||||
@@ -262,9 +216,11 @@ impl PullRequestDetailView {
|
||||
repo.merge_base(tip_id, head)?.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?;
|
||||
let commits =
|
||||
signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?;
|
||||
|
||||
Ok::<_, anyhow::Error>((diff, commits, workdir))
|
||||
})
|
||||
.await,
|
||||
@@ -281,37 +237,18 @@ impl PullRequestDetailView {
|
||||
this.loading = false;
|
||||
this.worktree = worktree;
|
||||
this.current_commit = current_commit.map(SharedString::from);
|
||||
this.commit_item_sizes =
|
||||
Rc::new(vec![size(px(0.), px(PR_COMMIT_ROW_HEIGHT)); commits.len()]);
|
||||
this.commit_item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]);
|
||||
this.commits = commits;
|
||||
|
||||
match diff {
|
||||
Ok(diff) => {
|
||||
let mut paths: Vec<PathBuf> = diff
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| PathBuf::from(&file.path))
|
||||
.collect();
|
||||
paths.sort();
|
||||
let items = tree_items(build_tree_items(&paths), true);
|
||||
let first = diff
|
||||
.files
|
||||
.first()
|
||||
.map(|file| SharedString::from(file.path.as_str()));
|
||||
this.tree_state.update(cx, |state, cx| {
|
||||
state.set_items(items.clone(), cx);
|
||||
let item = find_item(&items, first.as_deref());
|
||||
state.set_selected_item(item, cx);
|
||||
});
|
||||
this.selected_file = first.clone();
|
||||
this.diff = Some(diff);
|
||||
if let Some(path) = first {
|
||||
this.set_diff_rows(path.as_ref());
|
||||
}
|
||||
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
|
||||
}
|
||||
Err(error) => {
|
||||
this.error = Some(error.to_string().into());
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
@@ -322,27 +259,6 @@ impl PullRequestDetailView {
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Show the diff of the file at `path` (selected in the tree).
|
||||
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||
self.selected_file = Some(path.into());
|
||||
self.set_diff_rows(path);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Rebuild the virtual list state for the file at `path` and scroll back
|
||||
/// to the top.
|
||||
fn set_diff_rows(&mut self, path: &str) {
|
||||
let Some(diff) = self.diff.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(file) = diff.files.iter().find(|file| file.path == path) else {
|
||||
return;
|
||||
};
|
||||
self.rows = diff_rows(file);
|
||||
self.item_sizes = Rc::new(vec![size(px(0.), px(DIFF_ROW_HEIGHT)); self.rows.len()]);
|
||||
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
||||
}
|
||||
|
||||
/// Open the diff of `commit_id` in the bottom dock of the area.
|
||||
fn open_commit_diff(
|
||||
&mut self,
|
||||
@@ -370,204 +286,9 @@ impl PullRequestDetailView {
|
||||
});
|
||||
}
|
||||
|
||||
/// One row of the changed-files tree: icon + name, indented by depth.
|
||||
fn render_tree_item(
|
||||
ix: usize,
|
||||
entry: &TreeEntry,
|
||||
selected: bool,
|
||||
view: &WeakEntity<Self>,
|
||||
) -> ListItem {
|
||||
let view = view.clone();
|
||||
let id = entry.item().id.clone();
|
||||
|
||||
tree_row(ix, entry, selected, move |_window, cx| {
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| this.select_file(&id, cx));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Left column: the changed-files tree.
|
||||
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let tree_state = self.tree_state.clone();
|
||||
let view = cx.entity().downgrade();
|
||||
|
||||
v_flex()
|
||||
.h_full()
|
||||
.w(px(TREE_WIDTH))
|
||||
.flex_none()
|
||||
.border_r_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.when(self.diff.is_some(), |this| {
|
||||
this.child(
|
||||
tree(&tree_state, move |ix, entry, selected, _window, _cx| {
|
||||
Self::render_tree_item(ix, entry, selected, &view)
|
||||
})
|
||||
.p_2(),
|
||||
)
|
||||
})
|
||||
.when(self.diff.is_none() && !self.loading, |this| {
|
||||
this.child(placeholder("Failed to load diff", cx))
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Right column: header of the selected file plus its diff.
|
||||
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element();
|
||||
}
|
||||
if let Some(error) = self.error.clone() {
|
||||
return placeholder(&error, cx);
|
||||
}
|
||||
let Some(diff) = self.diff.as_ref() else {
|
||||
return placeholder("Failed to load diff", cx);
|
||||
};
|
||||
let Some(path) = self.selected_file.clone() else {
|
||||
return if diff.files.is_empty() {
|
||||
placeholder("No files changed in this pull request", cx)
|
||||
} else {
|
||||
placeholder("Select a file", cx)
|
||||
};
|
||||
};
|
||||
let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else {
|
||||
return placeholder("File not found", cx);
|
||||
};
|
||||
self.render_file_diff(file, cx.entity(), cx)
|
||||
}
|
||||
|
||||
/// The diff of one file: a header with status and stats, then the hunks
|
||||
/// in a virtual list (a large diff is never materialized per frame).
|
||||
fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
|
||||
let status_label = match file.status {
|
||||
signed_git::DiffStatus::Added => "A",
|
||||
signed_git::DiffStatus::Modified => "M",
|
||||
signed_git::DiffStatus::Deleted => "D",
|
||||
signed_git::DiffStatus::Renamed => "R",
|
||||
signed_git::DiffStatus::Copied => "C",
|
||||
};
|
||||
let status_color = match file.status {
|
||||
signed_git::DiffStatus::Added => cx.theme().success,
|
||||
signed_git::DiffStatus::Modified => cx.theme().info,
|
||||
signed_git::DiffStatus::Deleted => cx.theme().danger,
|
||||
signed_git::DiffStatus::Renamed | signed_git::DiffStatus::Copied => {
|
||||
cx.theme().muted_foreground
|
||||
}
|
||||
};
|
||||
let title = match &file.old_path {
|
||||
Some(old) => format!("{old} → {}", file.path),
|
||||
None => file.path.clone(),
|
||||
};
|
||||
|
||||
let body: AnyElement = if file.binary {
|
||||
placeholder("Diff not available", cx)
|
||||
} else if file.hunks.is_empty() {
|
||||
placeholder("No content changes", cx)
|
||||
} else {
|
||||
let sizes = self.item_sizes.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
v_flex()
|
||||
.size_full()
|
||||
.relative()
|
||||
.child(
|
||||
v_virtual_list(
|
||||
view,
|
||||
"pr-diff-rows",
|
||||
sizes,
|
||||
move |this, range, _window, cx| {
|
||||
let Some(diff) = this.diff.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(path) = this.selected_file.as_deref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(file) = diff.files.iter().find(|file| file.path == path)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
range
|
||||
.map(|ix| render_diff_row(&file.hunks, this.rows[ix], cx))
|
||||
.collect()
|
||||
},
|
||||
)
|
||||
.track_scroll(&scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(&scroll_handle)),
|
||||
)
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.h_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_3()
|
||||
.h_9()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(status_color)
|
||||
.child(status_label),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(title),
|
||||
)
|
||||
.when(!file.binary, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().success)
|
||||
.child(format!("+{}", file.insertions)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(format!("-{}", file.deletions)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(div().id("pr-diff-body").flex_1().min_h_0().child(body))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Underline tab bar: Discussion, Files and Commits.
|
||||
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let active = self.active_tab;
|
||||
let files_count = self.diff.as_ref().map(|diff| diff.files.len());
|
||||
let files_count = self.pane.read(cx).diff().map(|diff| diff.files.len());
|
||||
let commits_count = if self.commits.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -589,29 +310,19 @@ impl PullRequestDetailView {
|
||||
Tab::new()
|
||||
.label("Files")
|
||||
.when_some(files_count, |this, count| {
|
||||
this.suffix(
|
||||
Tag::secondary()
|
||||
.xsmall()
|
||||
.child(SharedString::from(count.to_string())),
|
||||
)
|
||||
this.suffix(CountBadge::new(count))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Tab::new()
|
||||
.label("Commits")
|
||||
.when_some(commits_count, |this, count| {
|
||||
this.suffix(
|
||||
Tag::secondary()
|
||||
.xsmall()
|
||||
.child(SharedString::from(count.to_string())),
|
||||
)
|
||||
this.suffix(CountBadge::new(count))
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Discussion tab: author, description and comments like the issue
|
||||
/// panel, with the comment form at the end and a sidebar on the right.
|
||||
fn render_discussion(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return v_flex()
|
||||
@@ -686,111 +397,49 @@ impl PullRequestDetailView {
|
||||
this.child(div().text_sm().child(self.description.clone()))
|
||||
}),
|
||||
)
|
||||
.child(self.render_comments(&root_id, cx))
|
||||
.child(self.render_form(&root_id, cx)),
|
||||
.child(comments_section(&self.store, root_id, cx))
|
||||
.child(comment_form(
|
||||
&self.store,
|
||||
root_id,
|
||||
pr_roots,
|
||||
&self.comment_input,
|
||||
"pr-comment",
|
||||
cx,
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(self.render_sidebar(cx))
|
||||
.child(sidebar_section(&self.store, root_id, pr_roots, true, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Right sidebar: participants and labels, like the issue panel.
|
||||
fn render_sidebar(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
let store = self.store.read(cx);
|
||||
|
||||
let Some(root) = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
||||
else {
|
||||
// `render_discussion` already bails out when the PR is missing.
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
// Participants: the PR author plus everyone who commented.
|
||||
let mut participants: Vec<PublicKey> = vec![root.pubkey];
|
||||
participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey));
|
||||
participants.sort_by_key(PublicKey::to_hex);
|
||||
participants.dedup();
|
||||
|
||||
// PR labels are NIP-34 `t` hashtag tags on the event.
|
||||
let labels: Vec<String> = root.tags.hashtags().map(|tag| tag.to_string()).collect();
|
||||
|
||||
v_flex()
|
||||
.w(px(240.))
|
||||
.h_full()
|
||||
.flex_none()
|
||||
.px_4()
|
||||
.gap_4()
|
||||
.border_l(px(1.))
|
||||
.border_color(cx.theme().sidebar_border)
|
||||
.child(
|
||||
v_flex()
|
||||
.mt_4()
|
||||
.gap_2()
|
||||
.child(sidebar_title("Participants", cx))
|
||||
.children(participants.iter().map(|pubkey| {
|
||||
let profile = profile_store.read(cx).get(pubkey);
|
||||
let name = profile.name();
|
||||
let picture = profile.picture();
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(UserAvatar::new(name.clone()).picture(picture))
|
||||
.child(div().text_sm().truncate().text_ellipsis().child(name))
|
||||
.into_any_element()
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(sidebar_title("Labels", cx))
|
||||
.map(|this| {
|
||||
if labels.is_empty() {
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("None yet."),
|
||||
)
|
||||
} else {
|
||||
this.child(h_flex().gap_1().children({
|
||||
let mut items = vec![];
|
||||
|
||||
for label in labels.iter() {
|
||||
items.push(
|
||||
Tag::secondary()
|
||||
.outline()
|
||||
.xsmall()
|
||||
.child(SharedString::from(label)),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Files tab: the changed-files tree on the left, the diff of the
|
||||
/// selected file on the right.
|
||||
fn render_files_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return v_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_h_0()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
if let Some(error) = self.error.clone() {
|
||||
return placeholder(&error, cx);
|
||||
}
|
||||
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_h_0()
|
||||
.overflow_hidden()
|
||||
.child(self.render_tree_column(cx))
|
||||
.child(self.render_detail_column(cx))
|
||||
.child(self.pane.clone())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Full-height Commits tab: every commit of the patch series, or a
|
||||
/// status message while loading / when there are none.
|
||||
/// Full-height Commits tab.
|
||||
///
|
||||
/// Every commit of the patch series, or a status message while loading or empty.
|
||||
fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return v_flex()
|
||||
@@ -838,8 +487,9 @@ impl PullRequestDetailView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// One row of the commits tab: id, summary, author and time. Clicking a
|
||||
/// row opens the commit's diff in the bottom dock.
|
||||
/// One row of the commits tab, id, summary, author and time.
|
||||
///
|
||||
/// Clicking a row opens the commit's diff in the bottom dock.
|
||||
fn render_commit_row(
|
||||
&self,
|
||||
ix: usize,
|
||||
@@ -852,7 +502,7 @@ impl PullRequestDetailView {
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.px_4()
|
||||
.h(px(PR_COMMIT_ROW_HEIGHT))
|
||||
.h(px(ROW_HEIGHT))
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.text_sm()
|
||||
@@ -882,8 +532,8 @@ impl PullRequestDetailView {
|
||||
.child(SharedString::from(meta)),
|
||||
)
|
||||
})
|
||||
// Commits parsed from the nostr patch set may not exist in any
|
||||
// local clone; only git-backed PRs open a diff viewer.
|
||||
// Commits parsed from the nostr patch set may not exist in any local clone.
|
||||
// Only git-backed PRs open a diff viewer.
|
||||
.when_some(self.worktree.clone(), |this, worktree| {
|
||||
this.on_click(cx.listener(move |this, _event, window, cx| {
|
||||
this.open_commit_diff(worktree.clone(), &id, window, cx);
|
||||
@@ -892,117 +542,7 @@ impl PullRequestDetailView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// One comment card, same design as the issue panel: avatar, author,
|
||||
/// "commented" and age on the header row, content below.
|
||||
fn render_comments(&mut self, id: &EventId, cx: &mut Context<Self>) -> AnyElement {
|
||||
let store = self.store.read(cx);
|
||||
let comments: Vec<&Event> = store.comments_of(id).collect();
|
||||
let title = SharedString::from(format!("Discussions {}", comments.len()));
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(div().text_xs().font_semibold().child(title))
|
||||
.children(comments.iter().map(|comment| {
|
||||
let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey);
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
let age = relative_time(comment.created_at);
|
||||
// Comment bodies are cloned into shared strings once per
|
||||
// comment, not on every render.
|
||||
let content = self
|
||||
.contents
|
||||
.entry(comment.id)
|
||||
.or_insert_with(|| SharedString::from(comment.content.clone()))
|
||||
.clone();
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(UserAvatar::new(author.clone()).picture(picture))
|
||||
.child(author),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("commented"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.child(div().text_sm().child(content))
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_form(&mut self, id: &EventId, cx: &mut Context<Self>) -> AnyElement {
|
||||
let comment_input = self.comment_input.clone();
|
||||
let store = self.store.clone();
|
||||
let id = id.to_owned();
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Textarea::new(&self.comment_input)
|
||||
.h_24()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.bg(cx.theme().muted),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Icon::new(CustomIconName::Markdown).small())
|
||||
.child("Markdown is supported"),
|
||||
)
|
||||
.child(
|
||||
Button::new("pr-comment")
|
||||
.primary()
|
||||
.label("Comment")
|
||||
.tooltip("Post comment")
|
||||
.on_click(move |_event, window, cx| {
|
||||
let content = comment_input.read(cx).value().trim().to_string();
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(root) = store
|
||||
.read(cx)
|
||||
.pull_requests
|
||||
.iter()
|
||||
.find(|pr| pr.id == id)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
store.update(cx, |store, cx| {
|
||||
store.comment(&root, content, cx);
|
||||
});
|
||||
comment_input.update(cx, |input, cx| {
|
||||
input.set_value("", window, cx);
|
||||
});
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Always-visible header: status badge and title, like the issue panel.
|
||||
/// Always-visible header with a status badge and title, like the issue panel.
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let current_commit = self.current_commit.clone();
|
||||
let (title, status, branch, author) = {
|
||||
@@ -1022,7 +562,7 @@ impl PullRequestDetailView {
|
||||
)
|
||||
};
|
||||
|
||||
// Only the PR author may publish revisions (kind 1619, NIP-34).
|
||||
// Only the PR author may publish revisions, NIP-34 kind 1619.
|
||||
let backend = Backend::global(cx);
|
||||
let can_update = backend.read(cx).current_user() == Some(author);
|
||||
|
||||
@@ -1104,8 +644,7 @@ impl PullRequestDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the "update pull request" dialog: a patch input that submits a new
|
||||
/// revision through [`RepoStore::update_pull_request`] when confirmed.
|
||||
/// Open the update pull request dialog.
|
||||
fn open_update_pull_request_dialog(
|
||||
store: Entity<RepoStore>,
|
||||
root: Event,
|
||||
@@ -1115,8 +654,8 @@ fn open_update_pull_request_dialog(
|
||||
let patch = cx.new(|cx| {
|
||||
TextareaState::new(window, cx).placeholder("Paste the updated `git format-patch` output...")
|
||||
});
|
||||
// Both the dialog body and the submit button capture the root event;
|
||||
// share it instead of cloning into each closure.
|
||||
// Both the dialog body and submit button capture the root event.
|
||||
// Share it instead of cloning into each closure.
|
||||
let root = Rc::new(root);
|
||||
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
@@ -1166,20 +705,9 @@ fn open_update_pull_request_dialog(
|
||||
});
|
||||
}
|
||||
|
||||
/// One sidebar section title.
|
||||
fn sidebar_title(text: &str, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(text.to_string())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The `c` tag of a PR event (tip of the proposed branch), as hex.
|
||||
fn current_commit_of(event: &Event) -> Option<String> {
|
||||
event
|
||||
.tags
|
||||
/// The `c` tag of a PR event, the commit the proposal points at.
|
||||
fn current_commit_of(root: &Event) -> Option<String> {
|
||||
root.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
|
||||
@@ -1187,8 +715,9 @@ fn current_commit_of(event: &Event) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The `merge-base` tag of a PR event (most recent common ancestor with the
|
||||
/// target branch), as hex.
|
||||
/// The `merge-base` tag of a PR event, as hex.
|
||||
///
|
||||
/// The most recent common ancestor with the target branch.
|
||||
fn merge_base_of(event: &Event) -> Option<String> {
|
||||
event
|
||||
.tags
|
||||
@@ -1199,8 +728,9 @@ fn merge_base_of(event: &Event) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The `clone` tag of a PR event (URLs where the proposed branch can be
|
||||
/// fetched), or `None` if the PR has none.
|
||||
/// The `clone` tag of a PR event.
|
||||
///
|
||||
/// URLs where the proposed branch can be fetched, or `None` if the PR has none.
|
||||
fn clone_urls_of(event: &Event) -> Option<Vec<String>> {
|
||||
event
|
||||
.tags
|
||||
@@ -1222,9 +752,7 @@ fn branch_name_of(event: &Event) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The latest PR update (kind 1619) revising `root`, found via its NIP-22
|
||||
/// `E` tag pointing at the root PR event. Only updates by the PR author
|
||||
/// count: the tip of a PR is only mutable by its author (NIP-34).
|
||||
/// The latest PR update, kind 1619, revising `root`.
|
||||
fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &Event) -> Option<&'a Event> {
|
||||
let root_hex = root.id.to_hex();
|
||||
events
|
||||
@@ -1238,8 +766,9 @@ fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &Event) -> O
|
||||
.max_by_key(|e| e.created_at)
|
||||
}
|
||||
|
||||
/// One-line commit metadata for the commits list: author and relative time,
|
||||
/// whichever is available.
|
||||
/// One-line commit metadata for the commits list.
|
||||
///
|
||||
/// Author and relative time, whichever is available.
|
||||
fn commit_meta(commit: &FileCommit) -> String {
|
||||
let author = commit.author.trim();
|
||||
let time = commit.time > 0;
|
||||
@@ -1281,7 +810,7 @@ impl Focusable for PullRequestDetailView {
|
||||
impl Render for PullRequestDetailView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.image_cache(image_cache("pull-request-detail", MAX_IMAGES))
|
||||
.image_cache(gpui::retain_all("pull-request-detail"))
|
||||
.id("pull-request-detail")
|
||||
.size_full()
|
||||
.min_h_0()
|
||||
@@ -1357,8 +886,7 @@ mod tests {
|
||||
created_at,
|
||||
)
|
||||
};
|
||||
// An update revising a different PR must be ignored even though it
|
||||
// is newer.
|
||||
// An update revising a different PR must be ignored even though it is newer.
|
||||
let unrelated = signed(
|
||||
Kind::GitPullRequestUpdate,
|
||||
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
|
||||
@@ -1386,8 +914,8 @@ mod tests {
|
||||
.finalize(&other)
|
||||
.expect("signed event");
|
||||
|
||||
// The tip of a PR is only mutable by its author: a newer update
|
||||
// from anyone else must not win.
|
||||
// The tip of a PR is only mutable by its author.
|
||||
// A newer update from anyone else must not win.
|
||||
assert!(latest_update([&stranger, &root].into_iter(), &root).is_none());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
@@ -16,7 +16,6 @@ use gpui_component::{
|
||||
use nostr::prelude::{EventId, Kind};
|
||||
use signed_core::{RepoStatus, activity_subject};
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{DropdownButton, SegmentButton, UserAvatar, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
@@ -25,12 +24,10 @@ use super::new_pull_request::open_new_pull_panel;
|
||||
use super::pull_request_detail::PullRequestDetailView;
|
||||
use super::send_patch::open_send_patch_panel;
|
||||
|
||||
/// Height of one pull request row in the virtual list; same layout as an
|
||||
/// issue row.
|
||||
const PR_ROW_HEIGHT: f32 = 73.;
|
||||
/// Height of one pull request row in the virtual list.
|
||||
const ROW_HEIGHT: f32 = 73.;
|
||||
|
||||
/// Status filter of the pull request list, chosen via the header's filter
|
||||
/// buttons.
|
||||
/// Status filter of the pull request list, chosen via the header's filter buttons.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PullRequestFilter {
|
||||
/// Every pull request, regardless of status.
|
||||
@@ -70,17 +67,11 @@ pub struct PullRequestsView {
|
||||
filter: PullRequestFilter,
|
||||
/// Per-row heights of the virtual list.
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// Number of rows [`Self::item_sizes`] was built for (the filtered
|
||||
/// pull request count); rebuilt on change.
|
||||
/// The filtered pull request count [`Self::item_sizes`] was built for.
|
||||
pr_len: usize,
|
||||
/// Indices into the store's `pull_requests` matching [`Self::filter`]
|
||||
/// (root PR events only; updates are revisions of the root); the
|
||||
/// virtual list renders this slice. Rebuilt only when the store
|
||||
/// version or the filter changes, keyed by [`Self::cache_key`].
|
||||
/// Indices into the store's `pull_requests` matching [`Self::filter`].
|
||||
visible_prs: Vec<usize>,
|
||||
/// Header counts `(total, open, closed, draft, merged)` of the root
|
||||
/// pull requests only (revisions are not separate PRs), rebuilt with
|
||||
/// [`Self::visible_prs`].
|
||||
/// Header counts `(total, open, closed, draft, merged)`.
|
||||
counts: (usize, usize, usize, usize, usize),
|
||||
/// Store version and filter the cached rows/counts were built from.
|
||||
cache_key: Option<(u64, PullRequestFilter)>,
|
||||
@@ -92,10 +83,11 @@ impl PullRequestsView {
|
||||
pub fn new(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
repo_name: SharedString,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let repo_name = store.read(cx).name();
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
@@ -111,7 +103,7 @@ impl PullRequestsView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the detail panel of `pr_id` at the bottom of the dock area.
|
||||
/// Open the detail panel of `pr_id` in the dock area.
|
||||
fn open_pull_request_detail(
|
||||
&mut self,
|
||||
pr_id: EventId,
|
||||
@@ -133,12 +125,13 @@ impl PullRequestsView {
|
||||
});
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Render one row of the pull request list; `ix` is the row index and
|
||||
/// `pr_ix` the index of the pull request in the store's `pull_requests`.
|
||||
/// Render one row of the pull request list.
|
||||
///
|
||||
/// `ix` is the row index, `pr_ix` the index in the store's `pull_requests`.
|
||||
fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||
let pr = &self.store.read(cx).pull_requests[pr_ix];
|
||||
let pr_id = pr.id;
|
||||
@@ -203,8 +196,7 @@ impl PullRequestsView {
|
||||
}
|
||||
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
// Counts of the last list rebuild (`render` rebuilds first when the
|
||||
// store version or filter changed, so this is never stale).
|
||||
// Counts of the last list rebuild.
|
||||
let (total, open, closed, draft, merged) = self.counts;
|
||||
|
||||
h_flex()
|
||||
@@ -339,9 +331,9 @@ impl Render for PullRequestsView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let filter = self.filter;
|
||||
|
||||
// Rebuild the filtered rows and header counts only when the store
|
||||
// refreshed or the filter changed; other renders reuse the cache.
|
||||
// Rows and counts are rebuilt only when the store refreshed or filter changed.
|
||||
let version = self.store.read(cx).version();
|
||||
|
||||
if self.cache_key != Some((version, filter)) {
|
||||
let store = self.store.read(cx);
|
||||
let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
|
||||
@@ -350,43 +342,43 @@ impl Render for PullRequestsView {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(ix, pr)| {
|
||||
// Kind-30620 patches are revisions of a root PR (NIP-34),
|
||||
// not separate pull requests: count only root events, or
|
||||
// the header counts inflate with every revision (which
|
||||
// also default to `Open` in `status_of`).
|
||||
if pr.kind != Kind::GitPullRequest {
|
||||
return None;
|
||||
}
|
||||
|
||||
let status = store.status_of(pr);
|
||||
counts.0 += 1;
|
||||
|
||||
match status {
|
||||
RepoStatus::Open => counts.1 += 1,
|
||||
RepoStatus::Closed => counts.2 += 1,
|
||||
RepoStatus::Draft => counts.3 += 1,
|
||||
RepoStatus::Applied => counts.4 += 1,
|
||||
}
|
||||
|
||||
filter.matches(status).then_some(ix)
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.counts = counts;
|
||||
self.cache_key = Some((version, filter));
|
||||
}
|
||||
|
||||
let count = self.visible_prs.len();
|
||||
|
||||
// The virtual list's item count comes from `item_sizes`; rebuild it
|
||||
// whenever the filtered pull request count changes.
|
||||
// The virtual list's item count comes from `item_sizes`.
|
||||
// Rebuild it whenever the filtered pull request count changes.
|
||||
if count != self.pr_len {
|
||||
self.pr_len = count;
|
||||
self.item_sizes = Rc::new(vec![size(px(0.), px(PR_ROW_HEIGHT)); count]);
|
||||
self.item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); count]);
|
||||
}
|
||||
|
||||
let sizes = self.item_sizes.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
let view = cx.entity().clone();
|
||||
|
||||
// Non-fatal warnings and errors of the last action (e.g. creating
|
||||
// or updating a PR), shown as dismissible banners above the list.
|
||||
// Non-fatal warnings and errors of the last action, like creating or updating a PR.
|
||||
// Shown as dismissible banners above the list.
|
||||
let (last_error, last_warning) = {
|
||||
let store = self.store.read(cx);
|
||||
(store.last_error.clone(), store.last_warning.clone())
|
||||
@@ -394,7 +386,7 @@ impl Render for PullRequestsView {
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.image_cache(image_cache("pull-requests", MAX_IMAGES))
|
||||
.image_cache(gpui::retain_all("pull-requests"))
|
||||
.on_action(cx.listener(|this, action: &RepoAction, window, cx| {
|
||||
if action == &RepoAction::SendPatch {
|
||||
open_send_patch_panel(this.dock_area.clone(), this.store.clone(), window, cx);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
|
||||
Subscription, WeakEntity, Window, div, px,
|
||||
WeakEntity, Window, div, px,
|
||||
};
|
||||
use gpui_base::{Button as BaseButton, StyledExt};
|
||||
use gpui_component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
||||
use gpui_component::input::{Input, InputState, Textarea, TextareaState};
|
||||
use gpui_component::scroll::ScrollableElement;
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
|
||||
@@ -19,17 +19,16 @@ pub struct SendPatchView {
|
||||
store: Entity<RepoStore>,
|
||||
/// Display name of the repository, for the panel title.
|
||||
repo_name: SharedString,
|
||||
/// Title input (required).
|
||||
/// Title input, required.
|
||||
subject: Entity<InputState>,
|
||||
/// Description input (optional).
|
||||
/// Description input, optional.
|
||||
description: Entity<TextareaState>,
|
||||
/// The pasted `git format-patch` output (required).
|
||||
/// The pasted `git format-patch` output, required.
|
||||
patch: Entity<TextareaState>,
|
||||
/// A submit is in flight.
|
||||
submitting: bool,
|
||||
/// Error of the last submit attempt (keeps the panel open).
|
||||
/// Error of the last submit attempt, it keeps the panel open.
|
||||
error: Option<SharedString>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl SendPatchView {
|
||||
@@ -41,22 +40,14 @@ impl SendPatchView {
|
||||
) -> Self {
|
||||
let repo_name = store.read(cx).name();
|
||||
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
|
||||
|
||||
let description = cx
|
||||
.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change (optional)"));
|
||||
|
||||
let patch = cx.new(|cx| {
|
||||
TextareaState::new(window, cx).placeholder("diff --git a/file.txt b/file.txt\nindex 1234567..abcdefg 100644\n--- a/file.txt\n+++ b/file.txt")
|
||||
});
|
||||
|
||||
// Re-evaluate the Send button's enabled state as the inputs change.
|
||||
let subscriptions = vec![
|
||||
cx.subscribe(&subject, |_this, _state, _event: &InputEvent, cx| {
|
||||
cx.notify();
|
||||
}),
|
||||
cx.subscribe(&patch, |_this, _state, _event: &InputEvent, cx| {
|
||||
cx.notify();
|
||||
}),
|
||||
];
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
@@ -67,24 +58,23 @@ impl SendPatchView {
|
||||
patch,
|
||||
submitting: false,
|
||||
error: None,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish the pull request from the pasted patch. The store validates
|
||||
/// synchronously (patch shape, per-part size, sign-in); on failure the
|
||||
/// panel stays open with the error inline, on success it closes — async
|
||||
/// publish failures surface in the pull request list's banner.
|
||||
/// Publish the pull request from the pasted patch.
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.submitting {
|
||||
return;
|
||||
}
|
||||
|
||||
let subject = self.subject.read(cx).value().to_string();
|
||||
let description = self.description.read(cx).value().to_string();
|
||||
let patch = self.patch.read(cx).value().to_string();
|
||||
|
||||
if patch.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let store = self.store.clone();
|
||||
let dock_area = self.dock_area.clone();
|
||||
let entity = cx.entity().clone();
|
||||
@@ -93,8 +83,7 @@ impl SendPatchView {
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
|
||||
// Errors the store detects before publishing are returned
|
||||
// synchronously through `last_error`.
|
||||
// Errors the store detects before publishing.
|
||||
let sync_error = store.update(cx, |store, cx| {
|
||||
store.open_pull_request(
|
||||
(!subject.is_empty()).then_some(subject),
|
||||
@@ -128,6 +117,7 @@ impl SendPatchView {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -212,7 +202,7 @@ pub(super) fn open_send_patch_panel(
|
||||
let panel = cx.new(|cx| SendPatchView::new(dock_area.clone(), store, window, cx));
|
||||
|
||||
let _ = dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ use gpui_component::{
|
||||
};
|
||||
use signed_core::Announcement;
|
||||
use signed_state::{ProfileStore, RepoListStore, Timestamp};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{SegmentButton, UserAvatar};
|
||||
use utils::relative_time;
|
||||
|
||||
@@ -24,13 +23,13 @@ use super::open_repo_panel;
|
||||
const COLUMNS: usize = 2;
|
||||
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.;
|
||||
|
||||
/// How many of the newest repositories the "Recent" sort shows.
|
||||
/// How many of the newest repositories the `Recent` sort shows.
|
||||
const RECENT_COUNT: usize = 10;
|
||||
|
||||
/// Sort of the explore list, chosen via the header's filter buttons.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
enum RepoFilter {
|
||||
/// Every repository, newest first (the store's default order).
|
||||
/// Every repository in the store's default order, newest first.
|
||||
All,
|
||||
#[default]
|
||||
/// Repositories ranked by total issues + pull requests + commits.
|
||||
@@ -40,15 +39,15 @@ enum RepoFilter {
|
||||
}
|
||||
|
||||
impl RepoFilter {
|
||||
/// Indices into the store's `announcements` included by this filter, in
|
||||
/// display order, narrowed to repositories whose name (or id) contains
|
||||
/// `query`; an empty query matches everything.
|
||||
/// Indices into the store's `announcements` this filter includes, in display order.
|
||||
///
|
||||
/// Narrowed to repositories whose name or id contains `query`.
|
||||
fn visible(self, store: &RepoListStore, query: &str) -> Vec<usize> {
|
||||
let announcements = &store.announcements;
|
||||
let mut indices: Vec<usize> = (0..announcements.len()).collect();
|
||||
|
||||
// Narrow by the search query first, so "Recent" limits the matches
|
||||
// and "Popular" ranks them.
|
||||
// Narrow by the search query first.
|
||||
// Recent then limits the matches and Popular ranks them.
|
||||
let query = query.trim().to_lowercase();
|
||||
if !query.is_empty() {
|
||||
indices.retain(|&ix| {
|
||||
@@ -93,10 +92,9 @@ pub struct RepoListView {
|
||||
filter: RepoFilter,
|
||||
/// Per-row heights of the virtual list.
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// Number of rows [`Self::item_sizes`] was built for (the filtered repo count).
|
||||
/// Number of rows [`Self::item_sizes`] was built for, the filtered repo count.
|
||||
repo_len: usize,
|
||||
/// Indices into the store's `announcements` matching [`Self::filter`],
|
||||
/// in display order; the virtual list renders this slice.
|
||||
/// Indices matching [`Self::filter`] into the store's `announcements`.
|
||||
visible: Vec<usize>,
|
||||
/// Search box filtering repositories by name.
|
||||
search: Entity<InputState>,
|
||||
@@ -121,8 +119,8 @@ impl RepoListView {
|
||||
}
|
||||
});
|
||||
|
||||
// Keep the visible slice and row sizes in sync with the store,
|
||||
// so newly announced repositories appear without waiting for a click.
|
||||
// Keep the visible slice and row sizes in sync with the store.
|
||||
// Newly announced repositories appear without waiting for a click.
|
||||
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||
this.rebuild_rows(cx);
|
||||
});
|
||||
@@ -141,16 +139,17 @@ impl RepoListView {
|
||||
_subscription: subscription,
|
||||
};
|
||||
|
||||
// Seed the rows right away; the store may already hold announcements
|
||||
// (it loaded before the panel opened), and the first render must not
|
||||
// depend on a later store update.
|
||||
// Seed the rows right away.
|
||||
// The store may already hold announcements from before the panel opened.
|
||||
// The first render must not depend on a later store update.
|
||||
this.rebuild_rows(cx);
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
/// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the current
|
||||
/// store contents, [`Self::filter`] and the search query.
|
||||
/// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the store.
|
||||
///
|
||||
/// Uses the store contents, [`Self::filter`] and the search query.
|
||||
fn rebuild_rows(&mut self, cx: &mut Context<Self>) {
|
||||
let filter = self.filter;
|
||||
let query = self.search.read(cx).value();
|
||||
@@ -188,12 +187,14 @@ impl RepoListView {
|
||||
|
||||
let name = announcement
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or(SharedString::from(announcement.id.clone()));
|
||||
|
||||
let description = announcement
|
||||
.description
|
||||
.clone()
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or(SharedString::from("No description"));
|
||||
|
||||
let activity = last_activity
|
||||
@@ -201,8 +202,8 @@ impl RepoListView {
|
||||
.map(|label| SharedString::from(format!("Updated {label}")))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Fork badge: the upstream's display name when its announcement is
|
||||
// known locally, otherwise its repository id from the `u` tag.
|
||||
// The fork badge shows the upstream name when its announcement is known locally.
|
||||
// Otherwise it shows the repository id from the `u` tag.
|
||||
let fork_label: Option<SharedString> =
|
||||
announcement.upstream.as_ref().and_then(|upstream| {
|
||||
let addr = upstream.addr.as_ref()?;
|
||||
@@ -214,7 +215,8 @@ impl RepoListView {
|
||||
.find(|a| a.addr() == *addr)
|
||||
.map(|a| {
|
||||
a.name
|
||||
.clone()
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(a.id.clone()))
|
||||
})
|
||||
.unwrap_or_else(|| SharedString::from(addr.identifier.clone()));
|
||||
@@ -347,8 +349,7 @@ impl RepoListView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// One segmented filter button of the header, styled like the issues
|
||||
/// list's status filter buttons.
|
||||
/// One segmented header filter button, like the issues list's status filter buttons.
|
||||
fn filter_button(
|
||||
&self,
|
||||
filter: RepoFilter,
|
||||
@@ -398,7 +399,7 @@ impl Render for RepoListView {
|
||||
|
||||
v_flex()
|
||||
.relative()
|
||||
.image_cache(image_cache("repos", MAX_IMAGES))
|
||||
.image_cache(gpui::retain_all("repos"))
|
||||
.size_full()
|
||||
.child(self.render_header(count, cx))
|
||||
.when(!has_repos, |this| {
|
||||
|
||||
@@ -1,31 +1,26 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use dock::DockArea;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px};
|
||||
use gpui::{App, Entity, PathPromptOptions, WeakEntity, Window, div, px};
|
||||
use gpui_base::input::TextareaState;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Input, InputState, Textarea};
|
||||
use gpui_component::{ActiveTheme, Disableable, IconName, WindowExt, h_flex};
|
||||
use gpui_component::{Disableable, IconName, WindowExt, h_flex};
|
||||
use settings::SettingsStore;
|
||||
use signed_core::Announcement;
|
||||
use signed_state::Backend;
|
||||
use signed_state::{Backend, CheckoutsStore};
|
||||
|
||||
use super::super::open_repo_panel;
|
||||
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
|
||||
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||
|
||||
/// Shared state for the Create Repository dialog, so async results can be rendered.
|
||||
#[derive(Default)]
|
||||
pub struct CreateRepoState {
|
||||
pub busy: bool,
|
||||
pub error: Option<SharedString>,
|
||||
}
|
||||
pub type CreateRepoState = DialogProgress;
|
||||
|
||||
/// Open the Create Repository dialog.
|
||||
///
|
||||
/// The dialog loads the user's default grasp servers (kind `10317` grasp
|
||||
/// list) and falls back to the shared defaults when none are set. On
|
||||
/// success the dialog closes and the new repository opens in the dock.
|
||||
pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
|
||||
let settings = SettingsStore::global(cx);
|
||||
let default_folder = settings
|
||||
@@ -56,7 +51,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
|
||||
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
const DESC: &str = "Publish a new repository to your grasp servers.";
|
||||
const FOLDER_NOTE: &str = "Where the repository is stored.";
|
||||
const FOLDER_NOTE: &str = "Where the repository's working copy is created.";
|
||||
|
||||
let name_input = name_input.clone();
|
||||
let desc_input = desc_input.clone();
|
||||
@@ -119,9 +114,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
|
||||
)
|
||||
.child(grasp_servers_field(&grasp_state, &relay_input, cx)),
|
||||
)
|
||||
.children(error.map(|message| {
|
||||
div().text_sm().text_color(cx.theme().danger).child(message)
|
||||
}))
|
||||
.children(error_row(&error, cx))
|
||||
.child(
|
||||
DialogFooter::new().justify_end().child(
|
||||
Button::new("create")
|
||||
@@ -134,6 +127,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
|
||||
.on_click({
|
||||
let name_input = name_input.clone();
|
||||
let desc_input = desc_input.clone();
|
||||
let folder_input = folder_input.clone();
|
||||
let state = state.clone();
|
||||
let grasp_state = grasp_state.clone();
|
||||
let dock_area = dock_area.clone();
|
||||
@@ -142,6 +136,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
|
||||
create_repository(
|
||||
name_input.clone(),
|
||||
desc_input.clone(),
|
||||
folder_input.clone(),
|
||||
state.clone(),
|
||||
grasp_state.clone(),
|
||||
dock_area.clone(),
|
||||
@@ -156,10 +151,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
|
||||
});
|
||||
}
|
||||
|
||||
/// Prompt the user to pick the folder the repository will be stored in, using
|
||||
/// the platform's native folder picker, and show the result in the disabled
|
||||
/// folder input. The picked folder is remembered in the settings so it
|
||||
/// becomes the default next time.
|
||||
/// Pick the repository's storage folder with the platform's native folder picker.
|
||||
fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||
let handle = window.window_handle();
|
||||
let folder_input = folder_input.clone();
|
||||
@@ -194,10 +186,14 @@ fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mu
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Run the create-repository flow; closes the dialog and opens the new repository on success.
|
||||
/// Run the create-repository flow.
|
||||
///
|
||||
/// Opens the new working copy and the repository panel on success.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn create_repository(
|
||||
name_input: Entity<InputState>,
|
||||
desc_input: Entity<TextareaState>,
|
||||
folder_input: Entity<InputState>,
|
||||
state: Entity<CreateRepoState>,
|
||||
grasp_state: Entity<GraspServersState>,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
@@ -206,48 +202,47 @@ fn create_repository(
|
||||
) {
|
||||
let name = name_input.read(cx).value().trim().to_owned();
|
||||
let description = desc_input.read(cx).value().trim().to_owned();
|
||||
let folder = PathBuf::from(folder_input.read(cx).value().trim());
|
||||
let servers = grasp_state.read(cx).grasp_servers.clone();
|
||||
|
||||
if name.is_empty() {
|
||||
state.update(cx, |state, _| {
|
||||
state.error = Some("Repository name is required".into());
|
||||
});
|
||||
state.update(cx, |state, _| state.fail("Repository name is required"));
|
||||
return;
|
||||
}
|
||||
if servers.is_empty() {
|
||||
state.update(cx, |state, _| {
|
||||
state.error = Some("Add at least one grasp server".into());
|
||||
});
|
||||
state.update(cx, |state, _| state.fail("Add at least one grasp server"));
|
||||
return;
|
||||
}
|
||||
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = true;
|
||||
state.error = None;
|
||||
});
|
||||
state.update(cx, |state, _| state.begin());
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
backend.create_repository(&name, &description, servers, cx)
|
||||
backend.create_repository(&name, &description, folder, servers, cx)
|
||||
});
|
||||
|
||||
let handle = window.window_handle();
|
||||
let state = state.clone();
|
||||
let dock_area = dock_area.clone();
|
||||
|
||||
cx.spawn(async move |cx| match task.await {
|
||||
Ok(announcement) => {
|
||||
Ok((announcement, local_path)) => {
|
||||
cx.update_window(handle, |_, window, cx| {
|
||||
window.close_dialog(cx);
|
||||
// Record the new working copy as a checkout of this repository.
|
||||
// The New PR panel then pre-fills it.
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
checkouts.update(cx, |store, cx| {
|
||||
store.record(local_path.clone(), announcement.addr(), cx);
|
||||
});
|
||||
cx.open_with_system(&local_path);
|
||||
open_repo(dock_area, announcement, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update_window(handle, |_, _window, cx| {
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = false;
|
||||
state.error = Some(e.to_string().into());
|
||||
});
|
||||
state.update(cx, |state, _| state.fail(e.to_string()));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
@@ -6,27 +6,24 @@ use gpui_component::input::{Input, InputState};
|
||||
use gpui_component::{ActiveTheme, IconName, Sizable, h_flex, v_flex};
|
||||
use nostr::prelude::*;
|
||||
use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
|
||||
use signed_core::filters;
|
||||
use signed_state::Backend;
|
||||
|
||||
/// State of the grasp-server section of a publish dialog, so async
|
||||
/// results can be rendered.
|
||||
/// State of the grasp-server section of a publish dialog, so async results can be rendered.
|
||||
#[derive(Default)]
|
||||
pub struct GraspServersState {
|
||||
/// The user's grasp list (kind `10317`) is being loaded.
|
||||
/// The user's grasp list of kind `10317` is being loaded.
|
||||
pub loading_servers: bool,
|
||||
pub grasp_servers: Vec<RelayUrl>,
|
||||
/// Whether the grasp server section is shown; defaults to shown.
|
||||
/// Whether the grasp server section is shown. Defaults to shown.
|
||||
pub servers_enabled: bool,
|
||||
/// Error of the last grasp-server edit (e.g. an invalid relay URL).
|
||||
/// Error of the last grasp-server edit, an invalid relay URL for example.
|
||||
pub error: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl GraspServersState {
|
||||
/// Defaults until the user's grasp list arrives; replaced by it when it lists any servers.
|
||||
/// Defaults used until the user's grasp list loads, which replaces them when non-empty.
|
||||
///
|
||||
/// The servers come from the persisted settings, falling back to the
|
||||
/// built-in defaults when the configured list is empty.
|
||||
/// Persisted settings supply the defaults, an empty list falls back to the built-ins.
|
||||
pub fn new_default(settings: &GraspServersSettings) -> Self {
|
||||
let urls: Vec<String> = if settings.default_servers.is_empty() {
|
||||
DEFAULT_GRASP_SERVERS
|
||||
@@ -48,10 +45,7 @@ impl GraspServersState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The "Grasp servers" form field shared by the publish dialogs: an
|
||||
/// expandable toggle, the configured servers (each removable) and an
|
||||
/// add-relay input, with a loading hint while the user's grasp list
|
||||
/// (kind `10317`) is being fetched.
|
||||
/// The Grasp servers form field shared by the publish dialogs.
|
||||
pub fn grasp_servers_field(
|
||||
state: &Entity<GraspServersState>,
|
||||
relay_input: &Entity<InputState>,
|
||||
@@ -143,7 +137,7 @@ pub fn grasp_servers_field(
|
||||
}))
|
||||
}
|
||||
|
||||
/// A grasp server row: the host as a tag plus a remove button.
|
||||
/// One grasp server row, the host in a tag plus a remove button.
|
||||
fn render_server_row(
|
||||
ix: usize,
|
||||
relay: &RelayUrl,
|
||||
@@ -182,7 +176,7 @@ fn render_server_row(
|
||||
)
|
||||
}
|
||||
|
||||
/// The bare host of a grasp server (defaults are entered without a scheme).
|
||||
/// The bare host of a grasp server, defaults are entered without a scheme.
|
||||
fn display_server(relay: &RelayUrl) -> SharedString {
|
||||
relay
|
||||
.domain()
|
||||
@@ -190,7 +184,7 @@ fn display_server(relay: &RelayUrl) -> SharedString {
|
||||
.unwrap_or_else(|| SharedString::from(relay.to_string()))
|
||||
}
|
||||
|
||||
/// Parse the relay input (accepting a bare host) and append it to the list.
|
||||
/// Parse the relay input, accepting a bare host, and append it to the list.
|
||||
fn add_relay(
|
||||
state: &Entity<GraspServersState>,
|
||||
input: &Entity<InputState>,
|
||||
@@ -226,8 +220,9 @@ fn add_relay(
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the user's grasp list (kind `10317`) from the local database and
|
||||
/// replace the defaults with it when it lists any servers.
|
||||
/// Load the user's grasp list of kind `10317` from the local database.
|
||||
///
|
||||
/// It replaces the defaults when it lists any servers.
|
||||
pub fn load_user_grasp_servers(
|
||||
state: Entity<GraspServersState>,
|
||||
window: &mut Window,
|
||||
@@ -242,30 +237,7 @@ pub fn load_user_grasp_servers(
|
||||
let handle = window.window_handle();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let result: anyhow::Result<Vec<RelayUrl>> = async {
|
||||
let mut events: Vec<Event> = client
|
||||
.database()
|
||||
.query(filters::grasp_list(user))
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
events.sort_by_key(|event| event.created_at);
|
||||
|
||||
Ok(events
|
||||
.into_iter()
|
||||
.last()
|
||||
.map(|event| {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|tag| tag.kind() == "g")
|
||||
.filter_map(|tag| tag.content())
|
||||
.filter_map(|url| RelayUrl::parse(url).ok())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default())
|
||||
}
|
||||
.await;
|
||||
let result = signed_state::user_grasp_list_servers(client, user).await;
|
||||
|
||||
let _ = cx.update_window(handle, |_, _window, cx| {
|
||||
state.update(cx, |state, _| {
|
||||
|
||||
@@ -2,8 +2,6 @@ use gpui::{App, Window, px};
|
||||
use gpui_component::WindowExt;
|
||||
|
||||
/// Open the Import Identity dialog.
|
||||
///
|
||||
/// Currently a placeholder — the dialog only shows a title for now.
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
dialog.title("Import identity").width(px(400.))
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ops::Range;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use assets::CustomIconName;
|
||||
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, TAB_BAR_HEIGHT, panel_handle};
|
||||
use dock::{
|
||||
BasePanel, DockArea, Panel, PanelEvent, TAB_BAR_HEIGHT, add_center_panel, panel_handle,
|
||||
};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ObjectFit, Render,
|
||||
SharedString, Subscription, WeakEntity, Window, div, img, px, uniform_list,
|
||||
AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ObjectFit, Render,
|
||||
SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list, white,
|
||||
};
|
||||
use gpui_base::Button as BaseButton;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::InputState;
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
||||
use signed_core::{Announcement, identifier_from_name};
|
||||
use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore, RepoListStore};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_core::{Announcement, RepoAddr, identifier_from_name};
|
||||
use signed_state::{
|
||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore,
|
||||
};
|
||||
use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
|
||||
|
||||
use super::{RepoDetailView, RepoListView, open_repo_panel};
|
||||
@@ -30,87 +34,176 @@ mod settings_dialog;
|
||||
|
||||
use self::onboarding_dialog::OnboardingState;
|
||||
|
||||
/// Left-dock panel with navigation entries. Entries open content panels in
|
||||
/// the dock area.
|
||||
pub struct SidebarPanel {
|
||||
focus_handle: FocusHandle,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
explore: Option<WeakEntity<RepoListView>>,
|
||||
logged_in: bool,
|
||||
/// Repositories announced by the current user, listed under
|
||||
/// "All Repositories". Recreated when the signer changes.
|
||||
my_repos: Option<Entity<RepoListStore>>,
|
||||
/// Observes the current user's repo store so the list re-renders.
|
||||
my_repos_subscription: Option<Subscription>,
|
||||
/// Banner artwork shown behind the sign-in screen,
|
||||
/// picked at random from the bundled `backgrounds/` assets.
|
||||
/// Artwork for the sign-in screen.
|
||||
banner: SharedString,
|
||||
/// Observes the local-repository scan so new discoveries re-render.
|
||||
_local_repos_subscription: Subscription,
|
||||
_subscription: Subscription,
|
||||
/// The signed-in user's announced repositories, newest first.
|
||||
announcements: Arc<Vec<Announcement>>,
|
||||
/// Local repositories found by the scan that are not announced yet.
|
||||
local_repos: Arc<Vec<PathBuf>>,
|
||||
/// A local scan is currently running.
|
||||
scanning: bool,
|
||||
/// Unpushed local commits per announced repository, the row badge counts.
|
||||
unpushed: HashMap<RepoAddr, usize>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl SidebarPanel {
|
||||
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
|
||||
let local_repos_store = LocalReposStore::global(cx);
|
||||
let backend = Backend::global(cx);
|
||||
let logged_in = backend.read(cx).current_user().is_some();
|
||||
let repos = RepoListStore::global(cx);
|
||||
let local = LocalReposStore::global(cx);
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, backend, event, cx| {
|
||||
match event {
|
||||
BackendEvent::SignerChanged => {
|
||||
this.logged_in = backend.read(cx).current_user().is_some();
|
||||
this.refresh_my_repos(cx);
|
||||
}
|
||||
BackendEvent::SignerRequired => {
|
||||
this.logged_in = false;
|
||||
this.banner = pick_banner();
|
||||
this.my_repos = None;
|
||||
this.my_repos_subscription = None;
|
||||
}
|
||||
_ => return,
|
||||
let mut subscriptions = Vec::new();
|
||||
|
||||
// Identity changes swap the whole sidebar between the sign-in screen and the signed-in content.
|
||||
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let signer_changed = matches!(event, BackendEvent::SignerChanged);
|
||||
let signer_required = matches!(event, BackendEvent::SignerRequired);
|
||||
|
||||
if !signer_changed && !signer_required {
|
||||
return;
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let local_repos_subscription = cx.observe(&local_repos_store, |_, _, cx| {
|
||||
cx.notify();
|
||||
});
|
||||
if signer_required {
|
||||
this.banner = pick_banner();
|
||||
}
|
||||
|
||||
let mut panel = Self {
|
||||
if this.refresh(cx) || signer_required {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
// The merged list re-derives when announcements or the local scan change.
|
||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
||||
if this.refresh(cx) {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
// The local scan re-derives when announcements or the local scan change.
|
||||
subscriptions.push(cx.observe(&local, |this, _local, cx| {
|
||||
if this.refresh(cx) {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
// Push statuses are recomputed in the background; only the badge counts change.
|
||||
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
|
||||
if this.refresh_unpushed(cx) {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
let mut this = Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
logged_in,
|
||||
explore: None,
|
||||
my_repos: None,
|
||||
my_repos_subscription: None,
|
||||
banner: pick_banner(),
|
||||
_local_repos_subscription: local_repos_subscription,
|
||||
_subscription: subscription,
|
||||
announcements: Arc::new(Vec::new()),
|
||||
local_repos: Arc::new(Vec::new()),
|
||||
scanning: false,
|
||||
unpushed: HashMap::new(),
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
|
||||
if logged_in {
|
||||
panel.refresh_my_repos(cx);
|
||||
}
|
||||
// Seed the snapshot right away.
|
||||
// The stores may already hold data from before the panel opened.
|
||||
// The first render must not depend on a later store update.
|
||||
this.refresh(cx);
|
||||
|
||||
panel
|
||||
this
|
||||
}
|
||||
|
||||
/// (Re)create the store listing the current user's repositories.
|
||||
fn refresh_my_repos(&mut self, cx: &mut Context<Self>) {
|
||||
self.my_repos_subscription = None;
|
||||
|
||||
/// The sidebar renders only its own derived fields, never the stores
|
||||
/// directly. Because the panel is a cached view, a store update alone does
|
||||
/// not re-render it: the observers notify this panel, which re-runs
|
||||
/// `render` over the fresh snapshot.
|
||||
///
|
||||
/// Returns `true` when a rendered field changed.
|
||||
fn refresh(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let backend = Backend::global(cx);
|
||||
let author = backend.read(cx).current_user();
|
||||
self.my_repos = author.map(|author| cx.new(|cx| RepoListStore::new(Some(author), cx)));
|
||||
let user = backend.read(cx).current_user();
|
||||
|
||||
if let Some(store) = self.my_repos.as_ref() {
|
||||
self.my_repos_subscription = Some(cx.observe(store, |_, _, cx| cx.notify()));
|
||||
let repo_list = RepoListStore::global(cx);
|
||||
let announcements = user
|
||||
.as_ref()
|
||||
.map(|user| repo_list.read(cx).announcements_of(user))
|
||||
.unwrap_or_default();
|
||||
|
||||
// A scanned repository is dropped from the local list
|
||||
// once the user announces it, so it is not listed twice.
|
||||
let local = LocalReposStore::global(cx);
|
||||
let scanning = local.read(cx).scanning;
|
||||
|
||||
let local_repos = {
|
||||
let ids: HashSet<String> = announcements.iter().map(|a| a.id.clone()).collect();
|
||||
local
|
||||
.read(cx)
|
||||
.repos
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
let Some(name) = path.file_name() else {
|
||||
return true;
|
||||
};
|
||||
!ids.contains(&identifier_from_name(&name.to_string_lossy()))
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
|
||||
let announcements_changed = *self.announcements != announcements;
|
||||
let local_changed = *self.local_repos != local_repos;
|
||||
let scanning_changed = self.scanning != scanning;
|
||||
|
||||
self.announcements = Arc::new(announcements);
|
||||
self.local_repos = Arc::new(local_repos);
|
||||
self.scanning = scanning;
|
||||
|
||||
if announcements_changed {
|
||||
self.request_push_watches(cx);
|
||||
self.unpushed.clear();
|
||||
}
|
||||
|
||||
announcements_changed || local_changed || scanning_changed
|
||||
}
|
||||
|
||||
/// Open the Explore (repository list) panel in the center of the dock
|
||||
/// area. No-op if it's already open.
|
||||
/// Recompute the badge counts from the global checkouts store's ready-to-push statuses
|
||||
fn refresh_unpushed(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let checkouts = CheckoutsStore::global(cx).read(cx);
|
||||
let mut unpushed = HashMap::with_capacity(self.announcements.len());
|
||||
|
||||
for announcement in self.announcements.iter() {
|
||||
let addr = announcement.addr();
|
||||
let count = checkouts.unpushed(&addr);
|
||||
if count > 0 {
|
||||
unpushed.insert(addr, count);
|
||||
}
|
||||
}
|
||||
|
||||
if unpushed == self.unpushed {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.unpushed = unpushed;
|
||||
true
|
||||
}
|
||||
|
||||
/// Keep the `ready to push` statuses of the announced repositories current.
|
||||
fn request_push_watches(&self, cx: &mut Context<Self>) {
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
checkouts.update(cx, |checkouts, cx| {
|
||||
for announcement in self.announcements.iter() {
|
||||
checkouts.request_push_statuses(&announcement.addr(), cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the Explore repository list panel in the dock area's center.
|
||||
pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self
|
||||
.explore
|
||||
@@ -125,7 +218,7 @@ impl SidebarPanel {
|
||||
self.explore = Some(panel.downgrade());
|
||||
|
||||
let _ = self.dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,32 +255,24 @@ impl SidebarPanel {
|
||||
open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
|
||||
}
|
||||
|
||||
/// Open a local repository's detail view in the dock's center; the
|
||||
/// detail view offers to publish it to NIP-34.
|
||||
/// Open a local repository's detail view in the dock's center.
|
||||
///
|
||||
/// The detail view offers to publish it to NIP-34.
|
||||
fn open_local_repo(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let detail =
|
||||
cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, window, cx));
|
||||
|
||||
let _ = self.dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(
|
||||
panel_handle(detail),
|
||||
DockPlacement::Center,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
self.dock_area
|
||||
.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(detail), window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// The "All Repositories" section: header with the create button and
|
||||
/// the current user's repositories below it, lazily rendered through a
|
||||
/// [`uniform_list`], followed by the local git repositories discovered
|
||||
/// by the startup scan.
|
||||
fn render_my_repos(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let store = self.my_repos.as_ref();
|
||||
let local = LocalReposStore::global(cx);
|
||||
let local_repos = local.read(cx).repos.clone();
|
||||
let scanning = local.read(cx).scanning;
|
||||
fn render_repos(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let announcements = self.announcements.clone();
|
||||
let local_repos = self.local_repos.clone();
|
||||
let scanning = self.scanning;
|
||||
|
||||
v_flex()
|
||||
.px_2()
|
||||
@@ -234,58 +319,36 @@ impl SidebarPanel {
|
||||
),
|
||||
),
|
||||
)
|
||||
.when_some(store, |builder, store| {
|
||||
let announcements = store.read(cx).announcements.clone();
|
||||
// Local repositories that have already been published to
|
||||
// NIP-34 are listed among the user's repositories above;
|
||||
// hide them from the local section (matched by the
|
||||
// identifier derived from the directory name, like the
|
||||
// init dialog's default name).
|
||||
let announced_ids: HashSet<String> =
|
||||
announcements.iter().map(|a| a.id.clone()).collect();
|
||||
let local_repos: Vec<PathBuf> = local_repos
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
let Some(name) = path.file_name() else {
|
||||
return true;
|
||||
};
|
||||
!announced_ids.contains(&identifier_from_name(&name.to_string_lossy()))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
// One merged list: the user's NIP-34 repositories first,
|
||||
// then the local repositories discovered by the scan.
|
||||
.map(|this| {
|
||||
// Merged list, the user's NIP-34 repositories and local repositories discovered.
|
||||
let total = announcements.len() + local_repos.len();
|
||||
|
||||
if total == 0 {
|
||||
builder.child(
|
||||
this.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(if scanning {
|
||||
"Scanning for local repositories…"
|
||||
} else {
|
||||
"No repositories yet"
|
||||
.map(|this| {
|
||||
if scanning {
|
||||
this.child("Scanning for local repositories…")
|
||||
} else {
|
||||
this.child("No repositories yet")
|
||||
}
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
builder.child(
|
||||
this.child(
|
||||
uniform_list(
|
||||
"repos",
|
||||
total,
|
||||
cx.processor(move |this, range: Range<usize>, _window, cx| {
|
||||
cx.processor(move |this, range: Range<usize>, _, cx| {
|
||||
range
|
||||
.map(|ix| {
|
||||
this.render_repo_row_at(
|
||||
&announcements,
|
||||
&local_repos,
|
||||
ix,
|
||||
cx,
|
||||
)
|
||||
.into_any_element()
|
||||
this.render_repo_at(&announcements, &local_repos, ix, cx)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
@@ -297,9 +360,8 @@ impl SidebarPanel {
|
||||
})
|
||||
}
|
||||
|
||||
/// One row of the merged sidebar list: a NIP-34 repository or a local
|
||||
/// repository.
|
||||
fn render_repo_row_at(
|
||||
/// One row of the merged sidebar list, a NIP-34 or a local repository.
|
||||
fn render_repo_at(
|
||||
&self,
|
||||
announcements: &[Announcement],
|
||||
local_repos: &[PathBuf],
|
||||
@@ -323,42 +385,60 @@ impl SidebarPanel {
|
||||
announcement: &Announcement,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let name = announcement
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
|
||||
let name = announcement.name().map(SharedString::from);
|
||||
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
|
||||
let announcement = announcement.clone();
|
||||
|
||||
NavItem::new(format!("my-repo:{}", announcement.id), name, avatar).on_click(
|
||||
// Badge with the unpushed commit count of the repository's local checkouts.
|
||||
let unpushed = self
|
||||
.unpushed
|
||||
.get(&announcement.addr())
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar);
|
||||
|
||||
if unpushed > 0 {
|
||||
row = row.suffix(
|
||||
v_flex()
|
||||
.flex_shrink_0()
|
||||
.size_4()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.line_height(relative(1.))
|
||||
.bg(cx.theme().red_light)
|
||||
.text_color(white())
|
||||
.text_size(px(8.))
|
||||
.child(SharedString::from(unpushed.to_string())),
|
||||
);
|
||||
}
|
||||
|
||||
row.on_click(
|
||||
cx.listener(move |this, _ev, window, cx| this.open_repo(&announcement, window, cx)),
|
||||
)
|
||||
}
|
||||
|
||||
/// One local repository row: a deterministic pixel avatar seeded from
|
||||
/// the path, the directory name, and a warning suffix marking it as
|
||||
/// not yet set up for NIP-34. Clicking it opens the repository's
|
||||
/// detail view, which offers to initialize it.
|
||||
/// One local repository row.
|
||||
///
|
||||
/// The directory name and a warning suffix, the repo is not yet set up for NIP-34.
|
||||
fn render_local_row(&self, path: &Path, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.display().to_string());
|
||||
.unwrap_or("Untitled".into());
|
||||
let path = path.to_path_buf();
|
||||
let avatar = PixelAvatar::new(path.to_string_lossy());
|
||||
|
||||
NavItem::new(
|
||||
format!("local-repo:{}", path.display()),
|
||||
name,
|
||||
PixelAvatar::new(path.to_string_lossy()),
|
||||
)
|
||||
.suffix(
|
||||
Icon::new(IconName::TriangleAlert)
|
||||
.small()
|
||||
.text_color(cx.theme().warning),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.open_local_repo(path.clone(), window, cx);
|
||||
}))
|
||||
NavItem::new(format!("local-repo:{}", path.display()), name, avatar)
|
||||
.suffix(
|
||||
Icon::new(IconName::TriangleAlert)
|
||||
.small()
|
||||
.text_color(cx.theme().warning),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.open_local_repo(path.clone(), window, cx);
|
||||
}))
|
||||
}
|
||||
|
||||
/// Show the Import Identity dialog.
|
||||
@@ -366,7 +446,7 @@ impl SidebarPanel {
|
||||
import_dialog::open(window, cx);
|
||||
}
|
||||
|
||||
/// Render the user avatar and name in the sidebar, wrapped in the window titlebar drag area.
|
||||
/// Render the user avatar and name in the sidebar, inside the titlebar drag area.
|
||||
fn render_user(
|
||||
&self,
|
||||
profile: &Profile,
|
||||
@@ -396,8 +476,7 @@ impl SidebarPanel {
|
||||
)
|
||||
}
|
||||
|
||||
/// Sign-in placeholder shown while logged out: banner artwork behind a
|
||||
/// scrim so the CTA buttons stay readable in both themes.
|
||||
/// Sign-in placeholder shown while logged out.
|
||||
fn render_sign_in(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
|
||||
v_flex()
|
||||
.size_full()
|
||||
@@ -503,10 +582,6 @@ impl Focusable for SidebarPanel {
|
||||
|
||||
impl Render for SidebarPanel {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if !self.logged_in {
|
||||
return self.render_sign_in(window, cx);
|
||||
}
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
|
||||
@@ -515,10 +590,14 @@ impl Render for SidebarPanel {
|
||||
.current_user()
|
||||
.map(|public_key| profile_store.read(cx).get(&public_key));
|
||||
|
||||
if profile.is_none() {
|
||||
return self.render_sign_in(window, cx);
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.justify_between()
|
||||
.image_cache(image_cache("sidebar", MAX_IMAGES))
|
||||
.image_cache(gpui::retain_all("sidebar"))
|
||||
.bg(cx.theme().sidebar)
|
||||
.text_color(cx.theme().sidebar_foreground)
|
||||
.child(
|
||||
@@ -561,7 +640,7 @@ impl Render for SidebarPanel {
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(self.render_my_repos(cx)),
|
||||
.child(self.render_repos(cx)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, SharedString, Window, div, px};
|
||||
use gpui::{App, Entity, Window, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Input, InputState};
|
||||
use gpui_component::{ActiveTheme, Disableable, WindowExt};
|
||||
use gpui_component::{Disableable, WindowExt};
|
||||
use signed_state::Backend;
|
||||
|
||||
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||
|
||||
/// Shared state for the Onboarding dialog, so async results can be rendered.
|
||||
#[derive(Default)]
|
||||
pub struct OnboardingState {
|
||||
pub busy: bool,
|
||||
pub error: Option<SharedString>,
|
||||
}
|
||||
pub type OnboardingState = DialogProgress;
|
||||
|
||||
/// Open the Onboarding dialog for creating a new identity.
|
||||
///
|
||||
/// The caller is responsible for creating the input and state entities and
|
||||
/// passing them in. This function only builds the dialog UI and wires up
|
||||
/// the continue-button handler.
|
||||
pub fn open(
|
||||
name_input: Entity<InputState>,
|
||||
pass_input: Entity<InputState>,
|
||||
@@ -66,9 +60,7 @@ pub fn open(
|
||||
)
|
||||
.child(field().required(true).child(Input::new(&repass_input))),
|
||||
)
|
||||
.children(error.map(|message| {
|
||||
div().text_sm().text_color(cx.theme().danger).child(message)
|
||||
}))
|
||||
.children(error_row(&error, cx))
|
||||
.child(
|
||||
DialogFooter::new().justify_end().child(
|
||||
Button::new("continue")
|
||||
@@ -91,17 +83,12 @@ pub fn open(
|
||||
|
||||
if pass != repass {
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = false;
|
||||
state.error =
|
||||
Some("Passphrases do not match".into());
|
||||
state.fail("Passphrases do not match");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = true;
|
||||
state.error = None;
|
||||
});
|
||||
state.update(cx, |state, _| state.begin());
|
||||
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
backend.create_identity(&name, &pass, cx)
|
||||
@@ -119,8 +106,7 @@ pub fn open(
|
||||
Err(e) => {
|
||||
cx.update_window(handle, |_, _window, cx| {
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = false;
|
||||
state.error = Some(e.to_string().into());
|
||||
state.fail(e.to_string());
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyWindowHandle, App, Entity, SharedString, Subscription, Window, div};
|
||||
use gpui::{AnyWindowHandle, App, Entity, Subscription, Window};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Input, InputEvent, InputState};
|
||||
use gpui_component::{ActiveTheme, Disableable, WindowExt};
|
||||
use gpui_component::{Disableable, WindowExt};
|
||||
use signed_state::Backend;
|
||||
|
||||
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||
|
||||
/// Shared state for the passphrase dialog, so async results can be rendered.
|
||||
#[derive(Default)]
|
||||
pub struct PassphraseState {
|
||||
pub busy: bool,
|
||||
pub error: Option<SharedString>,
|
||||
/// Progress of the unlock flow.
|
||||
pub progress: DialogProgress,
|
||||
/// Keeps the Enter-to-submit subscription alive while the dialog is open.
|
||||
_enter_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
/// Open the dialog asking for the passphrase that protects the stored
|
||||
/// NIP-49 encrypted identity (`ncryptsec1...`).
|
||||
///
|
||||
/// Called when the backend emits [`signed_state::BackendEvent::PassphraseRequired`].
|
||||
/// Open the dialog asking for the passphrase that protects the stored identity.
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let pass_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
@@ -53,8 +52,8 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
||||
.overlay_closable(false)
|
||||
.keyboard(false)
|
||||
.content(move |content, _window, cx| {
|
||||
let busy = state.read(cx).busy;
|
||||
let error = state.read(cx).error.clone();
|
||||
let busy = state.read(cx).progress.busy;
|
||||
let error = state.read(cx).progress.error.clone();
|
||||
|
||||
content
|
||||
.child(
|
||||
@@ -73,9 +72,7 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
||||
.child(Input::new(&pass_input)),
|
||||
),
|
||||
)
|
||||
.children(error.map(|message| {
|
||||
div().text_sm().text_color(cx.theme().danger).child(message)
|
||||
}))
|
||||
.children(error_row(&error, cx))
|
||||
.child(
|
||||
DialogFooter::new().justify_end().child(
|
||||
Button::new("unlock")
|
||||
@@ -98,8 +95,7 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Submit the passphrase to the backend. On success the dialog is closed;
|
||||
/// on failure the error is rendered inline and the dialog stays open.
|
||||
/// Submit the passphrase to the backend.
|
||||
fn unlock(
|
||||
pass_input: &Entity<InputState>,
|
||||
state: &Entity<PassphraseState>,
|
||||
@@ -111,15 +107,12 @@ fn unlock(
|
||||
|
||||
if pass.is_empty() {
|
||||
state.update(cx, |state, _| {
|
||||
state.error = Some("Passphrase must not be empty".into());
|
||||
state.progress.fail("Passphrase must not be empty");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = true;
|
||||
state.error = None;
|
||||
});
|
||||
state.update(cx, |state, _| state.progress.begin());
|
||||
|
||||
let task = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx));
|
||||
let handle = *handle;
|
||||
@@ -134,10 +127,7 @@ fn unlock(
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update_window(handle, |_this, _window, cx| {
|
||||
state.update(cx, |state, _| {
|
||||
state.busy = false;
|
||||
state.error = Some(e.to_string().into());
|
||||
});
|
||||
state.update(cx, |state, _| state.progress.fail(e.to_string()));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
//! The Settings dialog, opened from the sidebar's Settings entry.
|
||||
//!
|
||||
//! A custom settings layout that divides related settings into sections
|
||||
//! separated by simple horizontal lines — no `GroupBox` boxes and no settings
|
||||
//! navigation sidebar. Every control edits the persisted [`SettingsStore`]
|
||||
//! and applies the change to the live theme immediately.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
@@ -55,8 +48,7 @@ fn theme_options(cx: &App) -> (Vec<SelectOption>, Vec<SelectOption>) {
|
||||
(light, dark)
|
||||
}
|
||||
|
||||
/// Stateful controls of the settings dialog, created once when it opens so
|
||||
/// their values survive re-renders of the dialog content.
|
||||
/// Stateful controls of the settings dialog, created once when it opens.
|
||||
struct SettingsControls {
|
||||
appearance: Entity<SelectState<Vec<SelectOption>>>,
|
||||
light_theme: Entity<SelectState<Vec<SelectOption>>>,
|
||||
@@ -66,8 +58,7 @@ struct SettingsControls {
|
||||
radius: Entity<InputState>,
|
||||
radius_lg: Entity<InputState>,
|
||||
grasp_server_input: Entity<InputState>,
|
||||
/// The effective default create-repository folder, shown in the disabled
|
||||
/// folder selector.
|
||||
/// The effective default create-repository folder, shown in the disabled input.
|
||||
default_folder: Entity<InputState>,
|
||||
/// Keeps the control subscriptions alive for the dialog's lifetime.
|
||||
_subscriptions: Vec<Subscription>,
|
||||
@@ -276,28 +267,19 @@ impl SettingsControls {
|
||||
/// Open the Settings dialog.
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let controls = Rc::new(SettingsControls::new(window, cx));
|
||||
let store = SettingsStore::global(cx);
|
||||
let window_handle = window.window_handle();
|
||||
let store_subscription = cx.observe(&store, move |_, cx| {
|
||||
window_handle
|
||||
.update(cx, |_, window, _| window.refresh())
|
||||
.ok();
|
||||
});
|
||||
|
||||
let dialog_state = Rc::new((controls, store_subscription));
|
||||
|
||||
window.open_dialog(cx, move |dialog, _window, cx| {
|
||||
let dialog_state = dialog_state.clone();
|
||||
let controls = controls.clone();
|
||||
dialog
|
||||
.title("Settings")
|
||||
.width(px(650.))
|
||||
.h(px(560.))
|
||||
.child(settings_view(&dialog_state.0, cx))
|
||||
.child(settings_view(&controls, cx))
|
||||
});
|
||||
}
|
||||
|
||||
/// The settings content: one section per related setting, divided by
|
||||
/// horizontal separator lines.
|
||||
/// The settings content, one section per related setting.
|
||||
/// Sections are divided by horizontal separator lines.
|
||||
fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement {
|
||||
let store = SettingsStore::global(cx);
|
||||
let settings = store.read(cx).settings().clone();
|
||||
@@ -325,8 +307,7 @@ fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement
|
||||
))
|
||||
}
|
||||
|
||||
/// Theme configuration: the theme names in the registry plus
|
||||
/// the visual tweaks the application customizes at startup.
|
||||
/// Theme configuration, the registry theme names plus tweaks the app customizes at startup.
|
||||
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_3()
|
||||
@@ -397,7 +378,7 @@ fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) ->
|
||||
))
|
||||
}
|
||||
|
||||
/// The default grasp servers offered when the user hasn't published a grasp list (kind `10317`) yet.
|
||||
/// Default grasp servers offered until the user publishes a kind `10317` grasp list.
|
||||
fn grasp_servers_section(
|
||||
settings: &Settings,
|
||||
controls: &SettingsControls,
|
||||
@@ -413,8 +394,8 @@ fn grasp_servers_section(
|
||||
))
|
||||
}
|
||||
|
||||
/// The editable list of default grasp servers plus an add-relay input,
|
||||
/// styled like the grasp-server section of the publish dialogs.
|
||||
/// The editable list of default grasp servers plus an add-relay input.
|
||||
/// Styled like the grasp-server section of the publish dialogs.
|
||||
fn grasp_server_editor(
|
||||
servers: &[String],
|
||||
controls: &SettingsControls,
|
||||
@@ -478,8 +459,8 @@ fn grasp_server_editor(
|
||||
)
|
||||
}
|
||||
|
||||
/// The bare host of a grasp server (defaults are entered without a scheme),
|
||||
/// matching how the publish dialogs display servers.
|
||||
/// The bare host of a grasp server, defaults are entered without a scheme.
|
||||
/// Matches how the publish dialogs display servers.
|
||||
fn display_server(server: &str) -> SharedString {
|
||||
RelayUrl::parse(server)
|
||||
.ok()
|
||||
@@ -513,8 +494,8 @@ fn repositories_section(
|
||||
))
|
||||
}
|
||||
|
||||
/// The editable list of scan directories plus an add-directory button,
|
||||
/// styled like the grasp-server list.
|
||||
/// The editable list of scan directories plus an add-directory button.
|
||||
/// Styled like the grasp-server list.
|
||||
fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement {
|
||||
v_flex()
|
||||
.w_full()
|
||||
@@ -566,8 +547,8 @@ fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement {
|
||||
)
|
||||
}
|
||||
|
||||
/// The default-folder selector: a disabled input showing the effective
|
||||
/// folder plus a picker button, matching the create-repository dialog.
|
||||
/// The default-folder selector, a disabled input plus a picker button.
|
||||
/// Matches the create-repository dialog.
|
||||
fn folder_selector(controls: &SettingsControls) -> impl IntoElement {
|
||||
let default_folder = controls.default_folder.clone();
|
||||
h_flex()
|
||||
@@ -590,8 +571,8 @@ fn folder_selector(controls: &SettingsControls) -> impl IntoElement {
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse the server input (accepting a bare host) and append it to the
|
||||
/// default grasp servers.
|
||||
/// Parse the server input and append it to the default grasp servers.
|
||||
/// A bare host is accepted.
|
||||
fn add_server(input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||
let value = input.read(cx).value().trim().to_owned();
|
||||
if value.is_empty() {
|
||||
@@ -660,8 +641,8 @@ fn add_scan_path(cx: &mut App) {
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Prompt for the folder the Create Repository dialog should default to,
|
||||
/// remembering it in the settings and showing it in the disabled input.
|
||||
/// Prompt for the Create Repository dialog's default folder.
|
||||
/// Remember it in the settings and show it in the disabled input.
|
||||
fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||
let handle = window.window_handle();
|
||||
let default_folder = default_folder.clone();
|
||||
@@ -695,8 +676,9 @@ fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Windo
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Wire a number input to the settings: steps clamp and persist, typed
|
||||
/// changes parse, clamp and persist.
|
||||
/// Wire a number input to the settings.
|
||||
/// Step actions clamp and persist the value.
|
||||
/// Typed changes parse, clamp and persist.
|
||||
fn wire_number_input(
|
||||
state: &Entity<InputState>,
|
||||
subscriptions: &mut Vec<Subscription>,
|
||||
|
||||
@@ -45,10 +45,7 @@ impl Workspace {
|
||||
|
||||
let mut subscriptions = vec![];
|
||||
|
||||
// A bottom/right dock whose last panel was dragged away is removed
|
||||
// entirely: base keeps the emptied region, which would otherwise
|
||||
// linger as a bare strip. Deferred, because the event arrives while
|
||||
// the area is mid-update.
|
||||
// A bottom or right dock whose last panel was dragged away is removed entirely.
|
||||
let dock_for_pruning = dock.clone();
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&dock,
|
||||
@@ -78,9 +75,7 @@ impl Workspace {
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
// Ask for the passphrase when the stored identity is NIP-49
|
||||
// encrypted. Subscribed via the window, since opening a dialog
|
||||
// needs one.
|
||||
// Ask for the passphrase when the stored identity is NIP-49 encrypted.
|
||||
let passphrase_subscription =
|
||||
window.subscribe(&backend, cx, |_backend, event, window, cx| {
|
||||
if matches!(event, BackendEvent::PassphraseRequired) {
|
||||
@@ -88,9 +83,8 @@ impl Workspace {
|
||||
}
|
||||
});
|
||||
|
||||
// The event may have fired before this window existed (the backend
|
||||
// is initialized before the first window opens); fall back to the
|
||||
// backend state in that case.
|
||||
// The event may have fired before this window existed.
|
||||
// Fall back to the backend state in that case.
|
||||
if backend.read(cx).passphrase_required() {
|
||||
passphrase_dialog::open(window, cx);
|
||||
}
|
||||
|
||||
@@ -17,12 +17,8 @@ workspace = { path = "../crates/workspace" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_platform.workspace = true
|
||||
gpui_linux.workspace = true
|
||||
gpui_windows.workspace = true
|
||||
gpui_macos.workspace = true
|
||||
dock = { workspace = true }
|
||||
gpui-component.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
log.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
+10
-8
@@ -17,14 +17,14 @@ fn main() {
|
||||
gpui_component::init(cx);
|
||||
theme::init(cx);
|
||||
|
||||
// Load the persisted settings before applying the theme,
|
||||
// so the stored appearance and theme configuration take effect at startup.
|
||||
// Load the persisted settings before applying the theme.
|
||||
// Stored appearance and theme settings then take effect at startup.
|
||||
let store = cx.new(|cx| SettingsStore::new(paths::settings_file(), cx));
|
||||
SettingsStore::set_global(store.clone(), cx);
|
||||
let settings = store.read(cx).settings().clone();
|
||||
|
||||
// Register the built-in "Signed" theme (light + dark variants)
|
||||
// and make it the active theme, following the stored appearance.
|
||||
// Register the built-in Signed theme, light and dark variants.
|
||||
// The stored appearance then selects the active theme.
|
||||
let registry = ThemeRegistry::global_mut(cx);
|
||||
for (name, content) in Assets.themes() {
|
||||
if let Err(err) = registry.load_themes_from_str(&content) {
|
||||
@@ -74,11 +74,13 @@ fn main() {
|
||||
|
||||
// Connects relays and restores the session.
|
||||
std::fs::create_dir_all(paths::nostr_dir()).ok();
|
||||
signed_state::init(paths::nostr_dir(), settings.local_repos.scan_paths, cx);
|
||||
|
||||
// Local git clone cache for browsing repository contents.
|
||||
std::fs::create_dir_all(paths::repos_dir()).ok();
|
||||
signed_state::GitStore::set_global(paths::repos_dir().clone(), cx);
|
||||
signed_state::init(
|
||||
paths::nostr_dir(),
|
||||
paths::repos_dir().clone(),
|
||||
settings.local_repos.scan_paths,
|
||||
cx,
|
||||
);
|
||||
|
||||
cx.set_app_identity("su.reya.signed", "Signed");
|
||||
|
||||
|
||||
+72
-38
@@ -11,23 +11,22 @@ state.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["New pull request dialog"] --> B{"Patch source"}
|
||||
B -->|"Paste"| C["Paste git format-patch output"]
|
||||
B -->|"Local checkout"| D["Browse for checkout"]
|
||||
D --> E["Defaults: source = current branch, target = announced HEAD"]
|
||||
E --> F["Generate: merge-base plus format-patch base..tip"]
|
||||
F --> G["Apply check vs mirror clone - non-blocking warning"]
|
||||
C --> H["Submit"]
|
||||
F --> H
|
||||
G --> H
|
||||
A["New pull request panel"] --> B{"Compare source"}
|
||||
B -->|"Local checkout"| C["Pick folder (or auto-prefilled from remembered checkouts)"]
|
||||
B -->|"Announced fork"| D["Pick fork repo + branch"]
|
||||
D --> D1["Ensure base mirror (GitCache), fetch origin"]
|
||||
D1 --> D2["Import fork heads as refs/fork/<owner>/<id>/*"]
|
||||
C --> E["Defaults: target = announced HEAD, source = current branch / fork main"]
|
||||
E --> F["merge-base + commits + diff of target..source (Files/Commits tabs)"]
|
||||
F --> H["Submit: format-patch base..tip at publish time"]
|
||||
H --> I["split_patch_series: one part per commit"]
|
||||
I --> J{"Any part over 60 KB?"}
|
||||
J -->|"Yes"| K["Refuse with message"]
|
||||
J -->|"No"| L["tip = last part's From commit"]
|
||||
L --> M["Publish kind-1617 patch series: first has t root, later parts e-reply chained"]
|
||||
M --> N["Build kind-1618 PR event: c = tip, e = root patch, branch-name, merge-base"]
|
||||
M --> N["Build kind-1618 PR event: c = tip, e = root patch, branch-name, merge-base, clone"]
|
||||
N --> O["Sign early - learn the event id"]
|
||||
O --> P["Push tip to refs/nostr/event-id on every announced grasp server"]
|
||||
O --> P["Push tip to refs/nostr/event-id: author /prs/ grasp servers first, then the announced servers"]
|
||||
P -->|"All rejected"| Q["last_warning banner in PR list"]
|
||||
P --> R["Publish kind-1618 PR event"]
|
||||
Q --> R
|
||||
@@ -50,47 +49,81 @@ graph TD
|
||||
|
||||
Key points of the write side:
|
||||
|
||||
- **Merge base**: only computable in the local-checkout path
|
||||
(`signed_git::merge_base`); the paste path publishes none. The dialog
|
||||
reuses it at submit only while the patch textarea is unchanged.
|
||||
- **Compare sources** (NIP-34 / GRASP-06 native, no fork identity on the
|
||||
wire):
|
||||
- *Local checkout*: both branch selectors list a picked folder's
|
||||
branches; all git ops run in that folder. Checkouts of the target repo
|
||||
are remembered (folder pick + app clones) and matched implicitly
|
||||
(origin URL or EUC against the announcement), so the panel prefills the
|
||||
freshest one - no folder dialog for the common case.
|
||||
- *Announced fork*: the fork's heads are fetched into the target repo's
|
||||
GitCache mirror under `refs/fork/<owner-hex>/<id>/*` (private
|
||||
namespace; the browser never sees them). "Merge Into" lists the
|
||||
mirror's `refs/remotes/origin/*`, "Pull From" the imported fork
|
||||
branches, and every git op - merge-base, range diff/commits,
|
||||
format-patch, tip push - runs in the mirror, which holds both
|
||||
histories. Fork candidates are announcements related to the target by
|
||||
`u` tag or shared EUC, own forks first, without `clone` URLs excluded.
|
||||
- **GRASP-06 hosting**: the tip is pushed under `refs/nostr/<event-id>`
|
||||
(nak's convention) to the *author's* grasp servers first -
|
||||
`https://<host>/prs/<author-npub>/<repo-id>.git`, resolved from the
|
||||
author's kind-10317 grasp list, falling back to the settings defaults -
|
||||
then to the base repository's announced grasp servers. The `clone` tag
|
||||
lists those `/prs/` URLs first, then the announced clone URLs (fixed
|
||||
before signing; dead URLs are inert, the patches stay the source of
|
||||
truth). Contributing therefore never depends on the other project's
|
||||
servers accepting a push.
|
||||
- **Patch series**: each commit becomes its own kind-1617 event so no event
|
||||
grows past NIP-34's 60 KB guidance; the PR's `c` tag carries the *last*
|
||||
commit of the series (the tip), and each part carries its own
|
||||
`commit`/`r` tags.
|
||||
- **Push before publish**: the tip is pushed to every announced grasp
|
||||
server under `refs/nostr/<event-id>` (nak's convention) so the announced
|
||||
`clone` URLs really can serve the commit. Failure is non-fatal — the
|
||||
patch events remain the source of truth — and surfaces as a
|
||||
`last_warning` banner.
|
||||
- **Push before publish**: failure is non-fatal - the patch events remain
|
||||
the source of truth - and surfaces as a `last_warning` banner.
|
||||
- **1619 updates are paste-only today** (no repo path holds the new tip's
|
||||
objects), so updates are not pushed; hosting them is deferred until the
|
||||
update dialog gains a local-checkout source.
|
||||
|
||||
## Creating a pull request - event ordering
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant App
|
||||
participant Checkout as Local checkout
|
||||
participant Grasp as Grasp servers
|
||||
participant Relays as Nostr relays
|
||||
participant P as Base mirror (GitCache)
|
||||
participant F as Fork grasp server
|
||||
participant A as Author grasp (GRASP-06 /prs/)
|
||||
participant B as Base repo grasps
|
||||
participant R as Nostr relays
|
||||
|
||||
User->>App: pick checkout and branches, Generate
|
||||
App->>Checkout: merge-base(source, target)
|
||||
Checkout-->>App: base commit
|
||||
App->>Checkout: format-patch base..tip
|
||||
Checkout-->>App: patch series
|
||||
App->>App: split series, check per-part size
|
||||
User->>P: ensure mirror (fork mode) / pick local checkout
|
||||
P-->>F: fetch fork heads -> refs/fork/... (fork mode)
|
||||
User->>P: merge-base, range commits, range diff
|
||||
User->>P: submit: format-patch base..compare-ref
|
||||
loop each patch of the series
|
||||
App->>Relays: publish kind-1617 (first: t root, later: e reply)
|
||||
User->>R: publish kind-1617 (first: t root, later: e reply)
|
||||
end
|
||||
App->>App: build and sign kind-1618 PR event
|
||||
App->>Grasp: push tip to refs/nostr/event-id
|
||||
Grasp-->>App: accepted or rejected (best-effort)
|
||||
App->>Relays: publish kind-1618 PR event
|
||||
User->>User: build and sign kind-1618 (clone = /prs/ URLs + announced)
|
||||
User->>A: push tip to refs/nostr/event-id (author servers, first)
|
||||
User->>B: push tip to refs/nostr/event-id (best-effort)
|
||||
A-->>User: accepted or rejected (all rejected -> warning)
|
||||
User->>R: publish kind-1618 PR event
|
||||
opt draft
|
||||
App->>Relays: publish kind-1633 draft status
|
||||
User->>R: publish kind-1633 draft status
|
||||
end
|
||||
```
|
||||
|
||||
## Ready to contribute (suggestions)
|
||||
|
||||
Local checkouts are matched to announced repositories (remembered records
|
||||
freshest-first ∪ scanned matches by origin URL or EUC). While a repository's
|
||||
detail panel is open, each associated checkout is checked off the main
|
||||
thread: current branch vs its base (announced HEAD, else `main`, else the
|
||||
first branch), commits ahead, dirty worktrees excluded. A banner in the
|
||||
repository panel then offers a prefilled New PR panel for the first branch
|
||||
that is ahead with **no open PR by you** proposing it (`branch-name` tag,
|
||||
falling back to the `c` tip tag) - NIP-34-native dedupe, refreshed
|
||||
periodically and whenever the checkouts/announcements change. The panel
|
||||
never submits anything on its own; suggestions only navigate and prefill.
|
||||
|
||||
## Updating and merging
|
||||
|
||||
```mermaid
|
||||
@@ -100,7 +133,7 @@ sequenceDiagram
|
||||
participant Maintainer
|
||||
participant Clone as Mirror clone
|
||||
|
||||
Note over Author,Relays: Update - PR author only
|
||||
Note over Author,Relays: Update - PR author only (paste flow, no push yet)
|
||||
Author->>Relays: publish revision patch series (t root-revision, e reply to original root)
|
||||
Author->>Relays: publish kind-1619 update (E/P tags, c = new tip)
|
||||
|
||||
@@ -132,8 +165,9 @@ Reader rules that keep the flow consistent:
|
||||
|
||||
- **Status**: only status events by the root author or a repository
|
||||
maintainer count; the newest wins, `Open` is the default.
|
||||
- **Tip**: only kind-1619 updates by the PR author move the tip — a
|
||||
- **Tip**: only kind-1619 updates by the PR author move the tip - a
|
||||
stranger's update is ignored.
|
||||
- **Diff**: the patch set is preferred (NIP-34 `e`-linked chain); PRs from
|
||||
other clients without patch events fall back to diffing
|
||||
`merge-base..tip` in the local clone.
|
||||
`merge-base..tip` in the local clone. Fetching tips from `clone` URLs
|
||||
(ngit `pr checkout` analog) is not implemented yet.
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# TODO
|
||||
|
||||
## Fork support
|
||||
|
||||
- [x] Fork badge on repo list cards (`repo_list.rs::render_card`).
|
||||
- [x] "Forked from …" text button in the repo detail header (`repo_detail/mod.rs::render_header`) and About dialog.
|
||||
- [x] Clicking the upstream opens it as a center panel (shared `open_repo_panel` helper).
|
||||
|
||||
## Pull request improvement
|
||||
|
||||
### New pull request panel (replaces the dialog)
|
||||
|
||||
- [x] "New pull request" (PR list header + repo header `New PR`) opens a center panel instead of the paste dialog:
|
||||
- [x] Base/compare branch selectors fed from a user-chosen local checkout (GitHub-style; defaults: announced HEAD for base, checkout's current branch for compare).
|
||||
- [x] Files/Commits tabs like the repo panel: diff of `merge-base..compare` (shared `DiffPane` widget, also extracted for the commit diff panel) + virtual commit list with count badge; clicking a commit opens its diff panel.
|
||||
- [x] Only two inputs: title (required, gates the Create button) and description (optional).
|
||||
- [x] Patch is generated from the checkout at submit time (`format_patch_between` on the stored merge base); panel closes after publishing, errors surface in the PR list banner.
|
||||
- [x] Removed with the dialog: paste textarea, draft checkbox, branch-name input and the mirror-clone apply-check hint (store behavior unchanged: `open_pull_request` still publishes the series + `branch-name`/`merge-base`/`r` tags and pushes the tip).
|
||||
|
||||
### Send patch panel (classic paste flow)
|
||||
|
||||
- [x] "Send patch" entry in the repo header PRs dropdown (`RepoAction::SendPatch`) and a "New pull request ▾ Send patch" dropdown replacing the PR list's plain new-PR button.
|
||||
- [x] `send_patch.rs` center panel: title + optional description + `git format-patch` paste area; submits through `RepoStore::open_pull_request` (no checkout, no `branch-name`/`merge-base`). Synchronous store errors (malformed/oversized patch, sign-in) keep the panel open with an inline error; the panel closes once the publish is underway.
|
||||
|
||||
- [x] P1: `branch-name` tag + `r` EUC tag on PR creation; draft checkbox in the new-PR dialog (dialog since replaced by the panel above).
|
||||
- [x] P1: `RepoStore::update_pull_request` (kind 1619 + root-revision patch) with an author-only "Update" button on the PR detail header.
|
||||
- [x] P1: `latest_update` filters by PR author.
|
||||
- [x] P2: local checkout picker in the new-PR dialog (folder picker + source/target branches + Generate): `signed_git::{merge_base, format_patch_between, patch_applies}`; `merge-base` tag now published; best-effort apply check shown under the patch field (superseded by the panel's live compare view).
|
||||
- [x] P3: push tip to grasp servers under `refs/nostr/<event-id>` before publishing (from the local checkout); multi-commit series published as NIP-10-chained 1617 events with a 60 KB per-patch cap; PR list shows dismissible error/warning banners (incl. push failures).
|
||||
- [x] P4: merge status tags — `merge_pull_request` publishes 1631 with `applied-as-commits` + `r` per applied commit and `q`/`e`-reply tags per applied patch event.
|
||||
|
||||
### Pull request follow-ups
|
||||
|
||||
- [ ] GRASP-06 `/prs/<npub>/<id>.git` contributor endpoints + kind-10317 user grasp-list fallback.
|
||||
- [ ] Merge button in the PR detail view (`merge_pull_request` is store-only today), then fetch-and-merge (`merge-commit`) when the push backend is guaranteed.
|
||||
- [ ] Local-checkout generation for the update-PR dialog (currently paste-only).
|
||||
- [ ] Fork-aware compare in the New PR panel: today both branch selectors come from the user-picked local checkout, so a cross-fork PR (GitHub's "compare across forks") requires the fork's branch to exist locally. Add picking the fork repository from announced repos (its 30617 may point at this repo via the `u` tag, or share the EUC) + a branch, fetch it into the `GitCache` mirror, and run the `merge-base`/diff/`format-patch` flow against the base repo's mirror — like `choose_checkout` today but repo-driven.
|
||||
|
||||
## Performance: render path
|
||||
|
||||
- [ ] Virtualize issue/PR comment threads (`issue_detail.rs::render_comments`, `pull_request_detail.rs::render_comments`). Harder than the list tabs: comment cards have variable heights and live inside a scrolling page together with the body and the comment form, so this needs either measured item sizes or restructuring the whole discussion tab into one virtual list. (Comment bodies are already cached as `SharedString`, so re-renders are cheap element constructions, not byte copies.)
|
||||
|
||||
## Performance: relay/subscription behavior
|
||||
|
||||
- [ ] Narrow `RepoStore`'s `BackendEvent::NostrUpdate` relevance filter (`crates/signed_state/src/repo.rs:65-98`): any comment/status/label/deletion from anywhere wakes every open repo store; match only events referencing this repo's roots or coordinate.
|
||||
- [ ] Reconsider `ban_relay_on_mismatch(true)` (`crates/signed_nostr/src/backend.rs:49`): combined with many short-lived auto-close subscriptions, a late event after EOSE can permanently ban a relay for the session.
|
||||
- [ ] Relays added for a repo stay in the pool forever and grow unboundedly (`crates/signed_state/src/backend.rs`); consider removing repo relays when the last panel for that repo closes.
|
||||
Reference in New Issue
Block a user