feat: push checkout #14
Generated
+69
-96
@@ -14,38 +14,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "accesskit_atspi_common"
|
name = "accesskit_atspi_common"
|
||||||
version = "0.18.1"
|
version = "0.19.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
|
checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_consumer 0.36.0",
|
"accesskit_consumer",
|
||||||
"atspi-common",
|
"atspi-common",
|
||||||
"phf 0.13.1",
|
"phf 0.13.1",
|
||||||
"serde",
|
"serde",
|
||||||
"zvariant",
|
"zvariant",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "accesskit_consumer"
|
|
||||||
version = "0.36.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
|
|
||||||
dependencies = [
|
|
||||||
"accesskit",
|
|
||||||
"hashbrown 0.16.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "accesskit_consumer"
|
|
||||||
version = "0.37.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f950720ce064757a1b629caad3a408e8d2c63bb01f29b8a3ff8daa331053ffeb"
|
|
||||||
dependencies = [
|
|
||||||
"accesskit",
|
|
||||||
"hashbrown 0.16.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "accesskit_consumer"
|
name = "accesskit_consumer"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -63,7 +43,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
|
checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_consumer 0.38.0",
|
"accesskit_consumer",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-app-kit 0.2.2",
|
"objc2-app-kit 0.2.2",
|
||||||
@@ -72,9 +52,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "accesskit_unix"
|
name = "accesskit_unix"
|
||||||
version = "0.21.1"
|
version = "0.22.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
|
checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_atspi_common",
|
"accesskit_atspi_common",
|
||||||
@@ -90,12 +70,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "accesskit_windows"
|
name = "accesskit_windows"
|
||||||
version = "0.33.1"
|
version = "0.34.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "36e93ac7bf50b964f1cbb75f741629a4e950571baa1ef1274457ab5a80d9bcc2"
|
checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_consumer 0.37.0",
|
"accesskit_consumer",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
"windows 0.62.2",
|
"windows 0.62.2",
|
||||||
@@ -311,7 +291,6 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"gpui",
|
"gpui",
|
||||||
"gpui-component",
|
"gpui-component",
|
||||||
"log",
|
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
@@ -342,9 +321,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-compression"
|
name = "async-compression"
|
||||||
version = "0.4.43"
|
version = "0.4.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8"
|
checksum = "515a1f282e33d55983c499d7e9e87082e81cbc32974825bf9032f928392d5844"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"compression-codecs",
|
"compression-codecs",
|
||||||
"compression-core",
|
"compression-core",
|
||||||
@@ -678,9 +657,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bindgen"
|
name = "bindgen"
|
||||||
version = "0.71.1"
|
version = "0.72.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
|
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"cexpr",
|
"cexpr",
|
||||||
@@ -1238,7 +1217,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "collections"
|
name = "collections"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gpui_util",
|
"gpui_util",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
@@ -1263,9 +1242,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compression-codecs"
|
name = "compression-codecs"
|
||||||
version = "0.4.38"
|
version = "0.4.39"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
|
checksum = "2fe67f2944eef52fc7b106b8c9450d243a88701a0c065f7f57235e76abaed7df"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bzip2",
|
"bzip2",
|
||||||
"compression-core",
|
"compression-core",
|
||||||
@@ -1275,9 +1254,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compression-core"
|
name = "compression-core"
|
||||||
version = "0.4.32"
|
version = "0.4.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
@@ -1704,7 +1683,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_refineable"
|
name = "derive_refineable"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -1866,7 +1845,7 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
"memchr",
|
"memchr",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
"toml 1.1.4+spec-1.1.0",
|
"toml 1.1.5+spec-1.1.0",
|
||||||
"vswhom",
|
"vswhom",
|
||||||
"winreg",
|
"winreg",
|
||||||
]
|
]
|
||||||
@@ -3484,7 +3463,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui"
|
name = "gpui"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -3547,7 +3526,7 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
"waker-fn",
|
"waker-fn",
|
||||||
"web-time",
|
"web-time",
|
||||||
"windows 0.61.3",
|
"windows 0.62.2",
|
||||||
"zed-font-kit",
|
"zed-font-kit",
|
||||||
"zed-scap",
|
"zed-scap",
|
||||||
"ztracing",
|
"ztracing",
|
||||||
@@ -3556,7 +3535,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui-base"
|
name = "gpui-base"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5"
|
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -3592,7 +3571,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui-component"
|
name = "gpui-component"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5"
|
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -3669,7 +3648,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui-component-assets"
|
name = "gpui-component-assets"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5"
|
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"gpui",
|
"gpui",
|
||||||
@@ -3683,7 +3662,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui-component-macros"
|
name = "gpui-component-macros"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5"
|
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -3693,21 +3672,21 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui-fps"
|
name = "gpui-fps"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/longbridge/gpui-component#5cb094628d27acbd557a1c22fd830417a702f0e5"
|
source = "git+https://github.com/longbridge/gpui-component#18922d661e136bffbe154dbd34317d03692bde0b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gpui",
|
"gpui",
|
||||||
"instant",
|
|
||||||
"libc",
|
"libc",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-io-kit",
|
"objc2-io-kit",
|
||||||
"sysinfo 0.37.2",
|
"sysinfo 0.37.2",
|
||||||
|
"web-time",
|
||||||
"windows 0.58.0",
|
"windows 0.58.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_apple"
|
name = "gpui_apple"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"block",
|
"block",
|
||||||
@@ -3730,7 +3709,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_linux"
|
name = "gpui_linux"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_unix",
|
"accesskit_unix",
|
||||||
@@ -3776,7 +3755,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_macos"
|
name = "gpui_macos"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_macos",
|
"accesskit_macos",
|
||||||
@@ -3822,7 +3801,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_macros"
|
name = "gpui_macros"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -3833,7 +3812,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_platform"
|
name = "gpui_platform"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
"gpui",
|
"gpui",
|
||||||
@@ -3846,7 +3825,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_shared_string"
|
name = "gpui_shared_string"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -3856,7 +3835,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_util"
|
name = "gpui_util"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"log",
|
"log",
|
||||||
@@ -3866,7 +3845,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_web"
|
name = "gpui_web"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
@@ -3890,7 +3869,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_wgpu"
|
name = "gpui_wgpu"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
@@ -3916,7 +3895,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_windows"
|
name = "gpui_windows"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_windows",
|
"accesskit_windows",
|
||||||
@@ -3935,10 +3914,10 @@ dependencies = [
|
|||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"uuid",
|
"uuid",
|
||||||
"windows 0.61.3",
|
"windows 0.62.2",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.62.2",
|
||||||
"windows-numerics 0.2.0",
|
"windows-numerics 0.3.1",
|
||||||
"windows-registry 0.5.3",
|
"windows-registry 0.6.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4230,7 +4209,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "http_client"
|
name = "http_client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-compression",
|
"async-compression",
|
||||||
@@ -4250,7 +4229,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "http_client_tls"
|
name = "http_client_tls"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -5034,9 +5013,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru"
|
name = "lru"
|
||||||
version = "0.18.3"
|
version = "0.18.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c"
|
checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru-slab"
|
name = "lru-slab"
|
||||||
@@ -5206,7 +5185,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "media"
|
name = "media"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
@@ -5300,9 +5279,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.2"
|
version = "1.2.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -6119,9 +6098,9 @@ checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "open"
|
name = "open"
|
||||||
version = "5.4.2"
|
version = "5.4.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ade3be4664bc1ef537ce133015f04c176b737815c2ba9fd60edf212d6e90dd55"
|
checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"is-wsl",
|
"is-wsl",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -6264,7 +6243,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "perf"
|
name = "perf"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"collections",
|
"collections",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -7052,7 +7031,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "refineable"
|
name = "refineable"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"derive_refineable",
|
"derive_refineable",
|
||||||
]
|
]
|
||||||
@@ -7135,7 +7114,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest_client"
|
name = "reqwest_client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -7513,7 +7492,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "scheduler"
|
name = "scheduler"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-task",
|
"async-task",
|
||||||
"backtrace",
|
"backtrace",
|
||||||
@@ -7907,11 +7886,7 @@ dependencies = [
|
|||||||
"dock",
|
"dock",
|
||||||
"gpui",
|
"gpui",
|
||||||
"gpui-component",
|
"gpui-component",
|
||||||
"gpui_linux",
|
|
||||||
"gpui_macos",
|
|
||||||
"gpui_platform",
|
"gpui_platform",
|
||||||
"gpui_windows",
|
|
||||||
"log",
|
|
||||||
"paths",
|
"paths",
|
||||||
"reqwest_client",
|
"reqwest_client",
|
||||||
"settings",
|
"settings",
|
||||||
@@ -7925,7 +7900,6 @@ dependencies = [
|
|||||||
name = "signed_core"
|
name = "signed_core"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gpui",
|
|
||||||
"nostr",
|
"nostr",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -7945,13 +7919,11 @@ name = "signed_nostr"
|
|||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"nostr",
|
|
||||||
"nostr-connect",
|
"nostr-connect",
|
||||||
"nostr-gossip-memory",
|
"nostr-gossip-memory",
|
||||||
"nostr-lmdb",
|
"nostr-lmdb",
|
||||||
"nostr-memory",
|
"nostr-memory",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"signed_core",
|
|
||||||
"webbrowser",
|
"webbrowser",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -7969,9 +7941,11 @@ dependencies = [
|
|||||||
"nostr-connect",
|
"nostr-connect",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
"settings",
|
||||||
"signed_core",
|
"signed_core",
|
||||||
"signed_git",
|
"signed_git",
|
||||||
"signed_nostr",
|
"signed_nostr",
|
||||||
|
"tempfile",
|
||||||
"utils",
|
"utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -8227,7 +8201,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "sum_tree"
|
name = "sum_tree"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heapless 0.9.3",
|
"heapless 0.9.3",
|
||||||
"log",
|
"log",
|
||||||
@@ -8813,9 +8787,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "1.1.4+spec-1.1.0"
|
version = "1.1.5+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
|
checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -9625,7 +9599,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "util_macros"
|
name = "util_macros"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"perf",
|
"perf",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -10505,13 +10479,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-registry"
|
name = "windows-registry"
|
||||||
version = "0.5.3"
|
version = "0.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.2.1",
|
||||||
"windows-result 0.3.4",
|
"windows-result 0.4.1",
|
||||||
"windows-strings 0.4.2",
|
"windows-strings 0.5.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10819,7 +10793,6 @@ version = "1.0.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"assets",
|
"assets",
|
||||||
"chrono",
|
|
||||||
"dock",
|
"dock",
|
||||||
"futures",
|
"futures",
|
||||||
"gix",
|
"gix",
|
||||||
@@ -11394,7 +11367,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "zlog"
|
name = "zlog"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -11411,7 +11384,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "ztracing"
|
name = "ztracing"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -11422,7 +11395,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "ztracing_macro"
|
name = "ztracing_macro"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#2551721adb5b5187bc27cfae0fbe47f0ed4c5397"
|
source = "git+https://github.com/zed-industries/zed#b1a7ef0cf66dfbf9d7661170c96d97c7df916c68"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zune-core"
|
name = "zune-core"
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ publish = false
|
|||||||
# GPUI
|
# GPUI
|
||||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
|
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" }
|
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
||||||
reqwest_client = { 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"] }
|
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"
|
smol = "2"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ publish.workspace = true
|
|||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui-component.workspace = true
|
gpui-component.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
log.workspace = true
|
|
||||||
rust-embed.workspace = true
|
rust-embed.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -151,14 +151,14 @@ mod tests {
|
|||||||
assert_eq!(theme.border, parse("#27272A")); // neutral-800
|
assert_eq!(theme.border, parse("#27272A")); // neutral-800
|
||||||
assert_eq!(theme.green, parse("#22C55E")); // green-500
|
assert_eq!(theme.green, parse("#22C55E")); // green-500
|
||||||
} else {
|
} else {
|
||||||
// Light theme chrome is neutral; lime is a brand accent only.
|
// Light theme chrome is neutral, lime is a brand accent only.
|
||||||
assert_eq!(theme.background, parse("#FFFFFF"));
|
assert_eq!(theme.background, parse("#FFFFFF"));
|
||||||
assert_eq!(theme.foreground, parse("#18181B"));
|
assert_eq!(theme.foreground, parse("#18181B"));
|
||||||
assert_eq!(theme.border, parse("#E4E4E7"));
|
assert_eq!(theme.border, parse("#E4E4E7"));
|
||||||
assert_eq!(theme.green, parse("#16A34A"));
|
assert_eq!(theme.green, parse("#16A34A"));
|
||||||
}
|
}
|
||||||
// Active tab: a paler lime on light, a dim moss on dark — each
|
// Active tab, a paler lime on light and a dim moss on dark.
|
||||||
// paired with readable, contrasting text.
|
// Each is paired with readable contrasting text.
|
||||||
if config.mode.is_dark() {
|
if config.mode.is_dark() {
|
||||||
assert_eq!(theme.tab_active, parse("#19200A")); // dim lime
|
assert_eq!(theme.tab_active, parse("#19200A")); // dim lime
|
||||||
assert_eq!(theme.tab_active_foreground, parse("#C6FF4D")); // nostr-lime
|
assert_eq!(theme.tab_active_foreground, parse("#C6FF4D")); // nostr-lime
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
//! The dock-area appearance: the outer frame, the split frames, and one
|
|
||||||
//! dock's chrome. Ported from the vendored dock's `DockArea`/`Dock` render
|
|
||||||
//! onto `gpui_base::dock::DockAreaRenderer`.
|
|
||||||
|
|
||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
use std::ops::Deref as _;
|
use std::ops::Deref as _;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
@@ -26,8 +22,7 @@ use crate::tab_panel::SignedTabGroupSkin;
|
|||||||
use crate::tiles::SignedTilesSkin;
|
use crate::tiles::SignedTilesSkin;
|
||||||
use crate::{TAB_BAR_HEIGHT, panel_handle};
|
use crate::{TAB_BAR_HEIGHT, panel_handle};
|
||||||
|
|
||||||
/// What every part of the skin reads, and the dock area it belongs to.
|
/// State the skin shares with its per-container renderers.
|
||||||
/// Shared by reference with the per-container renderers.
|
|
||||||
pub(crate) struct SkinShared {
|
pub(crate) struct SkinShared {
|
||||||
area: WeakEntity<DockArea>,
|
area: WeakEntity<DockArea>,
|
||||||
toggle_button_visible: Cell<bool>,
|
toggle_button_visible: Cell<bool>,
|
||||||
@@ -53,16 +48,14 @@ impl SkinShared {
|
|||||||
&self.resizing_dock
|
&self.resizing_dock
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Redraw the area after a setting changed. The skin is not an entity, so
|
/// Redraw the area after a setting changed. The skin is not an entity, so nothing else would.
|
||||||
/// nothing else would notice.
|
|
||||||
pub(crate) fn notify(&self, cx: &mut App) {
|
pub(crate) fn notify(&self, cx: &mut App) {
|
||||||
_ = self.area.update(cx, |_, cx| cx.notify());
|
_ = self.area.update(cx, |_, cx| cx.notify());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Signed appearance for a [`DockArea`].
|
/// The Signed appearance for a [`DockArea`].
|
||||||
///
|
/// Install it in the constructor, the only place the area's weak handle is available.
|
||||||
/// Install it at construction, where the area's own weak handle is available:
|
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// let dock = cx.new(|cx| {
|
/// let dock = cx.new(|cx| {
|
||||||
@@ -90,8 +83,7 @@ impl SignedDockSkin {
|
|||||||
&self.shared
|
&self.shared
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether tab bars offer the affordance that collapses a neighbouring
|
/// Whether tab bars offer the affordance that collapses a neighbouring dock.
|
||||||
/// dock.
|
|
||||||
pub fn is_toggle_button_visible(&self) -> bool {
|
pub fn is_toggle_button_visible(&self) -> bool {
|
||||||
self.shared.is_toggle_button_visible()
|
self.shared.is_toggle_button_visible()
|
||||||
}
|
}
|
||||||
@@ -112,8 +104,9 @@ impl SignedDockSkin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The payload a dock's resize handle drags. It draws nothing: the handle
|
/// Payload a dock's resize handle drags.
|
||||||
/// itself is the affordance.
|
///
|
||||||
|
/// It draws nothing, the handle element is the visible affordance.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ResizePanel;
|
struct ResizePanel;
|
||||||
|
|
||||||
@@ -144,9 +137,7 @@ impl DockAreaRenderer for SignedDockSkin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||||
// `size_full` is what the old `StackPanel::render` carried; `flex_1`
|
// `size_full` and `flex_1` stop the frame collapsing in an unsizing parent.
|
||||||
// is belt and braces so the frame never collapses to zero height in
|
|
||||||
// an unsizing parent.
|
|
||||||
div()
|
div()
|
||||||
.id(("dock-split-frame", node.as_u64()))
|
.id(("dock-split-frame", node.as_u64()))
|
||||||
.size_full()
|
.size_full()
|
||||||
@@ -166,8 +157,8 @@ impl DockAreaRenderer for SignedDockSkin {
|
|||||||
let placement = dock.placement();
|
let placement = dock.placement();
|
||||||
let open = dock.is_open();
|
let open = dock.is_open();
|
||||||
|
|
||||||
// A closed left or right dock takes no space at all; a closed bottom
|
// A closed left or right dock takes no space.
|
||||||
// dock keeps a strip so its tab bar stays clickable.
|
// A closed bottom dock keeps a strip so its tab bar stays clickable.
|
||||||
if !open && !placement.is_bottom() {
|
if !open && !placement.is_bottom() {
|
||||||
return div().into_any_element();
|
return div().into_any_element();
|
||||||
}
|
}
|
||||||
@@ -183,8 +174,7 @@ impl DockAreaRenderer for SignedDockSkin {
|
|||||||
// Base never builds a dock for the centre.
|
// Base never builds a dock for the centre.
|
||||||
DockPlacement::Center => this,
|
DockPlacement::Center => this,
|
||||||
})
|
})
|
||||||
// The closed bottom dock's strip is the tab bar itself, which is
|
// The closed bottom dock's strip is the tab bar itself, a full tab bar tall.
|
||||||
// a full tab bar tall.
|
|
||||||
.when(!open && placement.is_bottom(), |this| {
|
.when(!open && placement.is_bottom(), |this| {
|
||||||
this.h(TAB_BAR_HEIGHT)
|
this.h(TAB_BAR_HEIGHT)
|
||||||
})
|
})
|
||||||
@@ -197,9 +187,8 @@ impl DockAreaRenderer for SignedDockSkin {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The "unknown panel" message the old `InvalidPanel` drew. It answers
|
/// Placeholder for a panel this build cannot construct.
|
||||||
/// `dump` with the state it was handed, so a layout written by a build
|
/// It dumps the state it was handed, so the layout survives a load and save.
|
||||||
/// that knows the panel survives a load and save here.
|
|
||||||
fn build_placeholder(
|
fn build_placeholder(
|
||||||
&self,
|
&self,
|
||||||
state: &PanelState,
|
state: &PanelState,
|
||||||
@@ -241,10 +230,8 @@ impl SignedDockSkin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Turns the window's mouse stream into dock resizing. A resize is driven
|
/// Turns the window's mouse stream into dock resizing.
|
||||||
/// by pointer moves anywhere in the window, so this paints nothing and
|
/// It draws nothing, the `paint` hook is the only window listener registration point.
|
||||||
/// exists for its `paint` hook — the only place a window-level mouse
|
|
||||||
/// listener can be registered.
|
|
||||||
struct DockResizeTracker {
|
struct DockResizeTracker {
|
||||||
dock: DockContext,
|
dock: DockContext,
|
||||||
shared: Rc<SkinShared>,
|
shared: Rc<SkinShared>,
|
||||||
@@ -310,10 +297,8 @@ impl Element for DockResizeTracker {
|
|||||||
if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
|
if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Dragging a closed dock's handle reopens it. The live
|
// Dragging a closed dock's handle reopens it.
|
||||||
// state is read rather than the render-time snapshot in
|
// Read the live state, the snapshot in `dock` would toggle it shut again.
|
||||||
// `dock`, which would still say closed for the rest of the
|
|
||||||
// frame and toggle it shut again on the next move.
|
|
||||||
let open = shared
|
let open = shared
|
||||||
.area()
|
.area()
|
||||||
.upgrade()
|
.upgrade()
|
||||||
@@ -332,8 +317,8 @@ impl Element for DockResizeTracker {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
shared.resizing_dock().set(None);
|
shared.resizing_dock().set(None);
|
||||||
// The size lives on the dock, not in the layout tree, so
|
// The size lives on the dock, not the layout tree.
|
||||||
// nothing else tells a subscriber to persist it.
|
// Nothing else tells a subscriber to persist it.
|
||||||
_ = shared
|
_ = shared
|
||||||
.area()
|
.area()
|
||||||
.update(cx, |_, cx| cx.emit(DockEvent::LayoutChanged));
|
.update(cx, |_, cx| cx.emit(DockEvent::LayoutChanged));
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ use gpui_component::ActiveTheme as _;
|
|||||||
|
|
||||||
use crate::Panel;
|
use crate::Panel;
|
||||||
|
|
||||||
/// Stands in for a panel this build cannot construct. It reports the
|
/// Stands in for a panel this build cannot construct.
|
||||||
/// original [`PanelState`] from [`dump`](gpui_base::dock::Panel::dump), so
|
/// It returns the state it was handed, so the layout survives a load and save.
|
||||||
/// a layout written by a build that knows the panel survives a load and
|
|
||||||
/// save here.
|
|
||||||
pub(crate) struct InvalidPanel {
|
pub(crate) struct InvalidPanel {
|
||||||
name: SharedString,
|
name: SharedString,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
|
|||||||
+17
-18
@@ -1,15 +1,7 @@
|
|||||||
//! The Signed dock skin.
|
use std::sync::Arc;
|
||||||
//!
|
|
||||||
//! The dock engine lives upstream (`gpui_base::dock` owns the layout tree,
|
|
||||||
//! drags, zoom and persistence); this crate is the appearance the app used
|
|
||||||
//! to vendor from gpui-component — a 44px tab bar that doubles as the
|
|
||||||
//! window title bar, with pill tabs, window controls, title-bar dragging
|
|
||||||
//! and previous/next tab buttons.
|
|
||||||
//!
|
|
||||||
//! Everything `gpui_component::dock` exports is re-exported here, so the app
|
|
||||||
//! keeps importing the dock from a single place.
|
|
||||||
|
|
||||||
use gpui::{Pixels, px};
|
use gpui::{Context, Pixels, Window, px};
|
||||||
|
use gpui_base::dock::PanelView;
|
||||||
|
|
||||||
mod dock_area;
|
mod dock_area;
|
||||||
mod invalid_panel;
|
mod invalid_panel;
|
||||||
@@ -19,20 +11,27 @@ mod window_controls;
|
|||||||
|
|
||||||
pub use dock_area::SignedDockSkin;
|
pub use dock_area::SignedDockSkin;
|
||||||
pub use gpui_component::dock::{
|
pub use gpui_component::dock::{
|
||||||
AnyDrag, BasePanel, BasePanelView, ClosePanel, DockArea, DockAreaState, DockContext, DockEvent,
|
BasePanel, DockArea, DockEvent, DockLayout, DockPlacement, Panel, PanelEvent, panel_handle,
|
||||||
DockLayout, DockPlacement, DockState, DragPanel, DropIndicator, DropPlaceholderBounds,
|
|
||||||
DropTarget, Panel, PanelControl, PanelEvent, PanelHandle, PanelInfo, PanelState, PanelStyle,
|
|
||||||
PanelView, TitleStyle, ToggleZoom, panel_handle, register_panel,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// 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.
|
/// The fixed height of the tab bar, which doubles as the window title bar.
|
||||||
pub const TAB_BAR_HEIGHT: Pixels = px(44.);
|
pub const TAB_BAR_HEIGHT: Pixels = px(44.);
|
||||||
|
|
||||||
/// Minimal i18n shim replacing gpui-component's `rust_i18n::t!()`, keeping the
|
/// i18n shim resolving `Dock.*` keys to English, so the crate has no i18n dependency.
|
||||||
/// same `Dock.*` keys resolved to English so the crate has no i18n dependency.
|
|
||||||
pub(crate) fn t(key: &'static str) -> &'static str {
|
pub(crate) fn t(key: &'static str) -> &'static str {
|
||||||
match key {
|
match key {
|
||||||
"Dock.Unnamed" => "Unnamed",
|
|
||||||
"Dock.Close" => "Close",
|
"Dock.Close" => "Close",
|
||||||
"Dock.Zoom In" => "Zoom In",
|
"Dock.Zoom In" => "Zoom In",
|
||||||
"Dock.Zoom Out" => "Zoom Out",
|
"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::cell::Cell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -27,6 +17,7 @@ use gpui_base::dock::{
|
|||||||
use gpui_base::{ElementExt, InteractiveElementExt, Tab, Tabs};
|
use gpui_base::{ElementExt, InteractiveElementExt, Tab, Tabs};
|
||||||
use gpui_component::animation::{Lerp as _, ease_out_cubic};
|
use gpui_component::animation::{Lerp as _, ease_out_cubic};
|
||||||
use gpui_component::button::{Button, ButtonVariants as _};
|
use gpui_component::button::{Button, ButtonVariants as _};
|
||||||
|
use gpui_component::dock::{ClosePanel, PanelControl, PanelHandle, ToggleZoom};
|
||||||
use gpui_component::menu::DropdownMenu as _;
|
use gpui_component::menu::DropdownMenu as _;
|
||||||
use gpui_component::{
|
use gpui_component::{
|
||||||
ActiveTheme as _, Disableable as _, IconName, Selectable as _, Sizable as _, h_flex, v_flex,
|
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 signed_ui::title_bar_drag_handlers;
|
||||||
|
|
||||||
use crate::dock_area::SkinShared;
|
use crate::dock_area::SkinShared;
|
||||||
use crate::{
|
use crate::{TAB_BAR_HEIGHT, t, window_controls};
|
||||||
ClosePanel, PanelControl, PanelHandle, TAB_BAR_HEIGHT, ToggleZoom, t, window_controls,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// The size the styled drag preview occupies, reported to base so a drop
|
/// The drag preview's size, reported to base for the drop placeholder.
|
||||||
/// placeholder knows where to fly in from.
|
|
||||||
const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = size(px(96.), px(30.));
|
const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = size(px(96.), px(30.));
|
||||||
|
|
||||||
/// A panel's title, or its registered name when it reached base without this
|
/// A panel's title, or its registered name when the panel has no handle.
|
||||||
/// crate's handle and so carries no presentation. See [`PanelHandle::of`].
|
|
||||||
pub(crate) fn panel_title(
|
pub(crate) fn panel_title(
|
||||||
panel: &Arc<dyn BasePanelView>,
|
panel: &Arc<dyn BasePanelView>,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
@@ -56,9 +43,7 @@ pub(crate) fn panel_title(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The preview that follows the cursor while a panel is dragged.
|
/// The preview that follows the cursor while a panel is dragged.
|
||||||
///
|
/// Base's `DragPanel` is the payload and draws nothing, this is the appearance half.
|
||||||
/// `gpui_base::dock::DragPanel` is the payload and draws nothing; this is the
|
|
||||||
/// appearance half, reintroduced here.
|
|
||||||
struct DragPanelPreview {
|
struct DragPanelPreview {
|
||||||
panel: Arc<dyn BasePanelView>,
|
panel: Arc<dyn BasePanelView>,
|
||||||
}
|
}
|
||||||
@@ -83,10 +68,8 @@ impl Render for DragPanelPreview {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the zoom affordance goes for the group's displayed panel, or `None`
|
/// The zoom affordance for the group's displayed panel, if it offers one.
|
||||||
/// when there is none to offer. Both [`Panel::zoom_control`] (where) and
|
/// The panel must offer a control and be zoomable, base refuses a zoom otherwise.
|
||||||
/// [`gpui_base::dock::Panel::zoomable`] (whether) must pass; base refuses a
|
|
||||||
/// zoom that fails the latter.
|
|
||||||
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
||||||
let panel = group.active_panel()?;
|
let panel = group.active_panel()?;
|
||||||
panel
|
panel
|
||||||
@@ -95,8 +78,8 @@ fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
|||||||
.flatten()
|
.flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The left-most, top-most tab group in a container — where a left dock's
|
/// The left-most, top-most tab group in a container.
|
||||||
/// collapse affordance goes. Mirrors the old `StackPanel::left_top_tab_panel`.
|
/// A left dock's collapse button lives in this group.
|
||||||
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
|
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||||
match node.kind() {
|
match node.kind() {
|
||||||
PaneRef::Tabs { .. } => Some(node.id()),
|
PaneRef::Tabs { .. } => Some(node.id()),
|
||||||
@@ -105,9 +88,8 @@ fn left_top_group(node: &PaneNode) -> Option<NodeId> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The right-most, top-most tab group. A vertical split stacks its children,
|
/// The right-most, top-most tab group.
|
||||||
/// so its *first* child is the top one; a horizontal split's last child is
|
/// A vertical split picks its first child, a horizontal split picks its last.
|
||||||
/// the right-most. Mirrors the old `StackPanel::right_top_tab_panel`.
|
|
||||||
fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||||
match node.kind() {
|
match node.kind() {
|
||||||
PaneRef::Tabs { .. } => Some(node.id()),
|
PaneRef::Tabs { .. } => Some(node.id()),
|
||||||
@@ -120,23 +102,17 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One tab group's appearance. Built once per container, so the tab bar's
|
/// One tab group's appearance, built once per container so its geometry is its own.
|
||||||
/// scroll position and measured title-bar geometry belong to the group.
|
|
||||||
pub(crate) struct SignedTabGroupSkin {
|
pub(crate) struct SignedTabGroupSkin {
|
||||||
shared: Rc<SkinShared>,
|
shared: Rc<SkinShared>,
|
||||||
scroll_handle: ScrollHandle,
|
scroll_handle: ScrollHandle,
|
||||||
/// The displayed tab the last frame drew, so a change scrolls the new tab
|
/// The tab shown last frame, so a change scrolls the new one into view.
|
||||||
/// into view.
|
|
||||||
last_active_ix: Cell<Option<usize>>,
|
last_active_ix: Cell<Option<usize>>,
|
||||||
/// Bounds of the title bar row (the wrapper around the tab bar), in
|
/// Bounds of the title bar row, measured to place the title-bar drag overlay.
|
||||||
/// window coordinates. Measured via `on_prepaint` to position the
|
|
||||||
/// title-bar drag overlay.
|
|
||||||
title_bar_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
title_bar_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||||
/// Bounds of the tab bar's trailing empty space (right after the last
|
/// Bounds of the empty strip after the last tab, where the drag region starts.
|
||||||
/// tab), which marks where the draggable region starts.
|
|
||||||
title_bar_strip_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
title_bar_strip_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||||
/// Bounds of the tab bar's suffix (toolbar) area, which marks where the
|
/// Bounds of the suffix area, where the drag region ends.
|
||||||
/// draggable region ends.
|
|
||||||
title_bar_suffix_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
title_bar_suffix_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,9 +128,8 @@ impl SignedTabGroupSkin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A group that is the left dock's whole content with a single panel
|
/// A group that is the left dock's only group, with one panel, draws no chrome.
|
||||||
/// draws no chrome at all — the vendored dock rendered such a panel bare,
|
/// The vendored dock rendered such a panel bare and the sidebar is one.
|
||||||
/// and the sidebar is one.
|
|
||||||
fn is_plain_sidebar_group(&self, group: &TabGroupContext, cx: &mut App) -> bool {
|
fn is_plain_sidebar_group(&self, group: &TabGroupContext, cx: &mut App) -> bool {
|
||||||
let Some(area) = self.shared.area().upgrade() else {
|
let Some(area) = self.shared.area().upgrade() else {
|
||||||
return false;
|
return false;
|
||||||
@@ -169,11 +144,8 @@ impl SignedTabGroupSkin {
|
|||||||
left == group.node() && group.panels().len() == 1
|
left == group.node() && group.panels().len() == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The bottom or right dock whose root tab group this group is, if any.
|
/// The bottom or right dock whose root tab group is this one, if any.
|
||||||
///
|
/// Base keeps a dock's last group, so the skin removes these docks as a whole.
|
||||||
/// Base bars a dock's only group from being dragged or closed, so the
|
|
||||||
/// dock cannot be emptied — but a bottom/right panel is supposed to be
|
|
||||||
/// closable, so the skin routes around the bar for these groups.
|
|
||||||
fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option<DockPlacement> {
|
fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option<DockPlacement> {
|
||||||
let area = self.shared.area().upgrade()?;
|
let area = self.shared.area().upgrade()?;
|
||||||
let area = area.read(cx);
|
let area = area.read(cx);
|
||||||
@@ -185,10 +157,8 @@ impl SignedTabGroupSkin {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The drag payload for the tab at `ix`, or `None` when this group must
|
/// The tab's drag payload, or `None` when the group must not be rearranged.
|
||||||
/// not be rearranged. A locked group is never draggable; a group that is
|
/// A locked group never is, a bottom or right dock root always is.
|
||||||
/// a bottom/right dock's only content still is, because the center is
|
|
||||||
/// always there to land in.
|
|
||||||
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
|
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
|
||||||
if group.is_locked() {
|
if group.is_locked() {
|
||||||
return None;
|
return None;
|
||||||
@@ -199,8 +169,8 @@ impl SignedTabGroupSkin {
|
|||||||
group.drag_panel(ix, cx)
|
group.drag_panel(ix, cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a dock's collapse affordance belongs in *this* group's tab
|
/// A dock's collapse button for this group's bar, or `None` when it does not belong.
|
||||||
/// bar, and which way it points. `None` means this group draws none.
|
/// The icon direction depends on whether the dock is open.
|
||||||
fn dock_toggle_button(
|
fn dock_toggle_button(
|
||||||
&self,
|
&self,
|
||||||
placement: DockPlacement,
|
placement: DockPlacement,
|
||||||
@@ -213,8 +183,7 @@ impl SignedTabGroupSkin {
|
|||||||
|
|
||||||
let area = self.shared.area().upgrade()?;
|
let area = self.shared.area().upgrade()?;
|
||||||
let area = area.read(cx);
|
let area = area.read(cx);
|
||||||
// A dock that does not exist is not collapsible, so this covers the
|
// A missing dock is not collapsible, this also covers the old `left_dock.is_some()` test.
|
||||||
// old `left_dock.is_some()` test too.
|
|
||||||
if !area.is_dock_collapsible(placement) {
|
if !area.is_dock_collapsible(placement) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -263,8 +232,8 @@ impl SignedTabGroupSkin {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The previous/next tab buttons shown in the tab bar's leading prefix.
|
/// The previous and next tab buttons in the tab bar's leading prefix.
|
||||||
/// Always rendered, disabled at the ends of the strip (or collapsed).
|
/// Always rendered, disabled at the strip ends or when collapsed.
|
||||||
fn render_prev_next_tab_buttons(
|
fn render_prev_next_tab_buttons(
|
||||||
&self,
|
&self,
|
||||||
group: &TabGroupContext,
|
group: &TabGroupContext,
|
||||||
@@ -306,8 +275,7 @@ impl SignedTabGroupSkin {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The trailing controls: the panel's own buttons, the zoom affordance,
|
/// The trailing controls, the panel's own buttons, zoom and the ellipsis menu.
|
||||||
/// and the ellipsis menu.
|
|
||||||
fn render_toolbar(
|
fn render_toolbar(
|
||||||
&self,
|
&self,
|
||||||
group: &TabGroupContext,
|
group: &TabGroupContext,
|
||||||
@@ -323,9 +291,8 @@ impl SignedTabGroupSkin {
|
|||||||
let control = zoom_control(group, cx);
|
let control = zoom_control(group, cx);
|
||||||
let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible());
|
let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible());
|
||||||
let menu_zoom = control.is_some_and(|control| control.menu_visible());
|
let menu_zoom = control.is_some_and(|control| control.menu_visible());
|
||||||
// A bottom/right dock's only panel cannot be closed through the
|
// A bottom or right dock's only panel cannot close through the group.
|
||||||
// group (base keeps a dock's last group), but the skin handles that
|
// The close item is offered, the skin removes the whole dock instead.
|
||||||
// close by removing the whole dock, so the item is offered.
|
|
||||||
let closable = group.is_closable()
|
let closable = group.is_closable()
|
||||||
|| (self.is_dock_root_group(group, cx).is_some()
|
|| (self.is_dock_root_group(group, cx).is_some()
|
||||||
&& group.active_panel().is_some_and(|panel| panel.closable(cx)));
|
&& group.active_panel().is_some_and(|panel| panel.closable(cx)));
|
||||||
@@ -397,9 +364,9 @@ impl SignedTabGroupSkin {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One tab of the pill strip. While collapsed, tabs lose the active
|
/// One tab of the pill strip.
|
||||||
/// style and all interactions, and the strip becomes the way a closed
|
/// While collapsed, tabs lose the active style and all interactions.
|
||||||
/// bottom dock is opened again.
|
/// The strip is also how a closed bottom dock is opened again.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn render_tab(
|
fn render_tab(
|
||||||
&self,
|
&self,
|
||||||
@@ -433,8 +400,7 @@ impl SignedTabGroupSkin {
|
|||||||
Some(tab_name) => this.child(tab_name),
|
Some(tab_name) => this.child(tab_name),
|
||||||
None => this.child(panel_title(&panel, window, cx)),
|
None => this.child(panel_title(&panel, window, cx)),
|
||||||
})
|
})
|
||||||
// Pill presentation: the selected tab is the filled pill, the
|
// Pill style, the selected tab is the filled pill, others show only on hover.
|
||||||
// rest are transparent until hovered.
|
|
||||||
.styles(|styles| {
|
.styles(|styles| {
|
||||||
styles.selected(|style| {
|
styles.selected(|style| {
|
||||||
style
|
style
|
||||||
@@ -457,8 +423,7 @@ impl SignedTabGroupSkin {
|
|||||||
move |_, window, cx| {
|
move |_, window, cx| {
|
||||||
group.select_tab(ix, window, cx);
|
group.select_tab(ix, window, cx);
|
||||||
|
|
||||||
// Clicking the strip of a collapsed bottom dock is how it
|
// Clicking the strip of a collapsed bottom dock reopens it.
|
||||||
// is opened again.
|
|
||||||
if is_bottom_dock && collapsed {
|
if is_bottom_dock && collapsed {
|
||||||
_ = area.update(cx, |area, cx| {
|
_ = area.update(cx, |area, cx| {
|
||||||
area.toggle_dock(DockPlacement::Bottom, window, cx);
|
area.toggle_dock(DockPlacement::Bottom, window, cx);
|
||||||
@@ -509,9 +474,8 @@ impl SignedTabGroupSkin {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The strip after the last tab: a drop target for panels and host-owned
|
/// The strip after the last tab, a drop target for panels and other drag items.
|
||||||
/// drag items. Its left edge (right after the last tab) marks the start
|
/// Its left edge marks where the title-bar drag overlay starts.
|
||||||
/// of the title-bar drag overlay.
|
|
||||||
fn render_empty_space(
|
fn render_empty_space(
|
||||||
&self,
|
&self,
|
||||||
group: &TabGroupContext,
|
group: &TabGroupContext,
|
||||||
@@ -541,9 +505,8 @@ impl SignedTabGroupSkin {
|
|||||||
let group = group.clone();
|
let group = group.clone();
|
||||||
let node = group.node();
|
let node = group.node();
|
||||||
move |drag: &DragPanel, window, cx| {
|
move |drag: &DragPanel, window, cx| {
|
||||||
// A panel dropped past its own last tab lands in the
|
// A panel dropped past its own last tab lands in the final slot.
|
||||||
// final slot; one from elsewhere is appended in the
|
// A panel from elsewhere is appended in the background.
|
||||||
// background.
|
|
||||||
let ix = (drag.source() == node).then(|| tabs_count - 1);
|
let ix = (drag.source() == node).then(|| tabs_count - 1);
|
||||||
group.drop_panel(drag.clone(), ix, false, window, cx);
|
group.drop_panel(drag.clone(), ix, false, window, cx);
|
||||||
}
|
}
|
||||||
@@ -564,37 +527,30 @@ impl SignedTabGroupSkin {
|
|||||||
impl TabGroupRenderer for SignedTabGroupSkin {
|
impl TabGroupRenderer for SignedTabGroupSkin {
|
||||||
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||||
let control = zoom_control(group, cx);
|
let control = zoom_control(group, cx);
|
||||||
// An emptied group — its last panel was dragged away — draws nothing,
|
// An emptied group draws nothing, so no bare tab bar is left behind.
|
||||||
// so an emptied dock does not leave a bare tab bar behind.
|
|
||||||
if group.panels().is_empty() {
|
if group.panels().is_empty() {
|
||||||
return div().id("tab-panel");
|
return div().id("tab-panel");
|
||||||
}
|
}
|
||||||
// Closing the only panel of a bottom/right dock would leave an
|
// Base refuses an empty dock, so closing its only panel removes the dock.
|
||||||
// empty dock, which base refuses; the skin removes the dock instead.
|
|
||||||
let dock_to_remove = (group.panels().len() <= 1)
|
let dock_to_remove = (group.panels().len() <= 1)
|
||||||
.then(|| self.is_dock_root_group(group, cx))
|
.then(|| self.is_dock_root_group(group, cx))
|
||||||
.flatten();
|
.flatten();
|
||||||
let shared = self.shared.clone();
|
let shared = self.shared.clone();
|
||||||
|
|
||||||
// `v_flex`, not `div`: gpui's default display is Block, and in block
|
// `v_flex`, a plain `div` ignores `flex_grow` and the content would collapse.
|
||||||
// layout a child's `flex_grow` is ignored — the content region below
|
|
||||||
// the tab bar would resolve to zero height.
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.id("tab-panel")
|
.id("tab-panel")
|
||||||
.size_full()
|
.size_full()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.bg(cx.theme().tokens.background)
|
.bg(cx.theme().tokens.background)
|
||||||
// A collapsed group is a strip of tabs with no content, and the
|
// A collapsed group has no content, so these actions are not registered.
|
||||||
// actions act on content.
|
|
||||||
.when(!group.is_collapsed(), |this| {
|
.when(!group.is_collapsed(), |this| {
|
||||||
this.on_action({
|
this.on_action({
|
||||||
let group = group.clone();
|
let group = group.clone();
|
||||||
move |_: &ToggleZoom, window, cx| {
|
move |_: &ToggleZoom, window, cx| {
|
||||||
// The affordance decides the control, so a panel
|
// A panel with no zoom control is not zoomed in by the keybinding.
|
||||||
// offering none is not zoomed *in* by the keybinding
|
// Zooming out is never refused.
|
||||||
// either. Zooming out is never refused: a panel that
|
// Otherwise a zoomed panel that lost its control would strand the user.
|
||||||
// stopped offering the control while zoomed would
|
|
||||||
// otherwise strand the user with no way back.
|
|
||||||
if !group.is_zoomed() && control.is_none() {
|
if !group.is_zoomed() && control.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -628,8 +584,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
fn content_frame(&self, group: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
fn content_frame(&self, group: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
||||||
v_flex()
|
v_flex()
|
||||||
.id("active-panel")
|
.id("active-panel")
|
||||||
// A collapsed group draws its tab strip and nothing else, so the
|
// A collapsed group draws its tab strip only, so the content claims no space.
|
||||||
// content region must not claim any space.
|
|
||||||
.when(!group.is_collapsed(), |this| this.flex_1())
|
.when(!group.is_collapsed(), |this| this.flex_1())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,14 +594,12 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
// An emptied group draws no tab bar; the app prunes the emptied
|
// An emptied group draws no tab bar, the app prunes the emptied dock later.
|
||||||
// bottom/right dock a moment later.
|
|
||||||
if group.panels().is_empty() {
|
if group.panels().is_empty() {
|
||||||
return Empty.into_any_element();
|
return Empty.into_any_element();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The sidebar group draws no chrome at all, like the vendored dock's
|
// The sidebar group draws no chrome, like the vendored `DockItem::Panel`.
|
||||||
// bare `DockItem::Panel`.
|
|
||||||
if self.is_plain_sidebar_group(group, cx) {
|
if self.is_plain_sidebar_group(group, cx) {
|
||||||
return Empty.into_any_element();
|
return Empty.into_any_element();
|
||||||
}
|
}
|
||||||
@@ -660,10 +613,9 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
||||||
let is_bottom_dock = bottom_dock_button.is_some();
|
let is_bottom_dock = bottom_dock_button.is_some();
|
||||||
|
|
||||||
// macOS: the traffic lights overlay the window's top-left corner.
|
// On macOS the traffic lights overlay the window's top-left corner.
|
||||||
// Only the group whose tab bar actually sits under them reserves the
|
// Only the tab bar that sits under them reserves the space.
|
||||||
// space — the center's left-most, top-most group when the left dock
|
// That is the center's top-left group when the left dock is closed or absent.
|
||||||
// is closed or absent.
|
|
||||||
let needs_traffic_light_padding = cfg!(target_os = "macos")
|
let needs_traffic_light_padding = cfg!(target_os = "macos")
|
||||||
&& self.shared.area().upgrade().is_some_and(|area| {
|
&& self.shared.area().upgrade().is_some_and(|area| {
|
||||||
let area = area.read(cx);
|
let area = area.read(cx);
|
||||||
@@ -674,8 +626,8 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
== Some(group.node())
|
== Some(group.node())
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bring a newly displayed tab into view. The group owns selection
|
// Bring a newly displayed tab into view.
|
||||||
// now, so the skin notices the change rather than being told about it.
|
// The group owns selection, so the skin watches for the change itself.
|
||||||
let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
|
let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
|
||||||
let visible: Vec<usize> = group
|
let visible: Vec<usize> = group
|
||||||
.panels()
|
.panels()
|
||||||
@@ -690,10 +642,8 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
self.scroll_handle.scroll_to_item(visible_ix);
|
self.scroll_handle.scroll_to_item(visible_ix);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The tab strip lays out at content width, so the area after the
|
// The tab strip ends at the last tab, the bar has no element after it.
|
||||||
// last tab has no element. Cover that dead zone (last tab's right
|
// Cover that dead zone with an overlay so it can drag the window.
|
||||||
// edge to suffix's left edge) with a measured overlay so the whole
|
|
||||||
// non-interactive area can drag the window.
|
|
||||||
let drag_overlay = match (
|
let drag_overlay = match (
|
||||||
self.title_bar_bounds.get(),
|
self.title_bar_bounds.get(),
|
||||||
self.title_bar_strip_bounds.get(),
|
self.title_bar_strip_bounds.get(),
|
||||||
@@ -728,8 +678,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
if !panel.visible(cx) {
|
if !panel.visible(cx) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// A collapsed group shows no tab as active: the strip is a
|
// Collapsed tabs never show as active, the strip only reopens the dock.
|
||||||
// way back in, not a selection.
|
|
||||||
if collapsed {
|
if collapsed {
|
||||||
active = false;
|
active = false;
|
||||||
}
|
}
|
||||||
@@ -768,7 +717,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.top_0()
|
.top_0()
|
||||||
// Right -1 for avoid border overlap with the first tab
|
// -1 px so the border does not overlap the first tab.
|
||||||
.right(-px(1.))
|
.right(-px(1.))
|
||||||
.h_full()
|
.h_full()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
@@ -852,9 +801,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> Option<AnyElement> {
|
) -> Option<AnyElement> {
|
||||||
let (from, to) = (indicator.from(), indicator.to());
|
let (from, to) = (indicator.from(), indicator.to());
|
||||||
// The placeholder animates from wherever it was to where the drop
|
// The element sits at the drop target, the animation walks back from the source.
|
||||||
// would land, so its own element is positioned at the destination and
|
|
||||||
// the animation only has to walk the difference back to zero.
|
|
||||||
let offset = from.origin() - to.origin();
|
let offset = from.origin() - to.origin();
|
||||||
|
|
||||||
Some(
|
Some(
|
||||||
|
|||||||
+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 std::rc::Rc;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
@@ -18,6 +10,7 @@ use gpui_base::dock::{
|
|||||||
DRAG_BAR_HEIGHT, HANDLE_SIZE, NodeId, ResizeSide, TileContext, TilesRenderer,
|
DRAG_BAR_HEIGHT, HANDLE_SIZE, NodeId, ResizeSide, TileContext, TilesRenderer,
|
||||||
};
|
};
|
||||||
use gpui_component::button::{Button, ButtonVariants as _};
|
use gpui_component::button::{Button, ButtonVariants as _};
|
||||||
|
use gpui_component::dock::PanelHandle;
|
||||||
use gpui_component::menu::{DropdownMenu as _, PopupMenuItem};
|
use gpui_component::menu::{DropdownMenu as _, PopupMenuItem};
|
||||||
use gpui_component::scroll::Scrollbar;
|
use gpui_component::scroll::Scrollbar;
|
||||||
use gpui_component::{
|
use gpui_component::{
|
||||||
@@ -25,8 +18,8 @@ use gpui_component::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::dock_area::SkinShared;
|
use crate::dock_area::SkinShared;
|
||||||
|
use crate::t;
|
||||||
use crate::tab_panel::panel_title;
|
use crate::tab_panel::panel_title;
|
||||||
use crate::{PanelHandle, t};
|
|
||||||
|
|
||||||
/// How far a resize handle sticks out past the tile's edge.
|
/// How far a resize handle sticks out past the tile's edge.
|
||||||
const HANDLE_OFFSET: Pixels = px(-4.);
|
const HANDLE_OFFSET: Pixels = px(-4.);
|
||||||
@@ -52,9 +45,7 @@ impl Render for DragResizing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One tiles canvas's appearance.
|
/// One tiles canvas's appearance.
|
||||||
///
|
/// Built once per container, so its scroll position belongs to the canvas it scrolls.
|
||||||
/// Built per canvas — `DockAreaRenderer::tiles_renderer` is called once per
|
|
||||||
/// container — so the scroll position belongs to the canvas it scrolls.
|
|
||||||
pub(crate) struct SignedTilesSkin {
|
pub(crate) struct SignedTilesSkin {
|
||||||
shared: Rc<SkinShared>,
|
shared: Rc<SkinShared>,
|
||||||
scroll_handle: ScrollHandle,
|
scroll_handle: ScrollHandle,
|
||||||
@@ -101,11 +92,8 @@ impl SignedTilesSkin {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The trailing controls of a tile's title bar: zoom, close and the
|
/// The trailing controls of a tile's title bar, zoom, close and the ellipsis menu.
|
||||||
/// ellipsis menu. They use click handlers rather than the
|
/// They use click handlers, the zoom and close actions target a focused tab group.
|
||||||
/// [`ToggleZoom`](crate::ToggleZoom)/[`ClosePanel`](crate::ClosePanel)
|
|
||||||
/// actions, which are dispatched to a focused tab group — a tile is
|
|
||||||
/// not one.
|
|
||||||
fn render_tile_controls(
|
fn render_tile_controls(
|
||||||
&self,
|
&self,
|
||||||
tile: &TileContext,
|
tile: &TileContext,
|
||||||
@@ -213,21 +201,17 @@ impl TilesRenderer for SignedTilesSkin {
|
|||||||
.border_1()
|
.border_1()
|
||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
.rounded(cx.theme().tile_radius)
|
.rounded(cx.theme().tile_radius)
|
||||||
// Room for the title bar, which is positioned over the padding so
|
// Room for the title bar, which overlays the top padding.
|
||||||
// the panel below it is never covered. Base draws the panel view
|
// Base draws the panel as a plain child, this keeps them apart.
|
||||||
// as a plain child, so this is the only way to keep the two from
|
|
||||||
// overlapping.
|
|
||||||
.pt(DRAG_BAR_HEIGHT)
|
.pt(DRAG_BAR_HEIGHT)
|
||||||
// Base installs the stored bounds on an ordinary tile and nothing
|
// Base stores no bounds on a zoomed tile, the skin decides how it fills the dock.
|
||||||
// at all on a zoomed one — how a zoomed tile fills the dock is
|
|
||||||
// this skin's decision.
|
|
||||||
.when(tile.is_zoomed(), |this| this.size_full())
|
.when(tile.is_zoomed(), |this| this.size_full())
|
||||||
.on_mouse_down(MouseButton::Left, {
|
.on_mouse_down(MouseButton::Left, {
|
||||||
let tile = tile.clone();
|
let tile = tile.clone();
|
||||||
move |_, window, cx| tile.bring_to_front(window, cx)
|
move |_, window, cx| tile.bring_to_front(window, cx)
|
||||||
})
|
})
|
||||||
// A gesture can end with the pointer anywhere, so both halves are
|
// A gesture can end anywhere, so both mouse-up hooks run.
|
||||||
// needed; each is a no-op unless this tile is the one moving.
|
// Each is a no-op unless this tile is the one that moved.
|
||||||
.on_mouse_up(MouseButton::Left, {
|
.on_mouse_up(MouseButton::Left, {
|
||||||
let tile = tile.clone();
|
let tile = tile.clone();
|
||||||
move |_, window, cx| {
|
move |_, window, cx| {
|
||||||
@@ -274,8 +258,7 @@ impl TilesRenderer for SignedTilesSkin {
|
|||||||
)
|
)
|
||||||
.children(handle.and_then(|handle| handle.title_suffix(window, cx)))
|
.children(handle.and_then(|handle| handle.title_suffix(window, cx)))
|
||||||
.child(self.render_tile_controls(tile, window, cx))
|
.child(self.render_tile_controls(tile, window, cx))
|
||||||
// A zoomed tile is not at its stored bounds, so there is nothing
|
// A zoomed tile is not at its stored bounds, so moving it would mean nothing.
|
||||||
// for a move to mean; base refuses the gesture too.
|
|
||||||
.when(!tile.is_zoomed(), |this| {
|
.when(!tile.is_zoomed(), |this| {
|
||||||
this.cursor_grab()
|
this.cursor_grab()
|
||||||
.on_mouse_down(MouseButton::Left, {
|
.on_mouse_down(MouseButton::Left, {
|
||||||
@@ -309,10 +292,8 @@ impl TilesRenderer for SignedTilesSkin {
|
|||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let bounds = tile.bounds();
|
let bounds = tile.bounds();
|
||||||
|
|
||||||
// A passive full-tile box so each handle is positioned against the
|
// A passive full-tile box, so handles sit against the tile, not its flow neighbours.
|
||||||
// tile rather than against whatever the flow put it next to. It
|
// It registers no interaction, so it does not shadow the panel underneath.
|
||||||
// registers no interaction of its own, so it does not shadow the panel
|
|
||||||
// underneath.
|
|
||||||
div()
|
div()
|
||||||
.absolute()
|
.absolute()
|
||||||
.top_0()
|
.top_0()
|
||||||
@@ -376,9 +357,7 @@ impl TilesRenderer for SignedTilesSkin {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The panel of a tile gets `size_full` here; base draws the panel as a
|
/// Gives the tile's panel `size_full`, base draws it as a plain child otherwise.
|
||||||
/// plain child, so without it a panel that does not size itself has no
|
|
||||||
/// size.
|
|
||||||
fn panel_frame(&self, tile: &TileContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
fn panel_frame(&self, tile: &TileContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
||||||
h_flex()
|
h_flex()
|
||||||
.id(("tile-panel", tile.panel_id().as_u64()))
|
.id(("tile-panel", tile.panel_id().as_u64()))
|
||||||
@@ -386,9 +365,8 @@ impl TilesRenderer for SignedTilesSkin {
|
|||||||
.size_full()
|
.size_full()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The canvas scrollbar. It must be an overlay: the frame is the scroll
|
/// The canvas scrollbar, as an overlay.
|
||||||
/// container and base appends the tiles after it, so a scrollbar placed
|
/// Placed inside the frame it would end up underneath every tile.
|
||||||
/// inside would paint and hit-test underneath every tile.
|
|
||||||
fn render_overlay(
|
fn render_overlay(
|
||||||
&self,
|
&self,
|
||||||
content: Size<Pixels>,
|
content: Size<Pixels>,
|
||||||
|
|||||||
@@ -147,8 +147,7 @@ pub(crate) fn window_controls(window: &mut Window, cx: &mut App) -> impl IntoEle
|
|||||||
.items_center()
|
.items_center()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.h_full()
|
.h_full()
|
||||||
// Like native windows apps, the controls span the title bar but never
|
// The controls span the title bar but never grow past the tab bar height.
|
||||||
// grow past the tab bar height.
|
|
||||||
.when(cfg!(target_os = "windows"), |this| {
|
.when(cfg!(target_os = "windows"), |this| {
|
||||||
this.max_h(TAB_BAR_HEIGHT)
|
this.max_h(TAB_BAR_HEIGHT)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
//! Render-path smoke tests: the skin reads the dock area while rendering, and
|
|
||||||
//! GPUI panics if an entity is read while it is leased (being updated). These
|
|
||||||
//! pin that the first frame — docks, groups, tab bars — renders without
|
|
||||||
//! tripping the lease check.
|
|
||||||
|
|
||||||
use dock::{BasePanel, Panel, SignedDockSkin, panel_handle};
|
use dock::{BasePanel, Panel, SignedDockSkin, panel_handle};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Empty, EventEmitter, FocusHandle, Focusable, IntoElement, Render,
|
App, AppContext, Context, Empty, EventEmitter, FocusHandle, Focusable, IntoElement, Render,
|
||||||
@@ -84,12 +79,10 @@ fn the_first_frame_renders_the_area_and_its_docks(cx: &mut TestAppContext) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// The first frame walks every render hook — the dock frame, each group's
|
// The first frame walks every render hook, all of which read the dock area.
|
||||||
// tab bar, the toolbar — all of which read the dock area.
|
|
||||||
cx.update(|window, cx| window.draw(cx).clear(cx));
|
cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||||
|
|
||||||
// Emptying a dock leaves an empty group behind; its render must also be
|
// Emptying a dock leaves an empty group, its render must also be safe.
|
||||||
// safe (and draw nothing).
|
|
||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
area.update(cx, |area, cx| {
|
area.update(cx, |area, cx| {
|
||||||
area.remove_panel(bottom, window, cx);
|
area.remove_panel(bottom, window, cx);
|
||||||
|
|||||||
+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::path::PathBuf;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
/// The application name, used to derive platform-specific data, config and
|
/// The application name.
|
||||||
/// cache directory paths.
|
///
|
||||||
|
/// It derives the platform-specific data, config and cache directory paths.
|
||||||
pub const APP_NAME: &str = "Signed";
|
pub const APP_NAME: &str = "Signed";
|
||||||
|
|
||||||
/// Lowercased form of [`APP_NAME`], for use in XDG-style paths on
|
/// Lowercased form of [`APP_NAME`].
|
||||||
/// Linux/FreeBSD and the macOS `~/.config` fallback.
|
///
|
||||||
|
/// Used in XDG-style paths on Linux and FreeBSD, and the macOS `~/.config` fallback.
|
||||||
pub const APP_NAME_LOWERCASE: &str = "signed";
|
pub const APP_NAME_LOWERCASE: &str = "signed";
|
||||||
|
|
||||||
/// A custom data directory override, set only by [`set_custom_data_dir`].
|
|
||||||
static CUSTOM_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
|
|
||||||
|
|
||||||
/// The resolved data directory.
|
/// The resolved data directory.
|
||||||
/// On macOS, this is `~/Library/Application Support/Signed`.
|
/// On macOS, this is `~/Library/Application Support/Signed`.
|
||||||
/// On Linux/FreeBSD, this is `$XDG_DATA_HOME/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")
|
dirs::home_dir().expect("failed to determine home directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the current user's Desktop folder, falling back to the home
|
/// Returns the current user's Desktop folder.
|
||||||
/// directory (or an empty path) when it can't be determined.
|
///
|
||||||
|
/// Falls back to the home directory or an empty path when it cannot be determined.
|
||||||
pub fn desktop_dir() -> PathBuf {
|
pub fn desktop_dir() -> PathBuf {
|
||||||
dirs::desktop_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
|
dirs::desktop_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the current user's Documents folder, falling back to the home
|
/// Returns the current user's Documents folder.
|
||||||
/// directory (or an empty path) when it can't be determined.
|
///
|
||||||
|
/// Falls back to the home directory or an empty path when it cannot be determined.
|
||||||
pub fn documents_dir() -> PathBuf {
|
pub fn documents_dir() -> PathBuf {
|
||||||
dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
|
dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets a custom directory for all user data, overriding the default data
|
|
||||||
/// 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.
|
/// Returns the path to the configuration directory.
|
||||||
pub fn config_dir() -> &'static PathBuf {
|
pub fn config_dir() -> &'static PathBuf {
|
||||||
CONFIG_DIR.get_or_init(|| {
|
CONFIG_DIR.get_or_init(|| {
|
||||||
if let Some(custom_dir) = CUSTOM_DATA_DIR.get() {
|
if cfg!(target_os = "windows") {
|
||||||
custom_dir.join("config")
|
|
||||||
} else if cfg!(target_os = "windows") {
|
|
||||||
dirs::config_dir()
|
dirs::config_dir()
|
||||||
.expect("failed to determine RoamingAppData directory")
|
.expect("failed to determine RoamingAppData directory")
|
||||||
.join(APP_NAME)
|
.join(APP_NAME)
|
||||||
@@ -93,9 +65,7 @@ pub fn config_dir() -> &'static PathBuf {
|
|||||||
/// Returns the path to the data directory.
|
/// Returns the path to the data directory.
|
||||||
pub fn data_dir() -> &'static PathBuf {
|
pub fn data_dir() -> &'static PathBuf {
|
||||||
CURRENT_DATA_DIR.get_or_init(|| {
|
CURRENT_DATA_DIR.get_or_init(|| {
|
||||||
if let Some(custom_dir) = CUSTOM_DATA_DIR.get() {
|
if cfg!(target_os = "macos") {
|
||||||
custom_dir.clone()
|
|
||||||
} else if cfg!(target_os = "macos") {
|
|
||||||
home_dir()
|
home_dir()
|
||||||
.join("Library/Application Support")
|
.join("Library/Application Support")
|
||||||
.join(APP_NAME)
|
.join(APP_NAME)
|
||||||
@@ -116,50 +86,13 @@ pub fn data_dir() -> &'static PathBuf {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the path to the cache directory.
|
/// Returns the path to the nostr database directory, LMDB.
|
||||||
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).
|
|
||||||
pub fn nostr_dir() -> &'static PathBuf {
|
pub fn nostr_dir() -> &'static PathBuf {
|
||||||
static NOSTR_DIR: OnceLock<PathBuf> = OnceLock::new();
|
static NOSTR_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
NOSTR_DIR.get_or_init(|| data_dir().join("nostr"))
|
NOSTR_DIR.get_or_init(|| data_dir().join("nostr"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the path to the local git clone cache (grasp mirrors).
|
/// Returns the path to the local git clone cache, the grasp mirrors.
|
||||||
pub fn repos_dir() -> &'static PathBuf {
|
pub fn repos_dir() -> &'static PathBuf {
|
||||||
static REPOS_DIR: OnceLock<PathBuf> = OnceLock::new();
|
static REPOS_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
REPOS_DIR.get_or_init(|| data_dir().join("repos"))
|
REPOS_DIR.get_or_init(|| data_dir().join("repos"))
|
||||||
@@ -170,9 +103,3 @@ pub fn settings_file() -> &'static PathBuf {
|
|||||||
static SETTINGS_FILE: OnceLock<PathBuf> = OnceLock::new();
|
static SETTINGS_FILE: OnceLock<PathBuf> = OnceLock::new();
|
||||||
SETTINGS_FILE.get_or_init(|| config_dir().join("settings.json"))
|
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 settings;
|
||||||
mod store;
|
mod store;
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// The default grasp servers offered when the user hasn't published a
|
/// The default grasp servers,
|
||||||
/// grasp list (kind `10317`) yet.
|
/// offered while the user has not published a grasp list.
|
||||||
pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [
|
pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [
|
||||||
"wss://relay.ngit.dev",
|
"wss://relay.ngit.dev",
|
||||||
"wss://gitnostr.com",
|
"wss://gitnostr.com",
|
||||||
@@ -14,7 +14,7 @@ pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [
|
|||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum AppearanceMode {
|
pub enum AppearanceMode {
|
||||||
/// Follow the system appearance (light/dark) at runtime.
|
/// Follow the system appearance, light or dark, at runtime.
|
||||||
#[default]
|
#[default]
|
||||||
System,
|
System,
|
||||||
/// Always use the light theme.
|
/// Always use the light theme.
|
||||||
@@ -23,12 +23,8 @@ pub enum AppearanceMode {
|
|||||||
Dark,
|
Dark,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Theme configuration.
|
/// Theme configuration,
|
||||||
///
|
/// fields mirror the gpui-component `Theme` surface customized at startup.
|
||||||
/// The fields mirror the gpui-component `Theme` surface the application
|
|
||||||
/// customizes at startup, so applying the settings is a plain field-for-field
|
|
||||||
/// copy. The theme names identify entries in the gpui-component theme
|
|
||||||
/// registry.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct ThemeSettings {
|
pub struct ThemeSettings {
|
||||||
@@ -42,7 +38,7 @@ pub struct ThemeSettings {
|
|||||||
pub mono_font_size: f32,
|
pub mono_font_size: f32,
|
||||||
/// Corner radius for general elements in pixels.
|
/// Corner radius for general elements in pixels.
|
||||||
pub radius: f32,
|
pub radius: f32,
|
||||||
/// Corner radius for large elements (dialogs, notifications) in pixels.
|
/// Corner radius for large elements, dialogs and notifications, in pixels.
|
||||||
pub radius_lg: f32,
|
pub radius_lg: f32,
|
||||||
/// Whether focused controls draw a ring outside their border.
|
/// Whether focused controls draw a ring outside their border.
|
||||||
pub focus_ring: bool,
|
pub focus_ring: bool,
|
||||||
@@ -69,8 +65,7 @@ impl Default for ThemeSettings {
|
|||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct GraspServersSettings {
|
pub struct GraspServersSettings {
|
||||||
/// The servers offered when the user hasn't published a grasp list
|
/// Servers offered while the user has not published a grasp list, kind `10317`.
|
||||||
/// (kind `10317`) yet.
|
|
||||||
pub default_servers: Vec<String>,
|
pub default_servers: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,9 +84,8 @@ impl Default for GraspServersSettings {
|
|||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct LocalReposSettings {
|
pub struct LocalReposSettings {
|
||||||
/// The directories scanned for local git repositories.
|
/// The directories scanned for local git repositories,
|
||||||
///
|
/// defaults to the user's Desktop and Documents folders.
|
||||||
/// Defaults to the user's Desktop and Documents folders.
|
|
||||||
pub scan_paths: Vec<PathBuf>,
|
pub scan_paths: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,12 +101,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.
|
/// The create-repository dialog.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct CreateRepositorySettings {
|
pub struct CreateRepositorySettings {
|
||||||
/// The folder the create-repository dialog defaults to; the user's
|
/// The folder the create-repository dialog defaults to, the user's Desktop when unset.
|
||||||
/// Desktop when unset.
|
|
||||||
pub default_folder: Option<PathBuf>,
|
pub default_folder: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +145,8 @@ pub struct Settings {
|
|||||||
pub grasp_servers: GraspServersSettings,
|
pub grasp_servers: GraspServersSettings,
|
||||||
/// Local repository scanning.
|
/// Local repository scanning.
|
||||||
pub local_repos: LocalReposSettings,
|
pub local_repos: LocalReposSettings,
|
||||||
|
/// Remembered local checkouts.
|
||||||
|
pub checkouts: CheckoutsSettings,
|
||||||
/// The create-repository dialog.
|
/// The create-repository dialog.
|
||||||
pub create_repository: CreateRepositorySettings,
|
pub create_repository: CreateRepositorySettings,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ struct GlobalSettingsStore(Entity<SettingsStore>);
|
|||||||
|
|
||||||
impl Global for GlobalSettingsStore {}
|
impl Global for GlobalSettingsStore {}
|
||||||
|
|
||||||
/// The application settings, loaded from disk at startup and persisted whenever they change.
|
/// The application settings,
|
||||||
|
/// loaded from disk at startup and saved whenever they change.
|
||||||
|
///
|
||||||
/// Installed as a global by the app so any part of the UI can read and edit them.
|
/// Installed as a global by the app so any part of the UI can read and edit them.
|
||||||
pub struct SettingsStore {
|
pub struct SettingsStore {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
@@ -17,7 +19,7 @@ pub struct SettingsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SettingsStore {
|
impl SettingsStore {
|
||||||
/// Retrieve the global settings store (created at startup by the app).
|
/// Retrieve the global settings store, created at startup by the app.
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
cx.global::<GlobalSettingsStore>().0.clone()
|
cx.global::<GlobalSettingsStore>().0.clone()
|
||||||
}
|
}
|
||||||
@@ -27,9 +29,11 @@ impl SettingsStore {
|
|||||||
cx.set_global(GlobalSettingsStore(entity));
|
cx.set_global(GlobalSettingsStore(entity));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the settings from `path`, falling back to the defaults when the
|
/// Load the settings from `path`,
|
||||||
/// file is missing or unreadable. Missing keys merge with the defaults,
|
/// falls back to defaults when the file is missing or unreadable.
|
||||||
/// so older settings files keep working as new settings are added.
|
///
|
||||||
|
/// Missing keys merge with the defaults,
|
||||||
|
/// older settings files keep working as new settings are added.
|
||||||
pub fn new(path: impl AsRef<Path>, _cx: &mut Context<Self>) -> Self {
|
pub fn new(path: impl AsRef<Path>, _cx: &mut Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
path: path.as_ref().to_path_buf(),
|
path: path.as_ref().to_path_buf(),
|
||||||
@@ -78,8 +82,7 @@ impl SettingsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the settings to disk, replacing the file atomically
|
/// Write the settings to disk, replacing the file atomically.
|
||||||
/// so a crash mid-write cannot corrupt the settings.
|
|
||||||
fn save(&self) -> Result<()> {
|
fn save(&self) -> Result<()> {
|
||||||
if let Some(parent) = self.path.parent() {
|
if let Some(parent) = self.path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
|
|||||||
@@ -5,5 +5,4 @@ edition.workspace = true
|
|||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
gpui.workspace = true
|
|
||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
/// Address of a NIP-34 repository announcement: `30617:<owner-pubkey>:<repo-id>`.
|
/// Address of a NIP-34 repository announcement, `30617:<owner-pubkey>:<repo-id>`.
|
||||||
///
|
///
|
||||||
/// The Rust Nostr SDK's [`Coordinate`] already provides parsing, formatting and hashing for this.
|
/// The Rust Nostr SDK's [`Coordinate`] parses, formats and hashes this,
|
||||||
/// The alias keeps the repository-specific vocabulary while reusing the SDK type.
|
/// the alias reuses the SDK type while keeping repository-specific vocabulary.
|
||||||
pub type RepoAddr = Coordinate;
|
pub type RepoAddr = Coordinate;
|
||||||
|
|
||||||
/// Build the address of a NIP-34 repository announcement.
|
/// Build the address of a NIP-34 repository announcement.
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
/// ngit / GitWorkshop cover-note extension (kind 1624): a markdown note
|
/// GitWorkshop and `ngit` cover-note extension, kind 1624.
|
||||||
/// attached to an issue, patch or PR by its author or a repository
|
///
|
||||||
/// maintainer. Not part of the NIP-34 draft; read support for interop.
|
/// A markdown note attached to an issue, patch or PR by its author or a maintainer,
|
||||||
|
/// not part of the NIP-34 draft, read support for interop.
|
||||||
pub const COVER_NOTE_KIND: Kind = Kind::Custom(1624);
|
pub const COVER_NOTE_KIND: Kind = Kind::Custom(1624);
|
||||||
|
|
||||||
/// Whether a kind-1985 label event is a valid annotation of `root`: it
|
/// Whether a kind-1985 label event is a valid annotation of `root`.
|
||||||
/// references the root via a lowercase `e` tag and was authored by the root
|
///
|
||||||
/// author or a maintainer.
|
/// The event references the root with a lowercase `e` tag,
|
||||||
|
/// its author must be the root author or a maintainer.
|
||||||
fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) -> bool {
|
fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) -> bool {
|
||||||
if event.kind != Kind::Label {
|
if event.kind != Kind::Label {
|
||||||
return false;
|
return false;
|
||||||
@@ -22,8 +24,8 @@ fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) ->
|
|||||||
.any(|tag| tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id))
|
.any(|tag| tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a kind-1985 label event declares the `#t` namespace and carries at
|
/// Whether a kind-1985 label event declares the `#t` namespace,
|
||||||
/// least one `["l", "<value>", "#t"]` label.
|
/// it must also carry at least one `["l", "<value>", "#t"]` label.
|
||||||
fn has_hashtag_labels(event: &Event) -> bool {
|
fn has_hashtag_labels(event: &Event) -> bool {
|
||||||
event.tags.iter().any(|tag| tag.as_slice() == ["L", "#t"])
|
event.tags.iter().any(|tag| tag.as_slice() == ["L", "#t"])
|
||||||
&& event.tags.iter().any(|tag| {
|
&& event.tags.iter().any(|tag| {
|
||||||
@@ -32,10 +34,12 @@ fn has_hashtag_labels(event: &Event) -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The effective hashtag labels of `root`: the `t` tags on the event itself
|
/// Effective hashtag labels of `root`,
|
||||||
/// (self-reported by its author) plus all labels attached via authorized
|
/// the `t` tags on the event itself, self-reported by its author,
|
||||||
/// NIP-32 kind-1985 events in the `#t` namespace. Labels are additive — all
|
/// authorized NIP-32 kind-1985 events in the `#t` namespace add more.
|
||||||
/// valid label events contribute (no latest-wins semantics).
|
///
|
||||||
|
/// Labels are additive, so all valid label events contribute,
|
||||||
|
/// there is no latest-wins semantics.
|
||||||
pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -> Vec<String> {
|
pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -> Vec<String> {
|
||||||
let mut labels: Vec<String> = root
|
let mut labels: Vec<String> = root
|
||||||
.tags
|
.tags
|
||||||
@@ -61,10 +65,10 @@ pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -
|
|||||||
labels
|
labels
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The effective subject/title override of `root`, from authorized kind-1985
|
/// Subject or title override of `root` from authorized kind-1985 label events,
|
||||||
/// label events in the `#subject` namespace. Only the latest event wins
|
/// only label events in the `#subject` namespace count.
|
||||||
/// (tiebreak: lexicographically larger event id, per NIP-01 replaceable
|
///
|
||||||
/// semantics). Returns `None` when no valid override exists.
|
/// Returns `None` when no valid override exists.
|
||||||
pub fn subject_override(
|
pub fn subject_override(
|
||||||
root: &Event,
|
root: &Event,
|
||||||
label_events: &[Event],
|
label_events: &[Event],
|
||||||
@@ -103,8 +107,8 @@ pub fn subject_override(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The effective hashtag labels and subject override of `root` in one pass
|
/// Effective hashtag labels and subject override of `root` in one pass,
|
||||||
/// (mirrors ngit's `get_labels_and_subject`).
|
/// mirrors ngit's `get_labels_and_subject`.
|
||||||
pub fn labels_and_subject(
|
pub fn labels_and_subject(
|
||||||
root: &Event,
|
root: &Event,
|
||||||
label_events: &[Event],
|
label_events: &[Event],
|
||||||
@@ -116,9 +120,9 @@ pub fn labels_and_subject(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The effective cover note of `root`: the latest authorized kind-1624 event
|
/// Effective cover note of `root`.
|
||||||
/// (tiebreak: lexicographically larger event id, per NIP-01 replaceable
|
///
|
||||||
/// semantics). Returns `None` when no valid cover note exists.
|
/// Returns `None` when no valid cover note exists.
|
||||||
pub fn cover_note<'a>(
|
pub fn cover_note<'a>(
|
||||||
root: &Event,
|
root: &Event,
|
||||||
cover_notes: &'a [Event],
|
cover_notes: &'a [Event],
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ use nostr::prelude::*;
|
|||||||
|
|
||||||
use crate::RepoAddr;
|
use crate::RepoAddr;
|
||||||
|
|
||||||
/// Target of a `nostr://` clone URL (NIP-34 "Nostr Clone URL format").
|
/// Target of a `nostr://` clone URL, as defined by NIP-34.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum CloneTarget {
|
pub enum CloneTarget {
|
||||||
/// `nostr://<naddr1...>` — direct repository address.
|
/// `nostr://<naddr1...>` encodes a direct repository address.
|
||||||
Addr(RepoAddr),
|
Addr(RepoAddr),
|
||||||
/// `nostr://<npub|nip05>/[relay-hint/]<identifier>`
|
/// `nostr://<npub|nip05>/[relay-hint/]<identifier>`
|
||||||
UserRepo {
|
UserRepo {
|
||||||
|
|||||||
@@ -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::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
/// NIP-09 deletion requests and NIP-62 vanish requests, used to hide
|
/// NIP-09 deletion requests and NIP-62 vanish requests,
|
||||||
/// deleted events before they reach the UI.
|
/// built from the kind-5 and kind-62 events in the local database.
|
||||||
///
|
///
|
||||||
/// Built from the kind-5 / kind-62 events stored in the local database;
|
/// Deleted events are hidden before they reach the UI.
|
||||||
/// pass any event through [`Deletions::is_deleted`] before displaying it.
|
///
|
||||||
|
/// Pass any event through [`Deletions::is_deleted`] before showing it.
|
||||||
pub struct Deletions {
|
pub struct Deletions {
|
||||||
/// `(deleted event id, expected author)` from `e` tags of kind-5 events.
|
/// `(deleted event id, expected author)` from `e` tags of kind-5 events.
|
||||||
ids: HashSet<(EventId, PublicKey)>,
|
ids: HashSet<(EventId, PublicKey)>,
|
||||||
/// `(coordinate, expected author, cutoff)` from `a` tags of kind-5 events.
|
/// `(coordinate, expected author, cutoff)` from `a` tags of kind-5 events.
|
||||||
|
///
|
||||||
/// All versions of the addressable event up to `cutoff` are deleted.
|
/// All versions of the addressable event up to `cutoff` are deleted.
|
||||||
coords: Vec<(Coordinate, PublicKey, Timestamp)>,
|
coords: Vec<(Coordinate, PublicKey, Timestamp)>,
|
||||||
/// `(author, cutoff)` from kind-62 vanish requests.
|
/// `(author, cutoff)` from kind-62 vanish requests.
|
||||||
@@ -34,8 +36,8 @@ impl Deletions {
|
|||||||
.map(|c| (c, event.pubkey, event.created_at)),
|
.map(|c| (c, event.pubkey, event.created_at)),
|
||||||
);
|
);
|
||||||
} else if event.kind == Kind::RequestToVanish {
|
} else if event.kind == Kind::RequestToVanish {
|
||||||
// Client-side we can't verify which relay the request targeted,
|
// Client-side we can't verify which relay the request targeted.
|
||||||
// so any vanish request is honored for the author's events.
|
// Any vanish request is then honored for the author's events.
|
||||||
vanished.push((event.pubkey, event.created_at));
|
vanished.push((event.pubkey, event.created_at));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,10 +50,9 @@ impl Deletions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the event is covered by a valid deletion or vanish request.
|
/// Whether the event is covered by a valid deletion or vanish request.
|
||||||
|
/// A request is valid when its author matches the deleted event's author, per NIP-09.
|
||||||
///
|
///
|
||||||
/// A request is only valid when its author matches the deleted event's
|
/// Addressable events are deleted up to the request's `created_at`.
|
||||||
/// author (NIP-09); addressable events are deleted up to the request's
|
|
||||||
/// `created_at`.
|
|
||||||
pub fn is_deleted(&self, event: &Event) -> bool {
|
pub fn is_deleted(&self, event: &Event) -> bool {
|
||||||
if self
|
if self
|
||||||
.vanished
|
.vanished
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ pub fn announcement(addr: &RepoAddr) -> Filter {
|
|||||||
.identifier(addr.identifier.clone())
|
.identifier(addr.identifier.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Latest state event (refs / HEAD) for a repository.
|
/// Latest state event for a repository, carrying refs and HEAD.
|
||||||
pub fn state(addr: &RepoAddr) -> Filter {
|
pub fn state(addr: &RepoAddr) -> Filter {
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kind(Kind::RepoState)
|
.kind(Kind::RepoState)
|
||||||
@@ -33,18 +33,18 @@ pub fn state(addr: &RepoAddr) -> Filter {
|
|||||||
.identifier(addr.identifier.clone())
|
.identifier(addr.identifier.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All NIP-34 activity addressed to a repository (`#a` tag): issues, PRs,
|
/// All NIP-34 activity addressed to a repository via its `#a` tag.
|
||||||
/// patches, statuses and comments (kind 1111).
|
/// Covers issues, PRs, patches, statuses and kind-1111 comments.
|
||||||
///
|
/// The `a` tag is optional on status events per NIP-34.
|
||||||
/// Note: the `a` tag on status events is optional per NIP-34, so statuses
|
/// Statuses published without it are not matched here.
|
||||||
/// published without it won't be matched here.
|
|
||||||
pub fn activity(addr: &RepoAddr) -> Filter {
|
pub fn activity(addr: &RepoAddr) -> Filter {
|
||||||
Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr)
|
Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Status events (`1630..=1633`) referencing any of the given root events
|
/// Status events, kinds `1630..=1633`, referencing any of the given root events.
|
||||||
/// (`#e` tag). Batched: one filter covers all roots, so a negentropy sync
|
/// They are matched via the `#e` tag. One filter covers all roots.
|
||||||
/// reconciles them in a single session instead of one per root.
|
///
|
||||||
|
/// A negentropy sync reconciles them in a single session, not one per root.
|
||||||
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kinds([
|
.kinds([
|
||||||
@@ -56,33 +56,29 @@ pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
|||||||
.events(roots)
|
.events(roots)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cover notes (kind 1624) and NIP-32 label events (kind 1985) referencing
|
/// Cover notes and NIP-32 label events referencing any of the given root events.
|
||||||
/// any of the given root events (`#e` tag), fetched per root like comments
|
/// These are kinds 1624 and 1985, matched via the `#e` tag.
|
||||||
/// and statuses because they carry no repository `a` tag. Batched, like
|
///
|
||||||
/// [`statuses_for`].
|
/// Because they carry no repository `a` tag, they are fetched by root like comments.
|
||||||
|
///
|
||||||
|
/// Batched, like [`statuses_for`].
|
||||||
pub fn annotations_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
pub fn annotations_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kinds([crate::COVER_NOTE_KIND, Kind::Label])
|
.kinds([crate::COVER_NOTE_KIND, Kind::Label])
|
||||||
.events(roots)
|
.events(roots)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A user's grasp list (kind `10317`).
|
/// A user's grasp list, kind `10317`.
|
||||||
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kind(Kind::GitUserGraspList)
|
.kind(Kind::GitUserGraspList)
|
||||||
.author(public_key)
|
.author(public_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NIP-22 comments (kind `1111`) referencing any of the given root events
|
/// NIP-22 comments, kind `1111`, referencing any of the given root events.
|
||||||
/// (issues, patches, PRs).
|
/// The roots are issues, patches and PRs.
|
||||||
///
|
///
|
||||||
/// Comments carry no repository `a` tag, so they must be fetched by their
|
/// Returns two filters, since combining `#E` and `#e` would AND the conditions.
|
||||||
/// root reference. NIP-22 defines the uppercase `E` tag as the thread root
|
|
||||||
/// (used by ngit), but some clients (including Signed) use a lowercase `e`
|
|
||||||
/// tag, so both are matched.
|
|
||||||
///
|
|
||||||
/// Returns two filters because `#E` and `#e` conditions would be ANDed if
|
|
||||||
/// combined into one.
|
|
||||||
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
||||||
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
|
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
|
||||||
if roots.is_empty() {
|
if roots.is_empty() {
|
||||||
@@ -98,49 +94,38 @@ pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All repositories announced by an author.
|
/// All repository announcements, for global discovery.
|
||||||
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.
|
|
||||||
pub fn all_announcements() -> Filter {
|
pub fn all_announcements() -> Filter {
|
||||||
Filter::new().kind(Kind::GitRepoAnnouncement)
|
Filter::new().kind(Kind::GitRepoAnnouncement)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How far back deletion requests are fetched and stored.
|
/// How far back deletion requests are fetched and stored.
|
||||||
///
|
|
||||||
/// A deletion request can only target events created before it, and NIP-34
|
|
||||||
/// events are all far younger than this window, so older requests can never
|
|
||||||
/// match anything shown. Bounding the window keeps the kind-5/62 set (one of
|
|
||||||
/// the largest on public relays) from being fully reconciled on every sync.
|
|
||||||
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
||||||
|
|
||||||
/// `now` minus [`DELETIONS_LOOKBACK`], quantized to whole days so identical
|
/// `now` minus [`DELETIONS_LOOKBACK`].
|
||||||
/// filters hash the same and the backend's sync dedup can match them.
|
/// Quantized to whole days so identical filters hash the same.
|
||||||
|
///
|
||||||
|
/// This lets the backend's sync dedup match identical filters.
|
||||||
fn deletions_since() -> Timestamp {
|
fn deletions_since() -> Timestamp {
|
||||||
let now = Timestamp::now().as_secs();
|
let now = Timestamp::now().as_secs();
|
||||||
Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK
|
Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All deletion-related events (NIP-09 kind `5`, NIP-62 kind `62`) within
|
/// All deletion-related events within [`DELETIONS_LOOKBACK`].
|
||||||
/// [`DELETIONS_LOOKBACK`]. Deletion requests must be known before any other
|
/// These are NIP-09 kind `5` and NIP-62 kind `62`.
|
||||||
/// event can be shown.
|
///
|
||||||
|
/// Deletion requests must be known before any other event is shown.
|
||||||
pub fn deletions() -> Filter {
|
pub fn deletions() -> Filter {
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||||
.since(deletions_since())
|
.since(deletions_since())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletion events relevant to a single repository: requests authored by
|
/// Deletion events relevant to a single repository.
|
||||||
/// the repository owner and requests addressed to the repository
|
///
|
||||||
/// coordinate (`#a` tag).
|
/// Requests authored by the repository owner.
|
||||||
|
///
|
||||||
|
/// Requests addressed to the repository coordinate via its `#a` tag.
|
||||||
pub fn deletions_for_repo(addr: &RepoAddr) -> Vec<Filter> {
|
pub fn deletions_for_repo(addr: &RepoAddr) -> Vec<Filter> {
|
||||||
vec![
|
vec![
|
||||||
Filter::new()
|
Filter::new()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
pub mod addr;
|
pub mod addr;
|
||||||
pub mod annotations;
|
pub mod annotations;
|
||||||
pub mod clone_url;
|
pub mod clone_url;
|
||||||
pub mod comments;
|
|
||||||
pub mod deletions;
|
pub mod deletions;
|
||||||
pub mod filters;
|
pub mod filters;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
@@ -11,7 +10,6 @@ pub mod status;
|
|||||||
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
||||||
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
|
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
|
||||||
pub use clone_url::{CloneTarget, parse_clone_url};
|
pub use clone_url::{CloneTarget, parse_clone_url};
|
||||||
pub use comments::{CommentThread, comment_threads};
|
|
||||||
pub use deletions::Deletions;
|
pub use deletions::Deletions;
|
||||||
pub use model::{Announcement, activity_subject, pull_request_patch, pull_request_patches};
|
pub use model::{Announcement, activity_subject, pull_request_patch, pull_request_patches};
|
||||||
pub use state::{build_state, parse_state};
|
pub use state::{build_state, parse_state};
|
||||||
|
|||||||
+159
-83
@@ -1,59 +1,52 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use gpui::SharedString;
|
|
||||||
use nostr::prelude::*;
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Announcement {
|
pub struct Announcement {
|
||||||
/// ID of the announcement event itself.
|
/// ID of the announcement event itself.
|
||||||
pub event_id: EventId,
|
pub event_id: EventId,
|
||||||
/// Repository ID (`d` tag).
|
/// Repository ID, the `d` tag.
|
||||||
pub id: String,
|
pub id: String,
|
||||||
/// Author of the announcement event.
|
/// Author of the announcement event.
|
||||||
pub owner: PublicKey,
|
pub owner: PublicKey,
|
||||||
/// When the announcement was published (for latest-wins resolution).
|
/// When the announcement was published, used for latest-wins resolution.
|
||||||
pub created_at: Timestamp,
|
pub created_at: Timestamp,
|
||||||
pub name: Option<SharedString>,
|
pub name: Option<String>,
|
||||||
pub description: Option<SharedString>,
|
pub description: Option<String>,
|
||||||
/// Webpage URLs for browsing.
|
/// Webpage URLs for browsing.
|
||||||
pub web: Vec<Url>,
|
pub web: Vec<Url>,
|
||||||
/// URLs for `git clone`.
|
/// URLs for `git clone`.
|
||||||
pub clone: Vec<Url>,
|
pub clone: Vec<Url>,
|
||||||
/// Relays the repository monitors for patches and issues.
|
/// Relays the repository monitors for patches and issues.
|
||||||
pub relays: Vec<RelayUrl>,
|
pub relays: Vec<RelayUrl>,
|
||||||
/// Earliest unique commit ID (`r` tag with `euc` marker).
|
/// Earliest unique commit ID, the `r` tag with `euc` marker.
|
||||||
pub euc: Option<String>,
|
pub euc: Option<String>,
|
||||||
/// Other recognized maintainers.
|
/// Other recognized maintainers.
|
||||||
pub maintainers: Vec<PublicKey>,
|
pub maintainers: Vec<PublicKey>,
|
||||||
/// Value of a `u` tag, if any: this repository is a subordinate fork of
|
/// Marks the repository as a subordinate fork of the upstream, per NIP-34.
|
||||||
/// the referenced upstream (NIP-34).
|
|
||||||
pub upstream: Option<Upstream>,
|
pub upstream: Option<Upstream>,
|
||||||
/// Hashtags labelling the repository (`t` tags).
|
/// Hashtags labelling the repository, the `t` tags.
|
||||||
pub hashtags: Vec<String>,
|
pub hashtags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `u` tag of a fork announcement (NIP-34)
|
/// The `u` tag of a fork announcement, per NIP-34.
|
||||||
/// the repository this one is a subordinate fork of. The first value is
|
|
||||||
/// the upstream coordinate (`30617:<pubkey>:<id>`) or a git URL.
|
|
||||||
/// The second is an optional relay hint for the upstream.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Upstream {
|
pub struct Upstream {
|
||||||
/// Raw first value of the `u` tag (coordinate or git URL).
|
/// Raw first value of the `u` tag, a coordinate or git URL.
|
||||||
pub raw: String,
|
pub raw: String,
|
||||||
/// The upstream `30617:<pubkey>:<id>` coordinate, when the `u` tag
|
/// Upstream repository coordinate when the `u` tag names a NIP-34 repository.
|
||||||
/// references a NIP-34 repository; `None` for the git-URL form.
|
/// `None` for the git-URL form.
|
||||||
pub addr: Option<RepoAddr>,
|
pub addr: Option<RepoAddr>,
|
||||||
/// Relay hint for the upstream, if the `u` tag carries one.
|
/// Relay hint for the upstream, if the `u` tag carries one.
|
||||||
pub relay_hint: Option<RelayUrl>,
|
pub relay_hint: Option<RelayUrl>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Upstream {
|
impl Upstream {
|
||||||
/// Parse the `u` tag values. The first is the upstream coordinate or a
|
/// Parse the `u` tag values.
|
||||||
/// git URL (the coordinate form may append `|git-url`; the coordinate is
|
|
||||||
/// the part before the first `|`), the second an optional relay hint.
|
|
||||||
fn parse(raw: &str, relay_hint: Option<&str>) -> Self {
|
fn parse(raw: &str, relay_hint: Option<&str>) -> Self {
|
||||||
let coordinate = raw.split('|').next().unwrap_or(raw);
|
let coordinate = raw.split('|').next().unwrap_or(raw);
|
||||||
let addr = coordinate
|
let addr = coordinate
|
||||||
@@ -67,19 +60,18 @@ impl Upstream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Text for display: the upstream coordinate when it is a NIP-34
|
/// Text for display.
|
||||||
/// repository, otherwise the raw `u` value (git-URL form).
|
pub fn display(&self) -> String {
|
||||||
pub fn display(&self) -> SharedString {
|
|
||||||
match &self.addr {
|
match &self.addr {
|
||||||
Some(addr) => SharedString::from(addr.to_string()),
|
Some(addr) => addr.to_string(),
|
||||||
None => SharedString::from(self.raw.clone()),
|
None => self.raw.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subject of a NIP-34 issue or pull request event: the `subject` tag,
|
/// Subject of a NIP-34 issue or pull request event.
|
||||||
/// falling back to the first non-empty line of the content.
|
/// Taken from the `subject` tag, else the first non-empty line of the content.
|
||||||
pub fn activity_subject(event: &Event) -> SharedString {
|
pub fn activity_subject(event: &Event) -> String {
|
||||||
let subject = event
|
let subject = event
|
||||||
.tags
|
.tags
|
||||||
.iter()
|
.iter()
|
||||||
@@ -89,24 +81,18 @@ pub fn activity_subject(event: &Event) -> SharedString {
|
|||||||
});
|
});
|
||||||
|
|
||||||
subject
|
subject
|
||||||
.map(SharedString::from)
|
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
event
|
event
|
||||||
.content
|
.content
|
||||||
.lines()
|
.lines()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.find(|line| !line.is_empty())
|
.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
|
/// The patch set of a pull request.
|
||||||
/// PR references via its `e` tag, plus every patch of the set chained to it
|
|
||||||
/// with NIP-10 `e` reply tags, in series order (oldest first). When the PR
|
|
||||||
/// has no `e` tag, falls back to the patch producing the PR's tip commit
|
|
||||||
/// (its `commit`/`r` tag, per NIP-34) and walks the reply chain backward to
|
|
||||||
/// the root.
|
|
||||||
///
|
///
|
||||||
/// Returns an empty list when no patch event can be linked to the PR.
|
/// Returns an empty list when no patch event can be linked to the PR.
|
||||||
pub fn pull_request_patches<'a>(
|
pub fn pull_request_patches<'a>(
|
||||||
@@ -115,17 +101,18 @@ pub fn pull_request_patches<'a>(
|
|||||||
) -> Vec<&'a Event> {
|
) -> Vec<&'a Event> {
|
||||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||||
|
|
||||||
// The PR references its root patch via an `e` tag; follow the NIP-10
|
// The PR references its root patch via an `e` tag.
|
||||||
// reply chain forward from there (each patch of the set replies to the
|
// Follow the NIP-10 reply chain forward from there.
|
||||||
// previous one). Among several replies (a revision), the newest wins.
|
// Each patch replies to the previous one, and among several replies the newest wins.
|
||||||
if let Some(root_id) = pr.tags.event_ids().next()
|
if let Some(root_id) = pr.tags.event_ids().next()
|
||||||
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
|
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
|
||||||
{
|
{
|
||||||
return forward_series(root, &patches);
|
return forward_series(root, &patches);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No `e` tag: the last patch of the set carries the PR's tip commit in
|
// The PR has no `e` tag.
|
||||||
// its `commit`/`r` tag; walk the reply chain backward to the root.
|
// The last patch of the set carries the tip commit in its `commit` or `r` tag.
|
||||||
|
// Walk the reply chain backward to the root.
|
||||||
let Some(tip) = current_commit_of(pr) else {
|
let Some(tip) = current_commit_of(pr) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
@@ -156,10 +143,7 @@ pub fn pull_request_patches<'a>(
|
|||||||
series
|
series
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The patch content of a pull request: the contents of every patch event of
|
/// The patch content of a pull request.
|
||||||
/// its patch set (see [`pull_request_patches`]) joined in series order,
|
|
||||||
/// falling back to the PR's own content for older PRs that carried the
|
|
||||||
/// patch inline.
|
|
||||||
pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a Event>) -> String {
|
pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a Event>) -> String {
|
||||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||||
let series = pull_request_patches(pr, patches.iter().copied());
|
let series = pull_request_patches(pr, patches.iter().copied());
|
||||||
@@ -173,7 +157,7 @@ pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a
|
|||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The chain of patches replying to `root` (NIP-10 `e` tags), oldest first.
|
/// The chain of patches replying to `root` via NIP-10 `e` tags, oldest first.
|
||||||
fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
|
fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
|
||||||
let mut series = vec![root];
|
let mut series = vec![root];
|
||||||
loop {
|
loop {
|
||||||
@@ -195,7 +179,7 @@ fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event>
|
|||||||
series
|
series
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `c` tag of an event (tip of the proposed branch), as hex.
|
/// The `c` tag of an event, the tip of the proposed branch, as hex.
|
||||||
fn current_commit_of(event: &Event) -> Option<String> {
|
fn current_commit_of(event: &Event) -> Option<String> {
|
||||||
event
|
event
|
||||||
.tags
|
.tags
|
||||||
@@ -206,8 +190,9 @@ fn current_commit_of(event: &Event) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `patch` produces `commit` (its `commit` or `r` tag), so clients
|
/// Whether `patch` produces `commit`, found via its `commit` or `r` tag.
|
||||||
/// can find existing patches for a specific commit.
|
///
|
||||||
|
/// It lets clients find existing patches for a specific commit.
|
||||||
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
||||||
patch
|
patch
|
||||||
.tags
|
.tags
|
||||||
@@ -219,7 +204,9 @@ fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Announcement {
|
impl Announcement {
|
||||||
/// Parse a kind `30617` event. Returns `None` if the kind is wrong or the `d` tag is missing.
|
/// Parse a kind `30617` event.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the kind is wrong or the `d` tag is missing.
|
||||||
pub fn from_event(event: &Event) -> Option<Self> {
|
pub fn from_event(event: &Event) -> Option<Self> {
|
||||||
if event.kind != Kind::GitRepoAnnouncement {
|
if event.kind != Kind::GitRepoAnnouncement {
|
||||||
return None;
|
return None;
|
||||||
@@ -230,8 +217,8 @@ impl Announcement {
|
|||||||
let mut hashtags: Vec<String> = Vec::new();
|
let mut hashtags: Vec<String> = Vec::new();
|
||||||
hashtags.extend(event.tags.hashtags().map(|t| t.to_string()));
|
hashtags.extend(event.tags.hashtags().map(|t| t.to_string()));
|
||||||
|
|
||||||
let mut name: Option<SharedString> = None;
|
let mut name: Option<String> = None;
|
||||||
let mut description: Option<SharedString> = None;
|
let mut description: Option<String> = None;
|
||||||
let mut web: Vec<Url> = Vec::new();
|
let mut web: Vec<Url> = Vec::new();
|
||||||
let mut clone: Vec<Url> = Vec::new();
|
let mut clone: Vec<Url> = Vec::new();
|
||||||
let mut relays: Vec<RelayUrl> = Vec::new();
|
let mut relays: Vec<RelayUrl> = Vec::new();
|
||||||
@@ -241,8 +228,8 @@ impl Announcement {
|
|||||||
|
|
||||||
for tag in event.tags.iter() {
|
for tag in event.tags.iter() {
|
||||||
match Nip34Tag::parse(tag.as_slice()) {
|
match Nip34Tag::parse(tag.as_slice()) {
|
||||||
Ok(Nip34Tag::Name(value)) => name = Some(value.into()),
|
Ok(Nip34Tag::Name(value)) => name = Some(value),
|
||||||
Ok(Nip34Tag::Description(value)) => description = Some(value.into()),
|
Ok(Nip34Tag::Description(value)) => description = Some(value),
|
||||||
Ok(Nip34Tag::Web(urls)) => web.extend(urls),
|
Ok(Nip34Tag::Web(urls)) => web.extend(urls),
|
||||||
Ok(Nip34Tag::Clone(urls)) => clone.extend(urls),
|
Ok(Nip34Tag::Clone(urls)) => clone.extend(urls),
|
||||||
Ok(Nip34Tag::Relays(urls)) => relays.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
|
// The SDK's `Nip34Tag` does not model the `u` tag, so parse it manually.
|
||||||
// manually (first wins).
|
// Only the first `u` tag is used.
|
||||||
if upstream.is_none() && tag.kind() == "u" {
|
if upstream.is_none() && tag.kind() == "u" {
|
||||||
let values = tag.as_slice();
|
let values = tag.as_slice();
|
||||||
let raw = values.get(1).map(String::as_str).unwrap_or_default();
|
let raw = values.get(1).map(String::as_str).unwrap_or_default();
|
||||||
@@ -280,21 +267,40 @@ impl Announcement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The repository address of this announcement.
|
/// The repository address of this announcement.
|
||||||
pub fn addr(&self) -> crate::RepoAddr {
|
pub fn addr(&self) -> RepoAddr {
|
||||||
crate::repo_addr(self.owner, self.id.clone())
|
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.
|
/// The description of the repository, or a default if none is provided.
|
||||||
pub fn description(&self) -> SharedString {
|
pub fn description(&self) -> String {
|
||||||
self.description
|
self.description
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or(SharedString::from("No description"))
|
.unwrap_or("No description".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The effective maintainers of this repository: the announced
|
/// The effective maintainers of this repository,
|
||||||
/// `maintainers` plus the announcement author, who asserts themselves as
|
/// the announced `maintainers` plus the announcement author.
|
||||||
/// a maintainer of the primary project unless a `u` tag marks this
|
///
|
||||||
/// repository as a subordinate fork (NIP-34).
|
/// A `u` tag that marks the repository as a subordinate fork excludes them, per NIP-34.
|
||||||
pub fn effective_maintainers(&self) -> Vec<PublicKey> {
|
pub fn effective_maintainers(&self) -> Vec<PublicKey> {
|
||||||
let mut maintainers = self.maintainers.clone();
|
let mut maintainers = self.maintainers.clone();
|
||||||
if self.upstream.is_none() && !maintainers.contains(&self.owner) {
|
if self.upstream.is_none() && !maintainers.contains(&self.owner) {
|
||||||
@@ -303,13 +309,12 @@ impl Announcement {
|
|||||||
maintainers
|
maintainers
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `git clone` URLs for this repository, deduplicated while
|
/// The `git clone` URLs for this repository, deduplicated.
|
||||||
/// preserving the announced order (deterministic across calls).
|
pub fn clone_urls(&self) -> Vec<String> {
|
||||||
pub fn clone_urls(&self) -> Vec<SharedString> {
|
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
self.clone
|
self.clone
|
||||||
.iter()
|
.iter()
|
||||||
.map(|url| SharedString::from(format!("git clone {url}")))
|
.map(|url| format!("git clone {url}"))
|
||||||
.filter(|command| seen.insert(command.clone()))
|
.filter(|command| seen.insert(command.clone()))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -449,8 +454,8 @@ mod tests {
|
|||||||
let announcement = Announcement::from_event(&event).expect("parses");
|
let announcement = Announcement::from_event(&event).expect("parses");
|
||||||
let upstream = announcement.upstream.expect("parses the u tag");
|
let upstream = announcement.upstream.expect("parses the u tag");
|
||||||
|
|
||||||
// The coordinate part resolves to a repository address; the raw
|
// The coordinate part resolves to a repository address.
|
||||||
// value keeps the `|git-url` suffix.
|
// The raw value keeps the `|git-url` suffix.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
upstream.addr,
|
upstream.addr,
|
||||||
Some(crate::repo_addr(
|
Some(crate::repo_addr(
|
||||||
@@ -474,8 +479,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_git_url_upstream() {
|
fn parses_git_url_upstream() {
|
||||||
// The `u` tag may reference a non-nostr upstream by git URL only;
|
// The `u` tag may reference a non-nostr upstream by git URL only.
|
||||||
// there is no repository address to navigate to.
|
// There is no repository address to navigate to.
|
||||||
let event = announcement_event(&[
|
let event = announcement_event(&[
|
||||||
&["d", "my-fork"],
|
&["d", "my-fork"],
|
||||||
&["u", "https://example.com/upstream.git"],
|
&["u", "https://example.com/upstream.git"],
|
||||||
@@ -491,6 +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]
|
#[test]
|
||||||
fn effective_maintainers_include_owner_for_primary_repos() {
|
fn effective_maintainers_include_owner_for_primary_repos() {
|
||||||
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]);
|
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]);
|
||||||
@@ -498,8 +574,8 @@ mod tests {
|
|||||||
let announcement = Announcement::from_event(&event).expect("parses");
|
let announcement = Announcement::from_event(&event).expect("parses");
|
||||||
let maintainers = announcement.effective_maintainers();
|
let maintainers = announcement.effective_maintainers();
|
||||||
|
|
||||||
// The owner asserts themselves as a maintainer of the primary
|
// The owner asserts themselves as a maintainer of the primary project, per NIP-34.
|
||||||
// project (NIP-34), alongside the announced co-maintainers.
|
// Announced co-maintainers are included too.
|
||||||
assert_eq!(maintainers.len(), 2);
|
assert_eq!(maintainers.len(), 2);
|
||||||
assert!(maintainers.contains(&announcement.owner));
|
assert!(maintainers.contains(&announcement.owner));
|
||||||
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")));
|
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")));
|
||||||
@@ -516,8 +592,8 @@ mod tests {
|
|||||||
let announcement = Announcement::from_event(&event).expect("parses");
|
let announcement = Announcement::from_event(&event).expect("parses");
|
||||||
let maintainers = announcement.effective_maintainers();
|
let maintainers = announcement.effective_maintainers();
|
||||||
|
|
||||||
// A `u` tag marks the repository as a subordinate fork: the author
|
// A `u` tag marks the repository as a subordinate fork.
|
||||||
// is not a maintainer of the primary project (NIP-34).
|
// The author is then not a maintainer of the primary project, per NIP-34.
|
||||||
assert!(!maintainers.contains(&announcement.owner));
|
assert!(!maintainers.contains(&announcement.owner));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
maintainers,
|
maintainers,
|
||||||
@@ -545,7 +621,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pull_request_patch_falls_back_to_inline_content() {
|
fn pull_request_patch_falls_back_to_inline_content() {
|
||||||
// Older PRs carried the patch in the content; no linked patch event.
|
// Older PRs carried the patch in the content and link no patch event.
|
||||||
let pr = pr_event("patch-inline", vec![]);
|
let pr = pr_event("patch-inline", vec![]);
|
||||||
|
|
||||||
assert_eq!(pull_request_patch(&pr, [] as [&Event; 0]), "patch-inline");
|
assert_eq!(pull_request_patch(&pr, [] as [&Event; 0]), "patch-inline");
|
||||||
@@ -572,8 +648,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pull_request_patch_joins_the_whole_patch_set() {
|
fn pull_request_patch_joins_the_whole_patch_set() {
|
||||||
// NIP-34: a PR references the root patch; later patches of the set
|
// A PR references the root patch, per NIP-34.
|
||||||
// reply to the previous one (NIP-10 `e` tags).
|
// Later patches of the set reply to the previous one via NIP-10 `e` tags.
|
||||||
let root = patch_event("patch-one", vec![], 100);
|
let root = patch_event("patch-one", vec![], 100);
|
||||||
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
||||||
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
||||||
@@ -625,8 +701,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
|
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
|
||||||
// PRs without an `e` tag: the last patch of the set carries the tip
|
// PRs without an `e` tag fall back to the patch producing the tip commit.
|
||||||
// commit in its `r` tag; walk the reply chain backward to the root.
|
// Walk the reply chain backward to the root.
|
||||||
let root = patch_event("patch-one", vec![], 100);
|
let root = patch_event("patch-one", vec![], 100);
|
||||||
let tip = "1111111111111111111111111111111111111111";
|
let tip = "1111111111111111111111111111111111111111";
|
||||||
let last = patch_event(
|
let last = patch_event(
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
/// Build a kind `30618` repository state event from refs and HEAD.
|
/// Build a kind `30618` repository state event from refs and HEAD,
|
||||||
|
/// it is published as `ref: refs/heads/<branch>`.
|
||||||
///
|
///
|
||||||
/// `refs` are `(refname, commit-id)` pairs (e.g. `refs/heads/main`); `head`
|
/// The `d` tag matches the repository id.
|
||||||
/// is the short branch name HEAD points to, published as
|
|
||||||
/// `ref: refs/heads/<branch>`. The `d` tag matches the repository id.
|
|
||||||
pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
|
pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
|
||||||
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
|
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
|
||||||
for (name, commit) in refs {
|
for (name, commit) in refs {
|
||||||
@@ -20,8 +19,8 @@ pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> E
|
|||||||
|
|
||||||
/// Parse a kind `30618` repository state event into refs and HEAD.
|
/// Parse a kind `30618` repository state event into refs and HEAD.
|
||||||
///
|
///
|
||||||
/// `refs` are `(refname, commit-id)` pairs; `head` is the branch pointed to
|
/// `refs` are `(refname, commit-id)` pairs.
|
||||||
/// by the `HEAD` tag, if any.
|
/// `head` is the branch pointed to by the `HEAD` tag, if any.
|
||||||
pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option<String>) {
|
pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option<String>) {
|
||||||
let mut refs = Vec::new();
|
let mut refs = Vec::new();
|
||||||
let mut head = None;
|
let mut head = None;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
/// Status of a root patch, pull request or issue (kinds `1630..=1633`).
|
/// Status of a root patch, pull request or issue, kinds `1630..=1633`.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum RepoStatus {
|
pub enum RepoStatus {
|
||||||
Open,
|
Open,
|
||||||
@@ -30,9 +30,9 @@ impl RepoStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check whether an event references the given root event via an `e` or `E`
|
/// NIP-10 and NIP-34 use the lowercase `e` tag.
|
||||||
/// tag. NIP-10 / NIP-34 use the lowercase `e` tag; NIP-22 comments (kind
|
///
|
||||||
/// `1111`) use the uppercase `E` tag for the root of the thread.
|
/// NIP-22 comments, kind `1111`, use the uppercase `E` tag for the thread root.
|
||||||
pub fn references_root(event: &Event, root: &EventId) -> bool {
|
pub fn references_root(event: &Event, root: &EventId) -> bool {
|
||||||
let root = root.to_hex();
|
let root = root.to_hex();
|
||||||
event
|
event
|
||||||
@@ -41,8 +41,8 @@ pub fn references_root(event: &Event, root: &EventId) -> bool {
|
|||||||
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
|
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the status of a root event per NIP-34:
|
/// Resolve the status of a root event per NIP-34.
|
||||||
/// the most recent status event from the root author or a maintainer wins.
|
///
|
||||||
/// Defaults to [`RepoStatus::Open`].
|
/// Defaults to [`RepoStatus::Open`].
|
||||||
pub fn resolve_status<'a, I>(
|
pub fn resolve_status<'a, I>(
|
||||||
status_events: I,
|
status_events: I,
|
||||||
|
|||||||
+803
-468
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,6 @@ edition.workspace = true
|
|||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
signed_core = { path = "../signed_core" }
|
|
||||||
|
|
||||||
nostr.workspace = true
|
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
nostr-connect.workspace = true
|
nostr-connect.workspace = true
|
||||||
nostr-gossip-memory.workspace = true
|
nostr-gossip-memory.workspace = true
|
||||||
|
|||||||
@@ -12,11 +12,6 @@ use nostr_sdk::prelude::*;
|
|||||||
|
|
||||||
use crate::signer::UniversalSigner;
|
use crate::signer::UniversalSigner;
|
||||||
|
|
||||||
/// Open (or create) the LMDB database at `db_path` and build a client
|
|
||||||
/// configured for Signed, together with a fresh signer.
|
|
||||||
///
|
|
||||||
/// The SDK manages its own internal tokio runtime.
|
|
||||||
/// the returned client can be driven by GPUI's executors.
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, UniversalSigner)> {
|
pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, UniversalSigner)> {
|
||||||
let signer = UniversalSigner::new(Keys::generate());
|
let signer = UniversalSigner::new(Keys::generate());
|
||||||
@@ -26,7 +21,7 @@ pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, Universal
|
|||||||
Ok(with_database(signer, database))
|
Ok(with_database(signer, database))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-memory database on wasm (no LMDB available).
|
/// In-memory database on wasm, LMDB is unavailable there.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub fn new_backend() -> Result<(Client, UniversalSigner)> {
|
pub fn new_backend() -> Result<(Client, UniversalSigner)> {
|
||||||
let signer = UniversalSigner::new(Keys::generate());
|
let signer = UniversalSigner::new(Keys::generate());
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ impl UniversalSignerError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A type-erased signer whose inner signer can be swapped in-place
|
/// A type-erased signer whose inner signer can be swapped in-place.
|
||||||
/// (e.g. after login/logout). All clones see the swap.
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct UniversalSigner {
|
pub struct UniversalSigner {
|
||||||
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
/// A lightweight "something changed" signal for the UI.
|
/// A lightweight change notification for the UI.
|
||||||
///
|
|
||||||
/// Heavy data stays in the database; consumers re-query on receipt.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Update {
|
pub struct Update {
|
||||||
pub kind: Kind,
|
pub kind: Kind,
|
||||||
/// First `a` tag value of the event, if any (e.g. the repository coordinate).
|
/// First `a` tag value of the event, if any, for example the repository coordinate.
|
||||||
pub coordinate: Option<Coordinate>,
|
pub coordinate: Option<Coordinate>,
|
||||||
pub author: PublicKey,
|
pub author: PublicKey,
|
||||||
pub event_id: EventId,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Update {
|
impl Update {
|
||||||
@@ -21,7 +18,6 @@ impl Update {
|
|||||||
kind: event.kind,
|
kind: event.kind,
|
||||||
coordinate,
|
coordinate,
|
||||||
author: event.pubkey,
|
author: event.pubkey,
|
||||||
event_id: event.id,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ publish.workspace = true
|
|||||||
signed_core = { path = "../signed_core" }
|
signed_core = { path = "../signed_core" }
|
||||||
signed_git = { path = "../signed_git" }
|
signed_git = { path = "../signed_git" }
|
||||||
signed_nostr = { path = "../signed_nostr" }
|
signed_nostr = { path = "../signed_nostr" }
|
||||||
|
settings = { path = "../settings" }
|
||||||
utils = { path = "../utils" }
|
utils = { path = "../utils" }
|
||||||
|
|
||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
@@ -24,3 +25,6 @@ log.workspace = true
|
|||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
rustls = "0.23"
|
rustls = "0.23"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
+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 {}
|
impl Global for GlobalGitStore {}
|
||||||
|
|
||||||
/// Global access to the on-disk git clone cache (grasp mirrors).
|
/// Global access to the on-disk git clone cache, the grasp mirrors.
|
||||||
///
|
|
||||||
/// Installed at startup via [`GitStore::set_global`]; see also
|
|
||||||
/// [`signed_state::init`].
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct GitStore(GitCache);
|
pub struct GitStore(GitCache);
|
||||||
|
|
||||||
impl GitStore {
|
impl GitStore {
|
||||||
/// Register the clone cache rooted at `root` as an app-wide global.
|
/// Register the clone cache rooted at `root` as an app-wide global.
|
||||||
/// Replaces any previously installed store (see [`signed_state::init`], which
|
|
||||||
/// installs an empty one).
|
|
||||||
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
|
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
|
||||||
let store = Self::new(root);
|
let store = Self::new(root);
|
||||||
cx.set_global(GlobalGitStore(store.0.clone()));
|
cx.set_global(GlobalGitStore(store.0.clone()));
|
||||||
@@ -25,10 +20,6 @@ impl GitStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The app-wide clone cache.
|
/// The app-wide clone cache.
|
||||||
///
|
|
||||||
/// # Panics
|
|
||||||
///
|
|
||||||
/// Panics if [`GitStore::set_global`] was never called.
|
|
||||||
pub fn global(cx: &App) -> Self {
|
pub fn global(cx: &App) -> Self {
|
||||||
Self(cx.global::<GlobalGitStore>().0.clone())
|
Self(cx.global::<GlobalGitStore>().0.clone())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
mod backend;
|
mod backend;
|
||||||
|
mod checkouts;
|
||||||
mod git_store;
|
mod git_store;
|
||||||
mod local_repos;
|
mod local_repos;
|
||||||
mod profile;
|
mod profile;
|
||||||
|
mod refresh;
|
||||||
mod repo;
|
mod repo;
|
||||||
mod repo_list;
|
mod repo_list;
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
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;
|
pub use git_store::GitStore;
|
||||||
use gpui::{App, AppContext, Entity};
|
use gpui::{App, AppContext, Entity};
|
||||||
pub use local_repos::LocalReposStore;
|
pub use local_repos::LocalReposStore;
|
||||||
@@ -16,16 +19,18 @@ pub use profile::{Profile, ProfileStore};
|
|||||||
pub use repo::RepoStore;
|
pub use repo::RepoStore;
|
||||||
pub use repo_list::{RepoActivityCounts, RepoListStore};
|
pub use repo_list::{RepoActivityCounts, RepoListStore};
|
||||||
use signed_nostr::new_backend;
|
use signed_nostr::new_backend;
|
||||||
pub use utils::shorten_pubkey;
|
|
||||||
|
|
||||||
/// Initialize the backend and stores, and install them as globals.
|
/// Initialize the backend and stores, and install them as globals.
|
||||||
/// Call once at startup, before opening any window that uses the stores.
|
/// Call once at startup, before opening any window that uses the stores.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -> Entity<Backend> {
|
pub fn init(
|
||||||
// rustls uses the `aws_lc_rs` provider by default; ignore if already installed.
|
db_path: impl AsRef<Path>,
|
||||||
rustls::crypto::aws_lc_rs::default_provider()
|
repos_root: impl Into<PathBuf>,
|
||||||
.install_default()
|
scan_paths: Vec<PathBuf>,
|
||||||
.ok();
|
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 (client, signer) = cx.foreground_executor().block_on(async move {
|
||||||
let path = db_path.as_ref().to_path_buf();
|
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));
|
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
||||||
Backend::set_global(entity.clone(), cx);
|
Backend::set_global(entity.clone(), cx);
|
||||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||||
|
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||||
// Seed the explore list from the local database; relay syncs continue
|
GitStore::set_global(repos_root, cx);
|
||||||
// in the background.
|
|
||||||
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
|
|
||||||
|
|
||||||
// The clone cache is native-only; wasm registers an empty store so
|
|
||||||
// `GitStore::global` still works.
|
|
||||||
GitStore::set_global(PathBuf::new(), cx);
|
|
||||||
|
|
||||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
||||||
|
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
||||||
|
|
||||||
entity
|
entity
|
||||||
}
|
}
|
||||||
@@ -55,13 +54,12 @@ pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -
|
|||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub fn init(cx: &mut App) -> Entity<Backend> {
|
pub fn init(cx: &mut App) -> Entity<Backend> {
|
||||||
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
|
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
|
||||||
|
|
||||||
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
||||||
Backend::set_global(entity.clone(), cx);
|
Backend::set_global(entity.clone(), cx);
|
||||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||||
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
|
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||||
GitStore::set_global(PathBuf::new(), cx);
|
GitStore::set_global(PathBuf::new(), cx);
|
||||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
|
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
|
||||||
|
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
|
||||||
entity
|
entity
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,9 +45,7 @@ impl LocalReposStore {
|
|||||||
store
|
store
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forget a repository that has just been published to NIP-34,
|
/// Forget a repository that has just been published to NIP-34.
|
||||||
/// so it leaves the local list immediately. A later rescan re-discovers it from disk,
|
|
||||||
/// the sidebar additionally hides published repositories by identifier.
|
|
||||||
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
|
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
|
||||||
self.repos = Arc::new(
|
self.repos = Arc::new(
|
||||||
self.repos
|
self.repos
|
||||||
@@ -95,8 +93,7 @@ impl LocalReposStore {
|
|||||||
dirty
|
dirty
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Scans requested while this one was running are coalesced into
|
// Scans requested while this one ran are coalesced into one follow-up scan.
|
||||||
// a single follow-up scan.
|
|
||||||
if again {
|
if again {
|
||||||
this.update(cx, |this, cx| this.rescan(cx))?;
|
this.update(cx, |this, cx| this.rescan(cx))?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use flume::{Receiver, RecvTimeoutError, Sender};
|
use flume::{Receiver, Sender};
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Task};
|
use gpui::{
|
||||||
|
App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, Task,
|
||||||
|
WeakEntity,
|
||||||
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use utils::shorten_pubkey;
|
use utils::shorten_pubkey;
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
|
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
|
||||||
|
|
||||||
/// A user profile (kind `0` metadata), as plain data for the UI.
|
/// A user profile as plain data for the UI, from the kind-0 metadata.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Profile {
|
pub struct Profile {
|
||||||
public_key: PublicKey,
|
public_key: PublicKey,
|
||||||
@@ -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.
|
/// How long to wait for more requests before firing a batched sync.
|
||||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
/// Global profile cache. Profiles are fetched in batches and kept as plain
|
/// Global profile cache.
|
||||||
/// data; the whole store notifies on change.
|
///
|
||||||
|
/// Profiles are fetched in batches and kept as plain data.
|
||||||
pub struct ProfileStore {
|
pub struct ProfileStore {
|
||||||
profiles: HashMap<PublicKey, Profile>,
|
profiles: HashMap<PublicKey, Profile>,
|
||||||
/// Public keys we've already requested this session (main thread only).
|
/// Public keys requested this session, main thread only.
|
||||||
seen: RefCell<HashSet<PublicKey>>,
|
seen: RefCell<HashSet<PublicKey>>,
|
||||||
/// Sender for queuing fetch requests, batched by a background task.
|
/// Sender for queuing fetch requests, batched by a background task.
|
||||||
sender: Sender<PublicKey>,
|
sender: Sender<PublicKey>,
|
||||||
@@ -111,24 +109,15 @@ impl ProfileStore {
|
|||||||
_ => {}
|
_ => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch requests are queued on a channel and synced in batches by a
|
// Fetch requests are queued on a channel, batched into one sync per debounce window.
|
||||||
// background task.
|
|
||||||
let client = backend.read(cx).client();
|
let client = backend.read(cx).client();
|
||||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||||
let (dispatch_tx, dispatch_rx) = flume::unbounded::<Dispatch>();
|
let entity = cx.entity().downgrade();
|
||||||
|
|
||||||
let mut tasks = Vec::new();
|
let mut tasks = Vec::new();
|
||||||
|
|
||||||
tasks.push(cx.background_spawn(async move {
|
tasks.push(cx.spawn(async move |_this, cx| {
|
||||||
Self::handle_requests(&client, &dispatch_tx, &receiver).await
|
Self::handle_requests(entity, &client, &receiver, cx).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(())
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let mut store = Self {
|
let mut store = Self {
|
||||||
@@ -143,8 +132,17 @@ impl ProfileStore {
|
|||||||
store
|
store
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a profile. Returns a placeholder (default metadata) and queues a
|
/// Track a spawned task, pruning finished tasks first.
|
||||||
/// fetch if the profile isn't cached yet.
|
///
|
||||||
|
/// 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 {
|
pub fn get(&self, public_key: &PublicKey) -> Profile {
|
||||||
if let Some(profile) = self.profiles.get(public_key) {
|
if let Some(profile) = self.profiles.get(public_key) {
|
||||||
return profile.clone();
|
return profile.clone();
|
||||||
@@ -170,7 +168,8 @@ impl ProfileStore {
|
|||||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
// Parse off the main thread; only plain profiles cross back.
|
// Parse off the main thread.
|
||||||
|
// Only plain profiles cross back.
|
||||||
let profiles: Vec<Profile> = events
|
let profiles: Vec<Profile> = events
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|event| {
|
.map(|event| {
|
||||||
@@ -182,7 +181,7 @@ impl ProfileStore {
|
|||||||
Ok::<_, Error>(profiles)
|
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?;
|
let profiles = work.await?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -205,7 +204,8 @@ impl ProfileStore {
|
|||||||
let filter = Filter::new().kind(Kind::Metadata).author(public_key);
|
let filter = Filter::new().kind(Kind::Metadata).author(public_key);
|
||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
// Parse off the main thread; only the profile crosses back.
|
// Parse off the main thread.
|
||||||
|
// Only the profile crosses back.
|
||||||
let profile = events
|
let profile = events
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.max_by_key(|e| e.created_at)
|
.max_by_key(|e| e.created_at)
|
||||||
@@ -217,7 +217,7 @@ impl ProfileStore {
|
|||||||
Ok::<_, Error>(profile)
|
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?;
|
let profile = work.await?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -231,8 +231,9 @@ impl ProfileStore {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-read the latest metadata of every requested author from the local
|
/// Re-read the latest metadata of every requested author from the local database.
|
||||||
/// database (used after a sync, which produces no NostrUpdate events).
|
///
|
||||||
|
/// Used after a sync, which produces no NostrUpdate events.
|
||||||
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
||||||
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
||||||
|
|
||||||
@@ -272,7 +273,7 @@ impl ProfileStore {
|
|||||||
Ok::<_, Error>(profiles)
|
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?;
|
let profiles = work.await?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -286,44 +287,57 @@ impl ProfileStore {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync metadata for requested authors in batches, debounced to collect
|
/// Sync metadata for requested authors in batches, debounced to collect requests.
|
||||||
/// requests. Runs on a background thread; results are dispatched to the
|
///
|
||||||
/// main thread, which re-reads the database.
|
/// After each batch, the seen profiles are re-read from the database on the main thread.
|
||||||
async fn handle_requests(
|
async fn handle_requests(
|
||||||
|
this: WeakEntity<ProfileStore>,
|
||||||
client: &Client,
|
client: &Client,
|
||||||
dispatch: &Sender<Dispatch>,
|
|
||||||
receiver: &Receiver<PublicKey>,
|
receiver: &Receiver<PublicKey>,
|
||||||
|
cx: &mut AsyncApp,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Wait for the first request of a batch.
|
// Wait for the first request of a batch.
|
||||||
match receiver.recv_timeout(BATCH_TIMEOUT) {
|
match receiver.recv_async().await {
|
||||||
Ok(public_key) => {
|
Ok(public_key) => {
|
||||||
batch.insert(public_key);
|
batch.insert(public_key);
|
||||||
}
|
}
|
||||||
Err(RecvTimeoutError::Disconnected) => return Ok(()),
|
Err(_) => return Ok(()),
|
||||||
Err(RecvTimeoutError::Timeout) => continue,
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Collect everything that arrives within the debounce window.
|
// 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;
|
let deadline = Instant::now() + BATCH_TIMEOUT;
|
||||||
while let Ok(public_key) = receiver.recv_deadline(deadline) {
|
loop {
|
||||||
batch.insert(public_key);
|
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()
|
let filter = Filter::new()
|
||||||
.kind(Kind::Metadata)
|
.kind(Kind::Metadata)
|
||||||
.authors(batch.drain().collect::<Vec<PublicKey>>());
|
.authors(batch.drain().collect::<Vec<PublicKey>>());
|
||||||
|
|
||||||
// Negentropy-sync with the bootstrap relays. Synced events are
|
// Negentropy-sync with the bootstrap relays.
|
||||||
// written to the database directly (no NostrUpdate), so re-apply
|
// Synced events are written to the database directly, no NostrUpdate.
|
||||||
// from the database afterwards.
|
// Re-apply from the database afterwards.
|
||||||
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
|
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
if dispatch.send(Dispatch::Synced).is_err() {
|
let _ = this.update(cx, |this, cx| this.apply_seen(cx));
|
||||||
log::warn!("profile dispatch channel closed, dropping sync result");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(e) => log::warn!("profile sync failed: {e}"),
|
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 signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent};
|
use crate::backend::{Backend, BackendEvent};
|
||||||
|
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||||
|
|
||||||
/// Delay between a refresh request and the actual re-query, so bursts of
|
/// Delay between a refresh request and the actual re-query.
|
||||||
/// events (e.g. sync progress ticks) collapse into one query.
|
///
|
||||||
|
/// Bursts of events, e.g. sync progress ticks, collapse into one query.
|
||||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
/// How far back activity events count toward a repository's last activity.
|
/// How far back activity events count toward a repository's last activity.
|
||||||
@@ -20,53 +22,45 @@ struct GlobalRepoListStore(Entity<RepoListStore>);
|
|||||||
|
|
||||||
impl Global for GlobalRepoListStore {}
|
impl Global for GlobalRepoListStore {}
|
||||||
|
|
||||||
/// Counts of NIP-34 activity events per repository, used to rank the
|
/// NIP-34 activity event counts per repository, ranking the explore list by popularity.
|
||||||
/// explore list by popularity. Each patch event is a pushed commit (or a
|
|
||||||
/// small series), the closest proxy for commit count in the event data.
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub struct RepoActivityCounts {
|
pub struct RepoActivityCounts {
|
||||||
/// Root `30611` issue events addressed to the repository.
|
/// Root `30611` issue events addressed to the repository.
|
||||||
pub issues: u32,
|
pub issues: u32,
|
||||||
/// Root `3063` pull request events addressed to the repository
|
/// Root `3063` pull request events addressed to the repository.
|
||||||
/// (updates to a PR are not new PRs and don't count).
|
///
|
||||||
|
/// PR updates are not new PRs and do not count.
|
||||||
pub pull_requests: u32,
|
pub pull_requests: u32,
|
||||||
/// `1617` patch events addressed to the repository.
|
/// `1617` patch events addressed to the repository.
|
||||||
pub commits: u32,
|
pub commits: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RepoActivityCounts {
|
impl RepoActivityCounts {
|
||||||
/// Total issues + pull requests + commits; the popularity ranking key.
|
/// Total issues, pull requests and commits, the popularity ranking key.
|
||||||
pub fn score(self) -> u32 {
|
pub fn score(self) -> u32 {
|
||||||
self.issues + self.pull_requests + self.commits
|
self.issues + self.pull_requests + self.commits
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store listing repository announcements (global discovery or per-author).
|
/// Store listing the discovered repository announcements, newest first.
|
||||||
///
|
|
||||||
/// The all-repos store (`author: None`) is created at startup by
|
|
||||||
/// [`crate::init`] and installed as a global, so the explore panel renders
|
|
||||||
/// what's in the local database without waiting for relays.
|
|
||||||
pub struct RepoListStore {
|
pub struct RepoListStore {
|
||||||
/// Shared so views can clone the list per frame without a deep copy.
|
/// Shared so views can clone the list per frame without a deep copy.
|
||||||
pub announcements: Arc<Vec<Announcement>>,
|
pub announcements: Arc<Vec<Announcement>>,
|
||||||
/// Latest known activity timestamp per repository
|
/// Latest known activity timestamp per repository.
|
||||||
/// (announcements, state updates, patches, PRs, issues, statuses).
|
/// Covers announcements, state updates, patches, PRs, issues and statuses.
|
||||||
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
|
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
|
||||||
/// Issues + pull requests + commits per repository, for the Popular
|
/// Issues, pull requests and commits per repository.
|
||||||
/// ranking of the explore list.
|
///
|
||||||
|
/// Used for the Popular ranking of the explore list.
|
||||||
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
|
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
|
||||||
author: Option<PublicKey>,
|
/// Refresh coalescing, see [`RefreshGate`].
|
||||||
refreshing: bool,
|
refresh: RefreshGate,
|
||||||
refresh_dirty: bool,
|
|
||||||
/// A refresh is waiting out [`REFRESH_DEBOUNCE`].
|
|
||||||
debouncing: bool,
|
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
_subscription: Subscription,
|
_subscription: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RepoListStore {
|
impl RepoListStore {
|
||||||
/// Retrieve the global explore store (all announcements, created at
|
/// Retrieve the global repository list store.
|
||||||
/// startup by [`crate::init`]).
|
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
cx.global::<GlobalRepoListStore>().0.clone()
|
cx.global::<GlobalRepoListStore>().0.clone()
|
||||||
}
|
}
|
||||||
@@ -75,35 +69,34 @@ impl RepoListStore {
|
|||||||
cx.set_global(GlobalRepoListStore(entity));
|
cx.set_global(GlobalRepoListStore(entity));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a store. If `author` is `None`, all announcements are listed.
|
/// Create the store listing all announcements.
|
||||||
pub fn new(author: Option<PublicKey>, cx: &mut Context<Self>) -> Self {
|
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
|
||||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||||
let relevant = match event {
|
let relevant = match event {
|
||||||
BackendEvent::NostrUpdate(update) => {
|
BackendEvent::NostrUpdate(update) => {
|
||||||
// Deletions may target anything we list; always refresh.
|
// Deletions may target anything we list, always refresh.
|
||||||
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
|
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
|
||||||
true
|
true
|
||||||
} else if filters::ACTIVITY_KINDS.contains(&update.kind) {
|
} else if filters::ACTIVITY_KINDS.contains(&update.kind) {
|
||||||
// Activity (patches, issues, ...) is addressed to repos via
|
// Activity events are addressed to repos via `a` tags.
|
||||||
// `a` tags, so its author isn't the repo owner; always refresh.
|
// Their author is not the repo owner, always refresh.
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
let is_announcement = update.kind == Kind::GitRepoAnnouncement;
|
let is_announcement = update.kind == Kind::GitRepoAnnouncement;
|
||||||
let is_repo_state = update.kind == Kind::RepoState;
|
let is_repo_state = update.kind == Kind::RepoState;
|
||||||
let tracked = is_announcement || is_repo_state;
|
is_announcement || is_repo_state
|
||||||
tracked && this.author.is_none_or(|a| a == update.author)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BackendEvent::Published(event) => {
|
BackendEvent::Published(event) => {
|
||||||
let announcement = event.kind == Kind::GitRepoAnnouncement
|
let announcement = event.kind == Kind::GitRepoAnnouncement;
|
||||||
&& this.author.is_none_or(|a| a == event.pubkey);
|
|
||||||
// Locally published deletions (e.g. deleting a repo)
|
// Locally published deletions are already in the local database.
|
||||||
// are already in the local database; refresh so they
|
// Refresh so they take effect immediately, like relay deletions.
|
||||||
// take effect immediately, like relay deletions.
|
|
||||||
let deletion =
|
let deletion =
|
||||||
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
|
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
|
||||||
|
|
||||||
announcement || deletion
|
announcement || deletion
|
||||||
}
|
}
|
||||||
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
|
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
|
||||||
@@ -119,105 +112,90 @@ impl RepoListStore {
|
|||||||
announcements: Arc::new(Vec::new()),
|
announcements: Arc::new(Vec::new()),
|
||||||
last_activity: Arc::new(HashMap::new()),
|
last_activity: Arc::new(HashMap::new()),
|
||||||
counts: Arc::new(HashMap::new()),
|
counts: Arc::new(HashMap::new()),
|
||||||
author,
|
refresh: RefreshGate::default(),
|
||||||
refreshing: false,
|
|
||||||
refresh_dirty: false,
|
|
||||||
debouncing: false,
|
|
||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
tasks: Vec::new(),
|
tasks: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
store.subscribe_remote(cx);
|
store.subscribe_remote(cx);
|
||||||
// Query the local database right away; the list never waits for the
|
// Query the local database right away.
|
||||||
// relay syncs started above to finish.
|
// The list never waits for the relay syncs started above to finish.
|
||||||
store.refresh_initial(cx);
|
store.refresh_initial(cx);
|
||||||
store
|
store
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scope the list to an author (or clear the scope with `None`).
|
/// The announcements of `user`, newest first.
|
||||||
pub fn set_author(&mut self, author: Option<PublicKey>, cx: &mut Context<Self>) {
|
pub fn announcements_of(&self, user: &PublicKey) -> Vec<Announcement> {
|
||||||
self.author = author;
|
self.announcements
|
||||||
self.subscribe_remote(cx);
|
.iter()
|
||||||
self.refresh(cx);
|
.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.
|
/// Negentropy-sync announcements with the bootstrap relays.
|
||||||
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let author = self.author;
|
|
||||||
|
|
||||||
backend.update(cx, |backend, cx| {
|
backend.update(cx, |backend, cx| {
|
||||||
let filter = match author {
|
backend.sync_bootstrap(filters::all_announcements(), cx);
|
||||||
Some(a) => filters::announcements_by(a),
|
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
|
||||||
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::deletions(), cx);
|
backend.sync_bootstrap(filters::deletions(), cx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One-shot initial load: query the local database immediately (no
|
/// One-shot initial load.
|
||||||
/// debounce), so stored announcements appear as soon as the app opens.
|
///
|
||||||
/// Only called from [`Self::new`], before any refresh can be pending.
|
/// Query the local database immediately, no debounce.
|
||||||
|
/// Stored announcements appear as soon as the app opens.
|
||||||
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
||||||
debug_assert!(!self.debouncing);
|
debug_assert!(!self.refresh.debouncing());
|
||||||
if self.refreshing {
|
if self.refresh.running() {
|
||||||
self.refresh_dirty = true;
|
self.refresh.request();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.run_refresh(cx);
|
self.run_refresh(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-query the local database. Latest announcement per repository wins.
|
/// Re-query the local database.
|
||||||
///
|
|
||||||
/// Debounced: a short delay collapses bursts of requests (e.g. sync
|
|
||||||
/// progress ticks), and requests that arrive while a query is running
|
|
||||||
/// are folded into one follow-up query. The query and processing run on
|
|
||||||
/// a background thread; only the results are applied on the main thread.
|
|
||||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.refreshing {
|
if self.refresh.request() != RefreshRequest::Schedule {
|
||||||
self.refresh_dirty = true;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if self.debouncing {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.debouncing = true;
|
|
||||||
|
|
||||||
let task = cx.spawn(async move |this, cx| {
|
let task = cx.spawn(async move |this, cx| {
|
||||||
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| this.run_refresh(cx))
|
||||||
this.debouncing = false;
|
|
||||||
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>) {
|
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
self.refreshing = true;
|
self.refresh.begin();
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let client = backend.read(cx).client();
|
let client = backend.read(cx).client();
|
||||||
let author = self.author;
|
|
||||||
|
|
||||||
let work = cx.background_spawn(async move {
|
let work = cx.background_spawn(async move {
|
||||||
let filter = match author {
|
let filter = filters::all_announcements();
|
||||||
Some(a) => filters::announcements_by(a),
|
|
||||||
None => filters::all_announcements(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
let deletion_events = client.database().query(filters::deletions()).await?;
|
let deletion_events = client.database().query(filters::deletions()).await?;
|
||||||
let deletions = Deletions::from_events(deletion_events);
|
let deletions = Deletions::from_events(deletion_events);
|
||||||
|
|
||||||
// Dedup and sort off the main thread; only the final list
|
// Dedup and sort off the main thread.
|
||||||
// crosses back into the entity.
|
// Only the final list crosses back into the entity.
|
||||||
let mut by_repo: HashMap<RepoAddr, Announcement> = HashMap::new();
|
let mut by_repo: HashMap<RepoAddr, Announcement> = HashMap::new();
|
||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
@@ -242,8 +220,9 @@ impl RepoListStore {
|
|||||||
let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
|
let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
|
||||||
announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at));
|
announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at));
|
||||||
|
|
||||||
// Last activity per repository: state updates plus all NIP-34
|
// Last activity per repository.
|
||||||
// activity events (patches, PRs, issues, statuses).
|
// State updates count, and all NIP-34 activity events.
|
||||||
|
// The activity events are patches, PRs, issues and statuses.
|
||||||
let mut last_activity: HashMap<RepoAddr, Timestamp> = announcements
|
let mut last_activity: HashMap<RepoAddr, Timestamp> = announcements
|
||||||
.iter()
|
.iter()
|
||||||
.map(|a| (a.addr(), a.created_at))
|
.map(|a| (a.addr(), a.created_at))
|
||||||
@@ -264,8 +243,8 @@ impl RepoListStore {
|
|||||||
*entry = (*entry).max(event.created_at);
|
*entry = (*entry).max(event.created_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bound the activity query to a recent window; older repos fall
|
// Bound the activity query to a recent window.
|
||||||
// back to their announcement / state timestamps.
|
// Older repos fall back to their announcement or state timestamps.
|
||||||
let activity_filter = Filter::new()
|
let activity_filter = Filter::new()
|
||||||
.kinds(filters::ACTIVITY_KINDS)
|
.kinds(filters::ACTIVITY_KINDS)
|
||||||
.since(Timestamp::now() - ACTIVITY_WINDOW);
|
.since(Timestamp::now() - ACTIVITY_WINDOW);
|
||||||
@@ -277,8 +256,8 @@ impl RepoListStore {
|
|||||||
if addr.kind != Kind::GitRepoAnnouncement {
|
if addr.kind != Kind::GitRepoAnnouncement {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Skip events for repos we don't list, so the map can't
|
// Skip events for repos we do not list.
|
||||||
// grow beyond the number of announcements.
|
// The map cannot grow beyond the number of announcements.
|
||||||
let Some(entry) = last_activity.get_mut(&addr) else {
|
let Some(entry) = last_activity.get_mut(&addr) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -286,9 +265,8 @@ impl RepoListStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Popularity counts per repository (issues, pull requests and
|
// Popularity counts per repository, issues, pull requests and patches.
|
||||||
// patches). Unbounded, unlike the windowed activity query
|
// Unbounded, unlike the windowed activity query above, so totals are exact.
|
||||||
// above, so totals are exact.
|
|
||||||
let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new();
|
let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new();
|
||||||
let count_filter =
|
let count_filter =
|
||||||
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
|
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
|
||||||
@@ -297,8 +275,8 @@ impl RepoListStore {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for addr in event.tags.coordinates() {
|
for addr in event.tags.coordinates() {
|
||||||
// Skip events for repos we don't list, so the map can't
|
// Skip events for repos we do not list.
|
||||||
// grow beyond the number of announcements.
|
// The map cannot grow beyond the number of announcements.
|
||||||
if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr)
|
if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -316,13 +294,13 @@ impl RepoListStore {
|
|||||||
Ok::<_, Error>((announcements, last_activity, counts))
|
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 {
|
let (announcements, last_activity, counts) = match work.await {
|
||||||
Ok(results) => results,
|
Ok(results) => results,
|
||||||
// Database errors are transient; keep the last list.
|
// Database errors are transient, keep the last list.
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return this.update(cx, |this, _cx| {
|
return this.update(cx, |this, _cx| {
|
||||||
this.refreshing = false;
|
this.refresh.abort();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -333,17 +311,11 @@ impl RepoListStore {
|
|||||||
this.counts = Arc::new(counts);
|
this.counts = Arc::new(counts);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
this.refreshing = false;
|
this.refresh.finish()
|
||||||
if this.refresh_dirty {
|
|
||||||
this.refresh_dirty = false;
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Requests that arrived while the refresh was running are
|
// Requests that arrived while the refresh was running.
|
||||||
// coalesced into one follow-up refresh.
|
// They are coalesced into one follow-up refresh.
|
||||||
if again {
|
if again {
|
||||||
this.update(cx, |this, cx| this.refresh(cx))?;
|
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ use gpui_component::clipboard::Clipboard;
|
|||||||
use gpui_component::menu::PopupMenuItem;
|
use gpui_component::menu::PopupMenuItem;
|
||||||
use gpui_component::{ActiveTheme, StyledExt, h_flex};
|
use gpui_component::{ActiveTheme, StyledExt, h_flex};
|
||||||
|
|
||||||
/// A muted command row with a copy button: the value in a mono-friendly,
|
/// A muted command row with a copy button.
|
||||||
/// truncated line, with a [`Clipboard`] button copying the full value.
|
pub fn copy_row<E, T>(copy_id: E, command: T, cx: &App) -> Div
|
||||||
pub fn copy_row<E>(copy_id: E, command: &SharedString, cx: &App) -> Div
|
|
||||||
where
|
where
|
||||||
E: Into<ElementId>,
|
E: Into<ElementId>,
|
||||||
|
T: Into<SharedString>,
|
||||||
{
|
{
|
||||||
|
let command = command.into();
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.h_8()
|
.h_8()
|
||||||
.w_full()
|
.w_full()
|
||||||
@@ -34,10 +36,7 @@ where
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of a copy menu: a small title above the compact label, with
|
/// One row of a copy menu, with a small title above the compact label.
|
||||||
/// a copy button that flips to a check while the value is on the clipboard.
|
|
||||||
/// Clicking the row copies and dismisses the menu; the copy button stops
|
|
||||||
/// propagation so the menu stays open. Both copy `copy`, never the label.
|
|
||||||
pub fn menu_copy_row(
|
pub fn menu_copy_row(
|
||||||
id: &'static str,
|
id: &'static str,
|
||||||
title: &'static str,
|
title: &'static str,
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ use gpui_base::{Button as BaseButton, Popover, Selectable, StyledExt};
|
|||||||
use gpui_component::menu::PopupMenu;
|
use gpui_component::menu::PopupMenu;
|
||||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex};
|
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex};
|
||||||
|
|
||||||
/// A split dropdown button built on `gpui_base::Popover`: an action element
|
/// A split dropdown button built on `gpui_base::Popover`.
|
||||||
/// with a separate caret trigger that opens a [`PopupMenu`].
|
/// An action element with a separate caret trigger that opens a [`PopupMenu`].
|
||||||
///
|
/// The action and the caret are caller-supplied elements, so the look stays in the app.
|
||||||
/// The action and the caret are caller-supplied elements, so the look stays
|
/// This component only owns the popover wiring.
|
||||||
/// in the application; this component only owns the popover wiring.
|
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct DropdownButton {
|
pub struct DropdownButton {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
@@ -38,15 +37,16 @@ impl DropdownButton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The action half of the button. It keeps its own icon, label, tooltip
|
/// The action half of the button.
|
||||||
/// and click handler.
|
/// It keeps its own icon, label, tooltip and click handler.
|
||||||
pub fn action(mut self, action: impl IntoElement + 'static) -> Self {
|
pub fn action(mut self, action: impl IntoElement + 'static) -> Self {
|
||||||
self.action = Some(action.into_any_element());
|
self.action = Some(action.into_any_element());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The menu built by `builder` — the same signature as gpui-component's
|
/// The menu built by `builder`.
|
||||||
/// `DropdownButton::dropdown_menu`, so existing menu code keeps working.
|
/// Matches gpui-component's `DropdownButton::dropdown_menu` signature.
|
||||||
|
/// Existing menu code keeps working.
|
||||||
pub fn dropdown_menu(
|
pub fn dropdown_menu(
|
||||||
mut self,
|
mut self,
|
||||||
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||||
@@ -55,10 +55,9 @@ impl DropdownButton {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which corner of the caret the menu anchors to. Defaults to
|
/// Which corner of the caret the menu anchors to.
|
||||||
/// [`Anchor::TopRight`], so the menu's right edge lines up with the
|
/// Defaults to [`Anchor::TopRight`], lining the menu's right edge up with the caret's.
|
||||||
/// caret's.
|
#[allow(dead_code)] // API knob, current call sites use the default anchor.
|
||||||
#[allow(dead_code)] // API knob; current call sites use the default anchor.
|
|
||||||
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
|
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
|
||||||
self.anchor = anchor.into();
|
self.anchor = anchor.into();
|
||||||
self
|
self
|
||||||
@@ -71,8 +70,8 @@ impl Styled for DropdownButton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Holds the [`PopupMenu`] entity of one popover between renders. Dismissal
|
/// Holds the [`PopupMenu`] entity of one popover between renders.
|
||||||
/// drops it, so the menu is rebuilt with fresh items on the next open.
|
/// Dismissal drops it, so the menu is rebuilt with fresh items on the next open.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct DropdownMenuState {
|
struct DropdownMenuState {
|
||||||
menu: Option<Entity<PopupMenu>>,
|
menu: Option<Entity<PopupMenu>>,
|
||||||
@@ -85,7 +84,8 @@ impl RenderOnce for DropdownButton {
|
|||||||
"a DropdownButton needs a `dropdown_menu`"
|
"a DropdownButton needs a `dropdown_menu`"
|
||||||
);
|
);
|
||||||
|
|
||||||
// The popover needs its own id: both the container and the popover register keyed state on this window.
|
// The popover needs its own id.
|
||||||
|
// The container and the popover both register keyed state on this window.
|
||||||
let popover_id = SharedString::from(format!("{}-popover", self.id));
|
let popover_id = SharedString::from(format!("{}-popover", self.id));
|
||||||
let anchor = self.anchor;
|
let anchor = self.anchor;
|
||||||
let menu_state =
|
let menu_state =
|
||||||
@@ -109,8 +109,8 @@ impl RenderOnce for DropdownButton {
|
|||||||
this.child(
|
this.child(
|
||||||
Popover::new(popover_id)
|
Popover::new(popover_id)
|
||||||
.anchor(anchor)
|
.anchor(anchor)
|
||||||
// The menu dismisses itself on outside click or Escape;
|
// The menu dismisses itself on outside click or Escape.
|
||||||
// the subscription below closes the popover along with it.
|
// The subscription below closes the popover along with it.
|
||||||
.overlay_closable(false)
|
.overlay_closable(false)
|
||||||
.trigger_with(caret)
|
.trigger_with(caret)
|
||||||
.content(
|
.content(
|
||||||
@@ -148,8 +148,8 @@ impl RenderOnce for DropdownButton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The default caret: a chevron button the height of a medium button, tinted
|
/// The default caret, a chevron button the height of a medium button.
|
||||||
/// by the theme, with hover and menu-open states.
|
/// It is tinted by the theme and styled for hover and menu-open states.
|
||||||
fn default_caret(id: impl Into<ElementId>, cx: &App) -> BaseButton {
|
fn default_caret(id: impl Into<ElementId>, cx: &App) -> BaseButton {
|
||||||
BaseButton::new(id)
|
BaseButton::new(id)
|
||||||
.h(px(32.))
|
.h(px(32.))
|
||||||
|
|||||||
@@ -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 dropdown_button;
|
||||||
mod nav_item;
|
mod nav_item;
|
||||||
mod pixel_avatar;
|
mod pixel_avatar;
|
||||||
@@ -40,12 +10,10 @@ mod tree_row;
|
|||||||
mod user_avatar;
|
mod user_avatar;
|
||||||
|
|
||||||
pub mod copy_row;
|
pub mod copy_row;
|
||||||
pub mod image_cache;
|
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
|
||||||
pub use copy_row::{copy_row, menu_copy_row};
|
pub use copy_row::{copy_row, menu_copy_row};
|
||||||
pub use dropdown_button::DropdownButton;
|
pub use dropdown_button::DropdownButton;
|
||||||
pub use image_cache::{MAX_IMAGES, image_cache};
|
|
||||||
pub use nav_item::NavItem;
|
pub use nav_item::NavItem;
|
||||||
pub use pixel_avatar::PixelAvatar;
|
pub use pixel_avatar::PixelAvatar;
|
||||||
pub use placeholder::placeholder;
|
pub use placeholder::placeholder;
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ use gpui::prelude::*;
|
|||||||
use gpui::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, div};
|
use gpui::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, div};
|
||||||
use gpui_component::{ActiveTheme, StyledExt, h_flex};
|
use gpui_component::{ActiveTheme, StyledExt, h_flex};
|
||||||
|
|
||||||
/// A single navigation entry in a sidebar: an arbitrary leading element
|
/// A single navigation entry in a sidebar.
|
||||||
/// (an icon, avatar, ...) and a text label with a hover highlight,
|
/// It has an arbitrary leading element, such as an icon or avatar, and a text label.
|
||||||
/// an optional trailing suffix (e.g. a status icon) and an optional click handler.
|
/// Hover highlights the row.
|
||||||
|
/// It can carry a trailing suffix, such as a status icon, and an optional click handler.
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct NavItem {
|
pub struct NavItem {
|
||||||
@@ -12,7 +13,7 @@ pub struct NavItem {
|
|||||||
style: StyleRefinement,
|
style: StyleRefinement,
|
||||||
icon: gpui::AnyElement,
|
icon: gpui::AnyElement,
|
||||||
label: SharedString,
|
label: SharedString,
|
||||||
/// Trailing element rendered at the right edge of the row, after the (ellipsized) label.
|
/// Trailing element at the right edge of the row, after the ellipsized label.
|
||||||
suffix: Option<gpui::AnyElement>,
|
suffix: Option<gpui::AnyElement>,
|
||||||
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,27 +9,30 @@ const GRID_SIZE: usize = 8;
|
|||||||
const FILL_PROBABILITY: f32 = 0.42;
|
const FILL_PROBABILITY: f32 = 0.42;
|
||||||
/// Probability that a filled cell uses the accent shade instead of the main color.
|
/// Probability that a filled cell uses the accent shade instead of the main color.
|
||||||
const ACCENT_PROBABILITY: f32 = 0.25;
|
const ACCENT_PROBABILITY: f32 = 0.25;
|
||||||
/// Minimum number of filled left-half cells, so a sparse roll still yields a
|
/// Minimum number of filled left-half cells.
|
||||||
/// recognizable shape (each left-half cell is mirrored to a right-half one).
|
/// A sparse roll still yields a recognizable shape.
|
||||||
|
/// Each left-half cell is mirrored to a right-half one.
|
||||||
const MIN_FILLED: usize = 5;
|
const MIN_FILLED: usize = 5;
|
||||||
|
|
||||||
/// A deterministic, offline "pixel art" avatar: an 8×8 grid with horizontal
|
/// Side length of the avatar in pixels, no setter.
|
||||||
/// mirror symmetry, seeded from a stable string such as the repository id and
|
const AVATAR_SIZE: Pixels = px(16.);
|
||||||
/// owner public key. The same seed always renders the same avatar.
|
|
||||||
|
/// A deterministic, offline pixel-art avatar.
|
||||||
|
/// An 8×8 grid with horizontal mirror symmetry.
|
||||||
|
/// Seeded from a stable string such as the repository id and owner public key.
|
||||||
|
/// The same seed always renders the same avatar.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct PixelAvatar {
|
pub struct PixelAvatar {
|
||||||
seed: u64,
|
seed: u64,
|
||||||
size: Pixels,
|
|
||||||
style: StyleRefinement,
|
style: StyleRefinement,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PixelAvatar {
|
impl PixelAvatar {
|
||||||
/// Create an avatar seeded from `seed`. The seed should be a stable string
|
/// Create an avatar seeded from `seed`.
|
||||||
/// unique to the entity the avatar represents.
|
/// The seed should be a stable string unique to the entity the avatar represents.
|
||||||
pub fn new(seed: impl AsRef<str>) -> Self {
|
pub fn new(seed: impl AsRef<str>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
seed: fnv1a(seed.as_ref().as_bytes()),
|
seed: fnv1a(seed.as_ref().as_bytes()),
|
||||||
size: px(16.),
|
|
||||||
style: StyleRefinement::default(),
|
style: StyleRefinement::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,7 +80,7 @@ impl RenderOnce for PixelAvatar {
|
|||||||
.grid()
|
.grid()
|
||||||
.grid_cols(GRID_SIZE as u16)
|
.grid_cols(GRID_SIZE as u16)
|
||||||
.grid_rows(GRID_SIZE as u16)
|
.grid_rows(GRID_SIZE as u16)
|
||||||
.size(self.size)
|
.size(AVATAR_SIZE)
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.bg(main.opacity(0.16))
|
.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`
|
/// Generate the 8×8 cell pattern for `seed`.
|
||||||
/// (main color) or `2` (accent shade); the right half mirrors the left half.
|
/// Cells are `0` for empty, `1` for main color and `2` for accent shade.
|
||||||
|
/// The right half mirrors the left half.
|
||||||
fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
||||||
let mut rng = PixelRng::new(seed);
|
let mut rng = PixelRng::new(seed);
|
||||||
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
|
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
|
||||||
@@ -102,8 +106,8 @@ fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sparse rolls can come out nearly empty; top the pattern up to the
|
// Sparse rolls can come out nearly empty.
|
||||||
// minimum fill, scanning from a seeded starting cell.
|
// Top the pattern up to the minimum fill, scanning from a seeded starting cell.
|
||||||
if filled < MIN_FILLED {
|
if filled < MIN_FILLED {
|
||||||
let half = GRID_SIZE * GRID_SIZE / 2;
|
let half = GRID_SIZE * GRID_SIZE / 2;
|
||||||
let start = (rng.next() % half as u64) as usize;
|
let start = (rng.next() % half as u64) as usize;
|
||||||
@@ -130,7 +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;
|
pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// FNV-1a 64-bit hash; stable across platforms and runs.
|
/// FNV-1a 64-bit hash, stable across platforms and runs.
|
||||||
fn fnv1a(bytes: &[u8]) -> u64 {
|
fn fnv1a(bytes: &[u8]) -> u64 {
|
||||||
let mut hash = 0xcbf2_9ce4_8422_2325u64;
|
let mut hash = 0xcbf2_9ce4_8422_2325u64;
|
||||||
for &byte in bytes {
|
for &byte in bytes {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ use gpui::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, di
|
|||||||
use gpui_base::{Button as BaseButton, StyledExt};
|
use gpui_base::{Button as BaseButton, StyledExt};
|
||||||
use gpui_component::ActiveTheme;
|
use gpui_component::ActiveTheme;
|
||||||
|
|
||||||
/// A small count badge shown after a label, e.g. on a segmented filter
|
/// A small count badge shown after a label.
|
||||||
/// button ("All 12") or a tab. Rendered from theme tokens; sized for the
|
/// Used on segmented filter buttons, like `All 12`, and on tabs.
|
||||||
/// compact header buttons it lives on.
|
/// Rendered from theme tokens and sized for the compact header buttons it lives on.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct CountBadge {
|
pub struct CountBadge {
|
||||||
count: usize,
|
count: usize,
|
||||||
@@ -46,12 +46,11 @@ impl RenderOnce for CountBadge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A segmented filter/tab button: an icon, a label, an optional [`CountBadge`]
|
/// A segmented filter/tab button with an icon, a label and an optional [`CountBadge`].
|
||||||
/// and a selected (pressed) state, styled from the theme's button tokens.
|
/// The selected state renders the button pressed, styled from theme button tokens.
|
||||||
///
|
/// Built on the unstyled `gpui_base::Button`, like the app's other custom controls.
|
||||||
/// Built on the unstyled `gpui_base::Button`, like the app's other custom
|
/// The `primary` variant uses the primary button tokens.
|
||||||
/// controls; the `primary` variant uses the primary button tokens for
|
/// It suits call-to-action buttons such as `New issue` and `New PR`.
|
||||||
/// call-to-action buttons ("New issue", "New PR").
|
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct SegmentButton {
|
pub struct SegmentButton {
|
||||||
@@ -101,7 +100,7 @@ impl SegmentButton {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Use the primary button tokens (for call-to-action buttons).
|
/// Use the primary button tokens, for call-to-action buttons.
|
||||||
pub fn primary(mut self) -> Self {
|
pub fn primary(mut self) -> Self {
|
||||||
self.primary = true;
|
self.primary = true;
|
||||||
self
|
self
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
//! Reusable settings UI: rows and blocks for building a settings dialog, plus
|
|
||||||
//! a labeled dropdown option.
|
|
||||||
|
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{App, SharedString, div};
|
use gpui::{App, SharedString, div};
|
||||||
use gpui_component::searchable_list::SearchableListItem;
|
use gpui_component::searchable_list::SearchableListItem;
|
||||||
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
||||||
|
|
||||||
/// A dropdown option with a display label and a stored value.
|
/// A dropdown option with a display label and a stored value.
|
||||||
///
|
/// The trigger and menu render the `label`.
|
||||||
/// Renders the `label` in the trigger and the menu, while `value` is what a
|
/// The `value` is what [`gpui_component::select::SelectState`] reports as the selection.
|
||||||
/// [`gpui_component::select::SelectState`] reports as the selection.
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SelectOption {
|
pub struct SelectOption {
|
||||||
value: SharedString,
|
value: SharedString,
|
||||||
@@ -48,7 +44,7 @@ impl SearchableListItem for SelectOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A settings row: label + description on the left, control on the right.
|
/// A settings row with the label and description on the left and the control on the right.
|
||||||
pub fn setting_row(
|
pub fn setting_row(
|
||||||
cx: &App,
|
cx: &App,
|
||||||
title: impl Into<SharedString>,
|
title: impl Into<SharedString>,
|
||||||
@@ -85,8 +81,8 @@ pub fn setting_row(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A full-width settings block: title + subtitle in one header, `gap_3`
|
/// A full-width settings block with title and subtitle in one header.
|
||||||
/// between the header and the control below.
|
/// `gap_3` separates the header from the control below.
|
||||||
pub fn setting_block(
|
pub fn setting_block(
|
||||||
cx: &App,
|
cx: &App,
|
||||||
title: impl Into<SharedString>,
|
title: impl Into<SharedString>,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use gpui_component::tooltip::Tooltip;
|
|||||||
use gpui_component::{ActiveTheme, Icon, Sizable, v_flex};
|
use gpui_component::{ActiveTheme, Icon, Sizable, v_flex};
|
||||||
use signed_core::RepoStatus;
|
use signed_core::RepoStatus;
|
||||||
|
|
||||||
/// The status badge shown next to an issue or pull request: icon + colored square,
|
/// The status badge shown next to an issue or pull request.
|
||||||
/// with a tooltip describing the status.
|
/// It has an icon and a colored square, with a tooltip describing the status.
|
||||||
pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
|
pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
|
||||||
let (icon, label, tooltip, bg, fg) = match status {
|
let (icon, label, tooltip, bg, fg) = match status {
|
||||||
RepoStatus::Open => (
|
RepoStatus::Open => (
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ struct WindowDragState {
|
|||||||
should_move: bool,
|
should_move: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Make an element behave like a window title bar: dragging it moves the
|
/// Make an element behave like a window title bar.
|
||||||
/// window, and double-clicking zooms the window (or performs the platform's
|
/// Dragging it moves the window.
|
||||||
/// default title-bar double-click action on macOS).
|
/// Double-clicking zooms the window.
|
||||||
///
|
/// On macOS it runs the platform's default title-bar double-click action.
|
||||||
/// Only the bar's non-interactive areas should get this — tabs are draggable
|
/// Only the bar's non-interactive areas should get this.
|
||||||
/// (to reorder panels) and must not move the window.
|
/// Tabs are draggable to reorder panels and must not move the window.
|
||||||
pub fn title_bar_drag_handlers(
|
pub fn title_bar_drag_handlers(
|
||||||
this: Stateful<Div>,
|
this: Stateful<Div>,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ use gpui_component::list::ListItem;
|
|||||||
use gpui_component::tree::TreeEntry;
|
use gpui_component::tree::TreeEntry;
|
||||||
use gpui_component::{Icon, IconName, Sizable, h_flex};
|
use gpui_component::{Icon, IconName, Sizable, h_flex};
|
||||||
|
|
||||||
/// One row of a file tree: icon + name, indented by depth.
|
/// One row of a file tree, an icon and a name indented by depth.
|
||||||
/// Clicking a file runs `on_click`; folders expand/collapse via the tree itself.
|
/// Clicking a file runs `on_click`.
|
||||||
|
/// Folders expand and collapse via the tree itself.
|
||||||
pub fn tree_row<F>(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem
|
pub fn tree_row<F>(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem
|
||||||
where
|
where
|
||||||
F: Fn(&mut Window, &mut App) + 'static,
|
F: Fn(&mut Window, &mut App) + 'static,
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use gpui::{App, SharedString, StyleRefinement, Window};
|
|||||||
use gpui_component::avatar::Avatar;
|
use gpui_component::avatar::Avatar;
|
||||||
use gpui_component::{ActiveTheme, Sizable, StyledExt};
|
use gpui_component::{ActiveTheme, Sizable, StyledExt};
|
||||||
|
|
||||||
/// A user avatar: the gpui-component [`Avatar`] sized small and rounded with
|
/// A small user avatar from gpui-component [`Avatar`], rounded with the theme radius.
|
||||||
/// the theme radius, showing the user's picture or a name-initials fallback.
|
/// It shows the user's picture or falls back to name initials.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct UserAvatar {
|
pub struct UserAvatar {
|
||||||
name: SharedString,
|
name: SharedString,
|
||||||
@@ -13,8 +13,8 @@ pub struct UserAvatar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UserAvatar {
|
impl UserAvatar {
|
||||||
/// Create an avatar for `name`; the name seeds the initials fallback
|
/// Create an avatar for `name`.
|
||||||
/// shown when no picture is set.
|
/// The name seeds the initials fallback shown when no picture is set.
|
||||||
pub fn new(name: impl Into<SharedString>) -> Self {
|
pub fn new(name: impl Into<SharedString>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/// `[head chars]...[tail chars]` middle truncation; the value is left alone
|
/// `[head chars]...[tail chars]` middle truncation.
|
||||||
/// when it is too short for the ellipsis to save space.
|
///
|
||||||
|
/// Values too short for the ellipsis to save space are left alone.
|
||||||
pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String {
|
pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String {
|
||||||
let len = value.chars().count();
|
let len = value.chars().count();
|
||||||
if len <= head + tail + 3 {
|
if len <= head + tail + 3 {
|
||||||
@@ -32,7 +33,7 @@ mod tests {
|
|||||||
),
|
),
|
||||||
"30617:a008...3564d:ngit"
|
"30617:a008...3564d:ngit"
|
||||||
);
|
);
|
||||||
// Too short to save space with the ellipsis: left alone.
|
// Too short to save space with the ellipsis, left alone.
|
||||||
assert_eq!(middle_truncate("short", 10, 10), "short");
|
assert_eq!(middle_truncate("short", 10, 10), "short");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
/// Format a timestamp as a short relative time (e.g. "3h ago").
|
/// Format a timestamp as a short relative time, e.g. `3h ago`.
|
||||||
pub fn relative_time(timestamp: Timestamp) -> String {
|
pub fn relative_time(timestamp: Timestamp) -> String {
|
||||||
let now = Timestamp::now().as_secs();
|
let now = Timestamp::now().as_secs();
|
||||||
let secs = now.saturating_sub(timestamp.as_secs());
|
let secs = now.saturating_sub(timestamp.as_secs());
|
||||||
@@ -20,7 +20,7 @@ pub fn relative_time(timestamp: Timestamp) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format a unix timestamp in seconds as a short relative time (e.g. "3h ago").
|
/// Format a unix timestamp in seconds as a short relative time, e.g. `3h ago`.
|
||||||
pub fn relative_time_secs(secs: i64) -> String {
|
pub fn relative_time_secs(secs: i64) -> String {
|
||||||
relative_time(Timestamp::from_secs(secs.max(0) as u64))
|
relative_time(Timestamp::from_secs(secs.max(0) as u64))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,5 @@ gix.workspace = true
|
|||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
chrono.workspace = true
|
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ mod workspace;
|
|||||||
|
|
||||||
use gpui::{App, AppContext, Entity, Window};
|
use gpui::{App, AppContext, Entity, Window};
|
||||||
use gpui_component::Root;
|
use gpui_component::Root;
|
||||||
pub use signed_ui::image_cache;
|
|
||||||
pub use views::{RepoListView, SidebarPanel};
|
pub use views::{RepoListView, SidebarPanel};
|
||||||
pub use workspace::Workspace;
|
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_detail;
|
||||||
mod repo_list;
|
mod repo_list;
|
||||||
pub(crate) mod sidebar;
|
pub(crate) mod sidebar;
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ use signed_core::Announcement;
|
|||||||
use signed_state::ProfileStore;
|
use signed_state::ProfileStore;
|
||||||
use signed_ui::{UserAvatar, middle_truncate};
|
use signed_ui::{UserAvatar, middle_truncate};
|
||||||
|
|
||||||
/// Open the "About" dialog: every field of the repository's announcement
|
/// Open the About dialog showing every field of the announcement event.
|
||||||
/// event (NIP-34, kind 30617), as parsed into [`Announcement`].
|
|
||||||
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
|
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
|
||||||
window.open_dialog(cx, move |dialog, _window, cx| {
|
window.open_dialog(cx, move |dialog, _window, cx| {
|
||||||
let announcement = announcement.clone();
|
let announcement = announcement.clone();
|
||||||
@@ -22,8 +21,7 @@ pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The announcement's fields as labeled rows; hex identifiers carry a copy
|
/// The announcement's fields as labeled rows.
|
||||||
/// button, multi-value tags one line per value.
|
|
||||||
fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
||||||
let mut rows: Vec<AnyElement> = Vec::new();
|
let mut rows: Vec<AnyElement> = Vec::new();
|
||||||
|
|
||||||
@@ -32,8 +30,9 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
|||||||
text(
|
text(
|
||||||
announcement
|
announcement
|
||||||
.name
|
.name
|
||||||
.clone()
|
.as_deref()
|
||||||
.unwrap_or_else(|| SharedString::from("—")),
|
.map(SharedString::from)
|
||||||
|
.unwrap_or_else(|| SharedString::from("-")),
|
||||||
),
|
),
|
||||||
cx,
|
cx,
|
||||||
));
|
));
|
||||||
@@ -43,8 +42,9 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
|||||||
text(
|
text(
|
||||||
announcement
|
announcement
|
||||||
.description
|
.description
|
||||||
.clone()
|
.as_deref()
|
||||||
.unwrap_or_else(|| SharedString::from("—")),
|
.map(SharedString::from)
|
||||||
|
.unwrap_or_else(|| SharedString::from("-")),
|
||||||
),
|
),
|
||||||
cx,
|
cx,
|
||||||
));
|
));
|
||||||
@@ -116,7 +116,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
|||||||
v_flex().gap_3().w_full().children(rows).into_any_element()
|
v_flex().gap_3().w_full().children(rows).into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One info row: a small muted label above the value.
|
/// One info row with a small muted label above the value.
|
||||||
fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
|
fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
@@ -133,7 +133,12 @@ fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Plain text value, wrapping within the dialog.
|
/// 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()
|
div()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.w_full()
|
.w_full()
|
||||||
@@ -159,8 +164,10 @@ fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row per maintainer: avatar and display name (falling back to a
|
/// One row per maintainer with avatar and display name.
|
||||||
/// shortened npub), with a copy button for the full pubkey.
|
/// The display name falls back to a shortened npub.
|
||||||
|
///
|
||||||
|
/// A copy button copies the full pubkey.
|
||||||
fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
|
fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
|
||||||
let profile_store = ProfileStore::global(cx);
|
let profile_store = ProfileStore::global(cx);
|
||||||
v_flex()
|
v_flex()
|
||||||
@@ -190,8 +197,9 @@ fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row per item of a multi-value tag: the value is truncated to a single
|
/// One row per item of a multi-value tag.
|
||||||
/// line, with a copy button that copies the full value.
|
///
|
||||||
|
/// The value is truncated to a single line, with a copy button for the full value.
|
||||||
fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) -> AnyElement {
|
fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) -> AnyElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ use super::helpers::{code_language, is_markdown_path};
|
|||||||
const TREE_WIDTH: f32 = 240.;
|
const TREE_WIDTH: f32 = 240.;
|
||||||
/// Files larger than this are not previewed.
|
/// Files larger than this are not previewed.
|
||||||
pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
|
pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
|
||||||
/// Preview cache caps: at most this many files (or this many text bytes)
|
/// Preview cache caps, a file count and a text byte count.
|
||||||
/// are kept in memory at once; the oldest previews are evicted beyond that.
|
///
|
||||||
|
/// The oldest previews are evicted beyond the caps.
|
||||||
pub(super) const MAX_PREVIEWED_FILES: usize = 32;
|
pub(super) const MAX_PREVIEWED_FILES: usize = 32;
|
||||||
pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
|
pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -34,19 +35,13 @@ pub(super) enum FileContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A markdown document loaded into a persistent [`TextViewState`].
|
/// A markdown document loaded into a persistent [`TextViewState`].
|
||||||
///
|
|
||||||
/// The state is owned by the view rather than created per render: GPUI
|
|
||||||
/// drops keyed element state after one absent frame, which would re-parse
|
|
||||||
/// the whole document on every pane switch (README / file / spinner).
|
|
||||||
pub(super) struct MarkdownView {
|
pub(super) struct MarkdownView {
|
||||||
/// Source path; `None` means the repository README.
|
/// Source path, `None` means the repository README.
|
||||||
pub(super) path: Option<SharedString>,
|
pub(super) path: Option<SharedString>,
|
||||||
pub(super) state: Entity<TextViewState>,
|
pub(super) state: Entity<TextViewState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A code file loaded into a persistent [`InputState`], rendered as a
|
/// A code file loaded into a persistent [`InputState`].
|
||||||
/// disabled (read-only) code editor with syntax highlighting, line numbers
|
|
||||||
/// and search. Persistent for the same reason as [`MarkdownView`].
|
|
||||||
pub(super) struct CodeView {
|
pub(super) struct CodeView {
|
||||||
/// Source path, relative to the worktree root.
|
/// Source path, relative to the worktree root.
|
||||||
pub(super) path: SharedString,
|
pub(super) path: SharedString,
|
||||||
@@ -64,7 +59,7 @@ fn preview_spinner() -> AnyElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RepoDetailView {
|
impl RepoDetailView {
|
||||||
/// One row of the file tree: icon + name, indented by depth.
|
/// One row of the file tree with icon and name, indented by depth.
|
||||||
fn render_tree_item(
|
fn render_tree_item(
|
||||||
ix: usize,
|
ix: usize,
|
||||||
entry: &TreeEntry,
|
entry: &TreeEntry,
|
||||||
@@ -81,7 +76,7 @@ impl RepoDetailView {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Left column: the file tree.
|
/// Left column showing the file tree.
|
||||||
pub(super) fn render_tree_column(
|
pub(super) fn render_tree_column(
|
||||||
tree_state: Entity<TreeState>,
|
tree_state: Entity<TreeState>,
|
||||||
view: WeakEntity<Self>,
|
view: WeakEntity<Self>,
|
||||||
@@ -102,7 +97,7 @@ impl RepoDetailView {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Right column: README, selected file preview, or status text.
|
/// Right column, README, selected file preview or status text.
|
||||||
pub(super) fn render_content_column(
|
pub(super) fn render_content_column(
|
||||||
&self,
|
&self,
|
||||||
pane_title: SharedString,
|
pane_title: SharedString,
|
||||||
@@ -148,7 +143,7 @@ impl RepoDetailView {
|
|||||||
self.code_element(path.as_ref(), cx)
|
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::TooLarge) => placeholder("File is too large to preview", cx),
|
||||||
Some(FileContent::Failed(message)) => placeholder(message, cx),
|
Some(FileContent::Failed(message)) => placeholder(message, cx),
|
||||||
None => preview_spinner(),
|
None => preview_spinner(),
|
||||||
@@ -159,9 +154,8 @@ impl RepoDetailView {
|
|||||||
placeholder("No README found", cx)
|
placeholder("No README found", cx)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Latest commit for the current pane: the selected file, or the README
|
// Latest commit for the current pane, the selected file or the README.
|
||||||
// while nothing is selected. Computed after the body above, which
|
// Computed after the body above, which needs `&mut self`.
|
||||||
// needs `&mut self`.
|
|
||||||
let commit = match &self.selected_file {
|
let commit = match &self.selected_file {
|
||||||
Some(path) => self.commits.get(path.as_ref()),
|
Some(path) => self.commits.get(path.as_ref()),
|
||||||
None => self
|
None => self
|
||||||
@@ -217,9 +211,6 @@ impl RepoDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load `text` into the persistent markdown TextView state.
|
/// Load `text` into the persistent markdown TextView state.
|
||||||
///
|
|
||||||
/// The state is created empty and fed via `push_str`, which parses on a
|
|
||||||
/// background task, so switching files never blocks the main thread.
|
|
||||||
pub(super) fn set_markdown(
|
pub(super) fn set_markdown(
|
||||||
&mut self,
|
&mut self,
|
||||||
path: Option<SharedString>,
|
path: Option<SharedString>,
|
||||||
@@ -231,16 +222,19 @@ impl RepoDetailView {
|
|||||||
self.md = Some(MarkdownView { path, state });
|
self.md = Some(MarkdownView { path, state });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The persistent markdown TextView for `path` (`None` = README), or a
|
/// The persistent markdown TextView for `path`, where `None` is the README.
|
||||||
/// spinner while the document is being loaded/parsed.
|
///
|
||||||
|
/// Shows a spinner while the document is being loaded or parsed.
|
||||||
fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement {
|
fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement {
|
||||||
let Some(md) = &self.md else {
|
let Some(md) = &self.md else {
|
||||||
return preview_spinner();
|
return preview_spinner();
|
||||||
};
|
};
|
||||||
|
|
||||||
let ready = match path {
|
let ready = match path {
|
||||||
Some(path) => md.path.as_deref() == Some(path),
|
Some(path) => md.path.as_deref() == Some(path),
|
||||||
None => md.path.is_none(),
|
None => md.path.is_none(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if !ready {
|
if !ready {
|
||||||
return preview_spinner();
|
return preview_spinner();
|
||||||
}
|
}
|
||||||
@@ -255,9 +249,7 @@ impl RepoDetailView {
|
|||||||
|
|
||||||
/// Load `text` into the persistent code editor state for `path`.
|
/// Load `text` into the persistent code editor state for `path`.
|
||||||
///
|
///
|
||||||
/// The state is created in code editor mode so the Input renders it as
|
/// Code editor mode makes the Input render it read-only and highlighted.
|
||||||
/// a syntax-highlighted, read-only editor; the tree-sitter parse runs
|
|
||||||
/// on a background task like [`set_markdown`]'s.
|
|
||||||
pub(super) fn set_code(
|
pub(super) fn set_code(
|
||||||
&mut self,
|
&mut self,
|
||||||
path: SharedString,
|
path: SharedString,
|
||||||
@@ -276,8 +268,7 @@ impl RepoDetailView {
|
|||||||
self.code = Some(CodeView { path, state });
|
self.code = Some(CodeView { path, state });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The persistent code editor for `path`, or a spinner while the file is
|
/// The persistent code editor for `path`, or a spinner while the file loads or parses.
|
||||||
/// being loaded/parsed.
|
|
||||||
fn code_element(&self, path: &str, _cx: &mut Context<Self>) -> AnyElement {
|
fn code_element(&self, path: &str, _cx: &mut Context<Self>) -> AnyElement {
|
||||||
let Some(code) = &self.code else {
|
let Some(code) = &self.code else {
|
||||||
return preview_spinner();
|
return preview_spinner();
|
||||||
|
|||||||
@@ -70,8 +70,6 @@ pub(super) fn commit_row(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RepoDetailView {
|
impl RepoDetailView {
|
||||||
/// Full-height body of the Commits tab: all commits in a virtual
|
|
||||||
/// list, or a status message while loading / when there are none.
|
|
||||||
pub(super) fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
pub(super) fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let Some(list) = self.all_commits.as_ref() else {
|
let Some(list) = self.all_commits.as_ref() else {
|
||||||
return if self.loading_all_commits {
|
return if self.loading_all_commits {
|
||||||
@@ -90,9 +88,9 @@ impl RepoDetailView {
|
|||||||
return placeholder("No commits found", cx);
|
return placeholder("No commits found", cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy only the values the element tree needs; the list itself is
|
// Copy only the values the element tree needs.
|
||||||
// borrowed inside the renderer below instead of being cloned per
|
// The list is borrowed by the renderer below instead of cloned per frame.
|
||||||
// frame (a full history can be tens of thousands of commits).
|
// A full history can be tens of thousands of commits.
|
||||||
let view = cx.entity().clone();
|
let view = cx.entity().clone();
|
||||||
let sizes = self.item_sizes.clone();
|
let sizes = self.item_sizes.clone();
|
||||||
let scroll_handle = self.scroll_handle.clone();
|
let scroll_handle = self.scroll_handle.clone();
|
||||||
@@ -137,7 +135,8 @@ impl RepoDetailView {
|
|||||||
.size_full(),
|
.size_full(),
|
||||||
)
|
)
|
||||||
.when(shown < total, |this| {
|
.when(shown < total, |this| {
|
||||||
// The history is capped; tell the user the list is truncated.
|
// The history is capped.
|
||||||
|
// Tell the user the list is truncated.
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
.py_2()
|
.py_2()
|
||||||
|
|||||||
@@ -28,19 +28,15 @@ use super::helpers::{
|
|||||||
/// Width of the changed-files column.
|
/// Width of the changed-files column.
|
||||||
const TREE_WIDTH: f32 = 260.;
|
const TREE_WIDTH: f32 = 260.;
|
||||||
|
|
||||||
/// The tree + per-file diff body shared by the commit diff panel and the
|
/// Tree and per-file diff body, shared by the commit diff and compare views.
|
||||||
/// compare view of the new-pull-request panel. Owns the changed-files
|
|
||||||
/// explorer and the virtual list of the selected file's hunks; the host
|
|
||||||
/// feeds it a [`CommitDiff`] via [`DiffPane::set_diff`].
|
|
||||||
pub struct DiffPane {
|
pub struct DiffPane {
|
||||||
/// Loaded diff; `None` until [`Self::set_diff`] is called.
|
/// Loaded diff, `None` until [`Self::set_diff`] is called.
|
||||||
diff: Option<CommitDiff>,
|
diff: Option<CommitDiff>,
|
||||||
/// Changed-files explorer state.
|
/// Changed-files explorer state.
|
||||||
tree_state: Entity<TreeState>,
|
tree_state: Entity<TreeState>,
|
||||||
/// Path of the file whose diff is shown in the detail column.
|
/// Path of the file whose diff is shown in the detail column.
|
||||||
selected_file: Option<SharedString>,
|
selected_file: Option<SharedString>,
|
||||||
/// Rows of the selected file's diff (hunk headers + lines), backing the
|
/// Rows of the selected file's diff, hunk headers and lines.
|
||||||
/// virtual list in the detail column.
|
|
||||||
rows: Vec<DiffRow>,
|
rows: Vec<DiffRow>,
|
||||||
/// Per-row heights of [`Self::rows`].
|
/// Per-row heights of [`Self::rows`].
|
||||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
@@ -90,8 +86,9 @@ impl DiffPane {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forget the diff (e.g. when the compared branches changed): clear the
|
/// Forget the diff, e.g. when the compared branches changed.
|
||||||
/// tree, the selection and the diff rows.
|
///
|
||||||
|
/// Clears the tree, the selection and the diff rows.
|
||||||
pub fn clear(&mut self, cx: &mut Context<Self>) {
|
pub fn clear(&mut self, cx: &mut Context<Self>) {
|
||||||
self.diff = None;
|
self.diff = None;
|
||||||
self.selected_file = None;
|
self.selected_file = None;
|
||||||
@@ -102,15 +99,14 @@ impl DiffPane {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show the diff of the file at `path` (selected in the tree).
|
/// Show the diff of the file at `path`, selected in the tree.
|
||||||
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
|
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||||
self.selected_file = Some(path.into());
|
self.selected_file = Some(path.into());
|
||||||
self.set_diff_rows(path);
|
self.set_diff_rows(path);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild the virtual list state for the file at `path` and scroll back
|
/// Rebuild the virtual list state for `path` and scroll back to the top.
|
||||||
/// to the top.
|
|
||||||
fn set_diff_rows(&mut self, path: &str) {
|
fn set_diff_rows(&mut self, path: &str) {
|
||||||
let Some(diff) = self.diff.as_ref() else {
|
let Some(diff) = self.diff.as_ref() else {
|
||||||
return;
|
return;
|
||||||
@@ -123,7 +119,7 @@ impl DiffPane {
|
|||||||
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of the changed-files tree: icon + name, indented by depth.
|
/// One row of the changed-files tree, icon and name, indented by depth.
|
||||||
fn render_tree_item(
|
fn render_tree_item(
|
||||||
ix: usize,
|
ix: usize,
|
||||||
entry: &TreeEntry,
|
entry: &TreeEntry,
|
||||||
@@ -140,7 +136,7 @@ impl DiffPane {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Left column: the changed-files tree.
|
/// Left column showing the changed-files tree.
|
||||||
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let tree_state = self.tree_state.clone();
|
let tree_state = self.tree_state.clone();
|
||||||
let view = cx.entity().downgrade();
|
let view = cx.entity().downgrade();
|
||||||
@@ -170,7 +166,7 @@ impl DiffPane {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Right column: header of the selected file plus its diff.
|
/// Right column, header of the selected file plus its diff.
|
||||||
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let Some(diff) = self.diff.as_ref() else {
|
let Some(diff) = self.diff.as_ref() else {
|
||||||
return placeholder("No changes", cx);
|
return placeholder("No changes", cx);
|
||||||
@@ -188,8 +184,7 @@ impl DiffPane {
|
|||||||
self.render_file_diff(file, cx.entity(), cx)
|
self.render_file_diff(file, cx.entity(), cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The diff of one file: a header with status and stats, then the hunks
|
/// The diff of one file, with a header showing status and stats.
|
||||||
/// in a virtual list (a large diff is never materialized per frame).
|
|
||||||
fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
|
fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
|
||||||
let status_label = match file.status {
|
let status_label = match file.status {
|
||||||
DiffStatus::Added => "A",
|
DiffStatus::Added => "A",
|
||||||
@@ -316,25 +311,21 @@ impl Render for DiffPane {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detail panel showing the diff of one commit: a metadata header plus the
|
/// Detail panel showing the diff of one commit.
|
||||||
/// shared [`DiffPane`] body.
|
|
||||||
pub struct CommitDiffView {
|
pub struct CommitDiffView {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
/// Local clone the commit lives in.
|
/// Local clone the commit lives in.
|
||||||
worktree: PathBuf,
|
worktree: PathBuf,
|
||||||
/// Display name of the repository the commit belongs to.
|
/// Display name of the repository the commit belongs to.
|
||||||
repo_name: SharedString,
|
repo_name: SharedString,
|
||||||
/// The commit being shown (header and tab title). Starts as an id-only
|
/// The commit being shown in the header and tab title.
|
||||||
/// stub; [`Self::load`] replaces it with the full metadata, which the
|
|
||||||
/// history list intentionally omits.
|
|
||||||
commit: FileCommit,
|
commit: FileCommit,
|
||||||
/// The diff is being computed on a background task.
|
/// The diff is being computed on a background task.
|
||||||
loading: bool,
|
loading: bool,
|
||||||
error: Option<SharedString>,
|
error: Option<SharedString>,
|
||||||
/// Changed-files explorer and per-file diff, shared with the compare
|
/// Changed-files explorer and per-file diff, also used by the new PR panel's compare view.
|
||||||
/// view of the new-pull-request panel.
|
|
||||||
pane: Entity<DiffPane>,
|
pane: Entity<DiffPane>,
|
||||||
/// In-flight tasks; pruned on every push (see [`helpers::track`]).
|
/// In-flight tasks, pruned on every push.
|
||||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,8 +362,7 @@ impl CommitDiffView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the commit diff (and the full commit metadata) on a background
|
/// Load the commit diff and the full commit metadata.
|
||||||
/// task and populate the tree.
|
|
||||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.loading = true;
|
self.loading = true;
|
||||||
self.error = None;
|
self.error = None;
|
||||||
@@ -419,7 +409,7 @@ impl CommitDiffView {
|
|||||||
self.tasks.push(task);
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Header: commit id, summary, author/time and overall change stats.
|
/// Header with the commit id, summary, author/time and overall change stats.
|
||||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let commit = &self.commit;
|
let commit = &self.commit;
|
||||||
let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| {
|
let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| {
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use assets::CustomIconName;
|
||||||
use gpui::prelude::*;
|
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::menu::PopupMenu;
|
||||||
|
use gpui_component::searchable_list::SearchableVec;
|
||||||
|
use gpui_component::tag::Tag;
|
||||||
use gpui_component::tree::TreeItem;
|
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::nips::nip19::{Nip19Coordinate, ToBech32};
|
||||||
|
use nostr::prelude::{Event, EventId, PublicKey};
|
||||||
use signed_core::Announcement;
|
use signed_core::Announcement;
|
||||||
use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff};
|
use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff};
|
||||||
use signed_ui::{menu_copy_row, middle_truncate};
|
use signed_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 {
|
pub(super) struct TreeItemSeed {
|
||||||
/// Path of the node, relative to the worktree root.
|
/// Path of the node, relative to the worktree root.
|
||||||
id: String,
|
id: String,
|
||||||
@@ -22,12 +28,6 @@ pub(super) struct TreeItemSeed {
|
|||||||
children: Vec<TreeItemSeed>,
|
children: Vec<TreeItemSeed>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert tree seeds into [`TreeItem`]s, expanding every folder
|
|
||||||
/// when `expand_folders` is set.
|
|
||||||
///
|
|
||||||
/// The commit diff explorer shows only changed files,
|
|
||||||
/// which is typically a handful of paths, so its folders start expanded;
|
|
||||||
/// the worktree explorer starts collapsed instead.
|
|
||||||
pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
|
pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
|
||||||
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
|
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
|
||||||
let mut item = TreeItem::new(seed.id, seed.label);
|
let mut item = TreeItem::new(seed.id, seed.label);
|
||||||
@@ -48,14 +48,9 @@ pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build nested tree items from a flat, sorted (dirs-first) entry list.
|
/// Build nested tree items from a flat entry list sorted dirs-first.
|
||||||
///
|
|
||||||
/// Returns [`TreeItemSeed`]s so the build can run off the main thread; a
|
|
||||||
/// worktree walk can yield tens of thousands of entries. Nodes live in an
|
|
||||||
/// arena and parents are found via a path -> index map, which keeps the
|
|
||||||
/// build linear in the number of path components.
|
|
||||||
pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
||||||
// Node indices by full path, for O(1) parent lookup while inserting.
|
// Node indices by full path, so parents resolve in constant time while inserting.
|
||||||
let mut index: HashMap<String, usize> = HashMap::new();
|
let mut index: HashMap<String, usize> = HashMap::new();
|
||||||
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
|
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
|
||||||
let mut roots: Vec<usize> = Vec::new();
|
let mut roots: Vec<usize> = Vec::new();
|
||||||
@@ -99,9 +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.
|
/// The markdown fence language for a file path, or `None` for plain text.
|
||||||
///
|
|
||||||
/// Names are chosen so `gpui_component`'s highlighter can resolve them
|
|
||||||
/// (`highlighter::Language::from_name` accepts short aliases such as `rs` and `js`).
|
|
||||||
pub(super) fn code_language(path: &str) -> Option<&'static str> {
|
pub(super) fn code_language(path: &str) -> Option<&'static str> {
|
||||||
let name = Path::new(path)
|
let name = Path::new(path)
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -166,7 +158,7 @@ pub(super) fn is_markdown_path(path: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) struct ShareTargets {
|
pub(super) struct ShareTargets {
|
||||||
/// NIP-19 `naddr1...` of the announcement (with its announced relays).
|
/// NIP-19 `naddr1...` of the announcement, with its announced relays.
|
||||||
pub(super) naddr: String,
|
pub(super) naddr: String,
|
||||||
/// Hex ID of the announcement event itself.
|
/// Hex ID of the announcement event itself.
|
||||||
pub(super) event_id: String,
|
pub(super) event_id: String,
|
||||||
@@ -195,8 +187,9 @@ impl ShareTargets {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The share dropdown menu: one row per target, each showing a compact
|
/// The share dropdown menu, one row per target.
|
||||||
/// label while the copy button (and row click) copy the full value.
|
///
|
||||||
|
/// Each shows a compact label, the copy button and row click copy the full value.
|
||||||
pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu {
|
pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu {
|
||||||
menu.min_w(px(340.))
|
menu.min_w(px(340.))
|
||||||
.item(menu_copy_row(
|
.item(menu_copy_row(
|
||||||
@@ -226,9 +219,7 @@ impl ShareTargets {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shorten an naddr link to `<url>/naddr1...[last tail chars]`, e.g.
|
/// Shorten an naddr link to `<url>/naddr1...[last tail chars]`.
|
||||||
/// `https://gitworkshop.dev/naddr1...abcd`. Only the label is shortened;
|
|
||||||
/// the value to be copied stays the full URL.
|
|
||||||
fn truncate_naddr_link(url: &str, tail: usize) -> String {
|
fn truncate_naddr_link(url: &str, tail: usize) -> String {
|
||||||
let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else {
|
let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else {
|
||||||
return url.to_string();
|
return url.to_string();
|
||||||
@@ -244,7 +235,8 @@ pub(super) const GUTTER_WIDTH: f32 = 44.;
|
|||||||
/// Height of one row in a virtual diff list.
|
/// Height of one row in a virtual diff list.
|
||||||
pub(super) const DIFF_ROW_HEIGHT: f32 = 20.;
|
pub(super) const DIFF_ROW_HEIGHT: f32 = 20.;
|
||||||
|
|
||||||
/// One row of a virtual diff list: a hunk header, or a line of a hunk.
|
/// One row of a virtual diff list, a hunk header or a line of a hunk.
|
||||||
|
///
|
||||||
/// Shared by the commit diff and pull request diff viewers.
|
/// Shared by the commit diff and pull request diff viewers.
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub(super) enum DiffRow {
|
pub(super) enum DiffRow {
|
||||||
@@ -258,7 +250,7 @@ pub(super) enum DiffRow {
|
|||||||
Line { hunk: usize, line: usize },
|
Line { hunk: usize, line: usize },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The rows of `file`'s diff: one header row per hunk, then its lines.
|
/// The rows of `file`'s diff, one header row per hunk then its lines.
|
||||||
pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
|
pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
|
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
|
||||||
@@ -276,7 +268,7 @@ pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
|
|||||||
rows
|
rows
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of the virtual diff list: a hunk header or a single line.
|
/// One row of the virtual diff list, a hunk header or a single line.
|
||||||
pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
|
pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
|
||||||
match row {
|
match row {
|
||||||
DiffRow::Hunk {
|
DiffRow::Hunk {
|
||||||
@@ -303,8 +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,
|
/// One diff line, old and new line numbers in the gutters.
|
||||||
/// tinted by kind (addition / deletion / context).
|
///
|
||||||
|
/// The content is tinted by kind, addition, deletion or context.
|
||||||
pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
||||||
let bg = match line.kind {
|
let bg = match line.kind {
|
||||||
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
|
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
|
||||||
@@ -313,8 +306,8 @@ pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
|||||||
};
|
};
|
||||||
let gutter = cx.theme().muted_foreground;
|
let gutter = cx.theme().muted_foreground;
|
||||||
|
|
||||||
// Fixed height and nowrap: the virtual list assumes every row has
|
// Fixed height and nowrap, the virtual list assumes every row has the same height.
|
||||||
// the same height, so long lines are clipped instead of wrapped.
|
// Long lines are clipped instead of wrapped.
|
||||||
h_flex()
|
h_flex()
|
||||||
.w_full()
|
.w_full()
|
||||||
.h(px(DIFF_ROW_HEIGHT))
|
.h(px(DIFF_ROW_HEIGHT))
|
||||||
@@ -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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -379,7 +623,7 @@ mod tests {
|
|||||||
|
|
||||||
let items = build_tree_items(&entries);
|
let items = build_tree_items(&entries);
|
||||||
|
|
||||||
// Input order is preserved (dirs-first, as produced by worktree_entries).
|
// Input order is preserved, dirs-first as produced by worktree_entries.
|
||||||
assert_eq!(items.len(), 3);
|
assert_eq!(items.len(), 3);
|
||||||
assert_eq!(items[0].label, "src");
|
assert_eq!(items[0].label, "src");
|
||||||
assert_eq!(items[0].id, "src");
|
assert_eq!(items[0].id, "src");
|
||||||
@@ -411,9 +655,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tree_builder_merges_shared_prefixes() {
|
fn tree_builder_merges_shared_prefixes() {
|
||||||
// File children of a directory arrive after other directories'
|
// File children of a directory arrive after other directories' entries.
|
||||||
// entries (the worktree list is dirs-first globally); the shared
|
// The worktree list is dirs-first globally.
|
||||||
// prefix must still resolve to one node.
|
// The shared prefix must still resolve to one node.
|
||||||
let entries = vec![
|
let entries = vec![
|
||||||
PathBuf::from("a/x.txt"),
|
PathBuf::from("a/x.txt"),
|
||||||
PathBuf::from("b/y.txt"),
|
PathBuf::from("b/y.txt"),
|
||||||
@@ -461,7 +705,7 @@ mod tests {
|
|||||||
truncate_naddr_link("https://gitworkshop.dev/naddr1qqqxyzabc1234", 4),
|
truncate_naddr_link("https://gitworkshop.dev/naddr1qqqxyzabc1234", 4),
|
||||||
"https://gitworkshop.dev/naddr1...1234"
|
"https://gitworkshop.dev/naddr1...1234"
|
||||||
);
|
);
|
||||||
// No naddr1 prefix: unchanged.
|
// Without the naddr1 prefix, unchanged.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
truncate_naddr_link("https://example.com/x", 4),
|
truncate_naddr_link("https://example.com/x", 4),
|
||||||
"https://example.com/x"
|
"https://example.com/x"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use assets::CustomIconName;
|
use assets::CustomIconName;
|
||||||
use gpui::prelude::*;
|
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::h_flex;
|
||||||
use gpui_base::input::TextareaState;
|
use gpui_base::input::TextareaState;
|
||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
@@ -14,22 +14,15 @@ use settings::SettingsStore;
|
|||||||
use signed_state::Backend;
|
use signed_state::Backend;
|
||||||
|
|
||||||
use super::RepoDetailView;
|
use super::RepoDetailView;
|
||||||
|
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||||
use crate::views::sidebar::grasp_servers::{
|
use crate::views::sidebar::grasp_servers::{
|
||||||
GraspServersState, grasp_servers_field, load_user_grasp_servers,
|
GraspServersState, grasp_servers_field, load_user_grasp_servers,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Shared state for the Init dialog, so async results can be rendered.
|
/// Shared state for the Init dialog, so async results can be rendered.
|
||||||
#[derive(Default)]
|
pub type InitRepoState = DialogProgress;
|
||||||
pub struct InitRepoState {
|
|
||||||
pub busy: bool,
|
|
||||||
pub error: Option<SharedString>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open the Init dialog for the local repository at `local_path`.
|
/// Open the Init dialog for the local repository at `local_path`.
|
||||||
///
|
|
||||||
/// The dialog loads the user's default grasp servers (kind `10317` grasp
|
|
||||||
/// list) and falls back to the shared defaults when none are set. On
|
|
||||||
/// success the dialog closes and `view` switches into NIP-34 mode.
|
|
||||||
pub fn open(
|
pub fn open(
|
||||||
local_path: PathBuf,
|
local_path: PathBuf,
|
||||||
view: WeakEntity<RepoDetailView>,
|
view: WeakEntity<RepoDetailView>,
|
||||||
@@ -40,23 +33,25 @@ pub fn open(
|
|||||||
.file_name()
|
.file_name()
|
||||||
.map(|name| name.to_string_lossy().into_owned())
|
.map(|name| name.to_string_lossy().into_owned())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let grasp_settings = SettingsStore::global(cx)
|
||||||
|
.read(cx)
|
||||||
|
.settings()
|
||||||
|
.grasp_servers
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let state = cx.new(|_| InitRepoState::default());
|
||||||
|
let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings));
|
||||||
|
|
||||||
|
let relay_input = cx.new(|cx| InputState::new(window, cx).placeholder("relay.example.com"));
|
||||||
let name_input = cx.new(|cx| InputState::new(window, cx).default_value(default_name));
|
let name_input = cx.new(|cx| InputState::new(window, cx).default_value(default_name));
|
||||||
let desc_input = cx.new(|cx| {
|
let desc_input = cx.new(|cx| {
|
||||||
TextareaState::new(window, cx)
|
TextareaState::new(window, cx)
|
||||||
.auto_grow(3, 5)
|
.auto_grow(3, 5)
|
||||||
.placeholder("Short description")
|
.placeholder("Short description")
|
||||||
});
|
});
|
||||||
let relay_input = cx.new(|cx| {
|
|
||||||
InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com")
|
|
||||||
});
|
|
||||||
let state = cx.new(|_| InitRepoState::default());
|
|
||||||
let grasp_settings = SettingsStore::global(cx)
|
|
||||||
.read(cx)
|
|
||||||
.settings()
|
|
||||||
.grasp_servers
|
|
||||||
.clone();
|
|
||||||
let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings));
|
|
||||||
|
|
||||||
|
// Load the user's grasp servers.
|
||||||
load_user_grasp_servers(grasp_state.clone(), window, cx);
|
load_user_grasp_servers(grasp_state.clone(), window, cx);
|
||||||
|
|
||||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||||
@@ -115,9 +110,7 @@ pub fn open(
|
|||||||
)
|
)
|
||||||
.child(grasp_servers_field(&grasp_state, &relay_input, cx)),
|
.child(grasp_servers_field(&grasp_state, &relay_input, cx)),
|
||||||
)
|
)
|
||||||
.children(error.map(|message| {
|
.children(error_row(&error, cx))
|
||||||
div().text_sm().text_color(cx.theme().danger).child(message)
|
|
||||||
}))
|
|
||||||
.child(
|
.child(
|
||||||
DialogFooter::new().justify_end().child(
|
DialogFooter::new().justify_end().child(
|
||||||
Button::new("init")
|
Button::new("init")
|
||||||
@@ -153,8 +146,9 @@ pub fn open(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the init flow; closes the dialog and switches the repository into
|
/// Run the init flow.
|
||||||
/// its NIP-34 mode on success.
|
///
|
||||||
|
/// Closes the dialog and switches the repository into NIP-34 mode on success.
|
||||||
fn init_repository(
|
fn init_repository(
|
||||||
local_path: PathBuf,
|
local_path: PathBuf,
|
||||||
inputs: (Entity<InputState>, Entity<TextareaState>),
|
inputs: (Entity<InputState>, Entity<TextareaState>),
|
||||||
@@ -170,23 +164,16 @@ fn init_repository(
|
|||||||
let servers = grasp_state.read(cx).grasp_servers.clone();
|
let servers = grasp_state.read(cx).grasp_servers.clone();
|
||||||
|
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| state.fail("Repository name is required"));
|
||||||
state.error = Some("Repository name is required".into());
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if servers.is_empty() {
|
if servers.is_empty() {
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| state.fail("Add at least one grasp server"));
|
||||||
state.error = Some("Add at least one grasp server".into());
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| state.begin());
|
||||||
state.busy = true;
|
|
||||||
state.error = None;
|
|
||||||
});
|
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let task = backend.update(cx, |backend, cx| {
|
let task = backend.update(cx, |backend, cx| {
|
||||||
@@ -211,10 +198,7 @@ fn init_repository(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
cx.update_window(handle, |_, _window, cx| {
|
cx.update_window(handle, |_, _window, cx| {
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| state.fail(e.to_string()));
|
||||||
state.busy = false;
|
|
||||||
state.error = Some(e.to_string().into());
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,26 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use assets::CustomIconName;
|
|
||||||
use dock::{BasePanel, Panel, PanelEvent};
|
use dock::{BasePanel, Panel, PanelEvent};
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
|
App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Window, div,
|
||||||
Window, div, px, relative,
|
relative,
|
||||||
};
|
};
|
||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::input::TextareaState;
|
||||||
use gpui_component::input::{Textarea, TextareaState};
|
|
||||||
use gpui_component::scroll::ScrollableElement;
|
use gpui_component::scroll::ScrollableElement;
|
||||||
use gpui_component::tag::Tag;
|
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
||||||
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
|
use nostr::prelude::EventId;
|
||||||
use nostr::prelude::{Event, EventId, PublicKey};
|
|
||||||
use signed_core::activity_subject;
|
use signed_core::activity_subject;
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileStore, RepoStore};
|
||||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
|
||||||
use signed_ui::{UserAvatar, placeholder, status_badge};
|
use signed_ui::{UserAvatar, placeholder, status_badge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
|
use super::helpers::{comment_form, comments_section, issue_roots, sidebar_section};
|
||||||
|
|
||||||
/// Detail panel of a single issue.
|
/// Detail panel of a single issue.
|
||||||
pub struct IssueDetailView {
|
pub struct IssueDetailView {
|
||||||
/// Repo store holding the issues and their statuses.
|
/// Repo store holding the issues and their statuses.
|
||||||
store: Entity<RepoStore>,
|
store: Entity<RepoStore>,
|
||||||
issue_id: EventId,
|
issue_id: EventId,
|
||||||
contents: HashMap<EventId, SharedString>,
|
/// Input state of the comment textarea.
|
||||||
/// Input state of the "leave a comment" textarea.
|
|
||||||
comment_input: Entity<TextareaState>,
|
comment_input: Entity<TextareaState>,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
}
|
}
|
||||||
@@ -45,193 +40,8 @@ impl IssueDetailView {
|
|||||||
store,
|
store,
|
||||||
issue_id,
|
issue_id,
|
||||||
comment_input,
|
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 {
|
impl BasePanel for IssueDetailView {
|
||||||
@@ -277,17 +87,11 @@ impl Render for IssueDetailView {
|
|||||||
let (title, author, picture, status, age, issue_id, content) = {
|
let (title, author, picture, status, age, issue_id, content) = {
|
||||||
let profile_store = ProfileStore::global(cx);
|
let profile_store = ProfileStore::global(cx);
|
||||||
let profile = profile_store.read(cx).get(&issue.pubkey);
|
let profile = profile_store.read(cx).get(&issue.pubkey);
|
||||||
let content = self
|
let content = if issue.content.is_empty() {
|
||||||
.contents
|
SharedString::from("No description provided.")
|
||||||
.entry(issue.id)
|
} else {
|
||||||
.or_insert_with(|| {
|
SharedString::from(&issue.content)
|
||||||
if issue.content.is_empty() {
|
};
|
||||||
SharedString::from("No description provided.")
|
|
||||||
} else {
|
|
||||||
SharedString::from(&issue.content)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
(
|
(
|
||||||
activity_subject(issue),
|
activity_subject(issue),
|
||||||
@@ -301,7 +105,7 @@ impl Render for IssueDetailView {
|
|||||||
};
|
};
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.image_cache(image_cache("issue-detail", MAX_IMAGES))
|
.image_cache(gpui::retain_all("issue-detail"))
|
||||||
.id("issue-detail")
|
.id("issue-detail")
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(
|
.child(
|
||||||
@@ -358,20 +162,24 @@ impl Render for IssueDetailView {
|
|||||||
)
|
)
|
||||||
.child(div().text_sm().child(content)),
|
.child(div().text_sm().child(content)),
|
||||||
)
|
)
|
||||||
.child(self.render_comments(&issue_id, cx))
|
.child(comments_section(&self.store, issue_id, cx))
|
||||||
.child(self.render_form(&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()
|
.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 std::rc::Rc;
|
||||||
|
|
||||||
use assets::CustomIconName;
|
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::prelude::*;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||||
@@ -18,14 +18,12 @@ use gpui_component::{
|
|||||||
use nostr::prelude::EventId;
|
use nostr::prelude::EventId;
|
||||||
use signed_core::{RepoStatus, activity_subject};
|
use signed_core::{RepoStatus, activity_subject};
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileStore, RepoStore};
|
||||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
|
||||||
use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge};
|
use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
use super::issue_detail::IssueDetailView;
|
use super::issue_detail::IssueDetailView;
|
||||||
|
|
||||||
/// Height of one issue row in the virtual list: `py_2` padding, a 32px
|
/// Height of one issue row in the virtual list.
|
||||||
/// title line (`h_8`), a 24px meta line (`h_6`) and the 1px bottom border.
|
|
||||||
const ISSUE_ROW_HEIGHT: f32 = 73.;
|
const ISSUE_ROW_HEIGHT: f32 = 73.;
|
||||||
|
|
||||||
/// Status filter of the issues list, chosen via the header's filter buttons.
|
/// Status filter of the issues list, chosen via the header's filter buttons.
|
||||||
@@ -35,8 +33,7 @@ enum IssueFilter {
|
|||||||
All,
|
All,
|
||||||
/// Issues whose resolved status is [`RepoStatus::Open`].
|
/// Issues whose resolved status is [`RepoStatus::Open`].
|
||||||
Open,
|
Open,
|
||||||
/// Issues whose resolved status is [`RepoStatus::Closed`] or
|
/// Issues whose resolved status is [`RepoStatus::Closed`].
|
||||||
/// [`RepoStatus::Applied`] (both are "done" states).
|
|
||||||
Closed,
|
Closed,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,14 +60,11 @@ pub struct IssuesView {
|
|||||||
filter: IssueFilter,
|
filter: IssueFilter,
|
||||||
/// Per-row heights of the virtual list.
|
/// Per-row heights of the virtual list.
|
||||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
/// Number of rows [`Self::item_sizes`] was built for (the filtered issue count).
|
/// The filtered issue count [`Self::item_sizes`] was built for.
|
||||||
issue_len: usize,
|
issue_len: usize,
|
||||||
/// Indices into the store's `issues` matching [`Self::filter`]; the
|
/// Indices into the store's `issues` matching [`Self::filter`].
|
||||||
/// virtual list renders this slice. Rebuilt only when the store
|
|
||||||
/// version or the filter changes, keyed by [`Self::cache_key`].
|
|
||||||
visible_issues: Vec<usize>,
|
visible_issues: Vec<usize>,
|
||||||
/// Header counts `(total, open, closed)`, rebuilt with
|
/// Header counts `(total, open, closed)`, rebuilt with [`Self::visible_issues`].
|
||||||
/// [`Self::visible_issues`].
|
|
||||||
counts: (usize, usize, usize),
|
counts: (usize, usize, usize),
|
||||||
/// Store version and filter the cached rows/counts were built from.
|
/// Store version and filter the cached rows/counts were built from.
|
||||||
cache_key: Option<(u64, IssueFilter)>,
|
cache_key: Option<(u64, IssueFilter)>,
|
||||||
@@ -82,10 +76,11 @@ impl IssuesView {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
dock_area: WeakEntity<DockArea>,
|
dock_area: WeakEntity<DockArea>,
|
||||||
store: Entity<RepoStore>,
|
store: Entity<RepoStore>,
|
||||||
repo_name: SharedString,
|
|
||||||
_window: &mut Window,
|
_window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let repo_name = store.read(cx).name();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
dock_area,
|
dock_area,
|
||||||
@@ -101,7 +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(
|
fn open_issue_detail(
|
||||||
&mut self,
|
&mut self,
|
||||||
issue_id: EventId,
|
issue_id: EventId,
|
||||||
@@ -115,12 +110,10 @@ impl IssuesView {
|
|||||||
let panel = cx.new(|cx| IssueDetailView::new(self.store.clone(), issue_id, window, cx));
|
let panel = cx.new(|cx| IssueDetailView::new(self.store.clone(), issue_id, window, cx));
|
||||||
|
|
||||||
dock_area.update(cx, |dock_area, 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 {
|
fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let issue = &self.store.read(cx).issues[issue_ix];
|
let issue = &self.store.read(cx).issues[issue_ix];
|
||||||
let title = activity_subject(issue);
|
let title = activity_subject(issue);
|
||||||
@@ -183,8 +176,7 @@ impl IssuesView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
// Counts of the last list rebuild (`render` rebuilds first when the
|
// Counts of the last list rebuild.
|
||||||
// store version or filter changed, so this is never stale).
|
|
||||||
let (total, open, closed) = self.counts;
|
let (total, open, closed) = self.counts;
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -242,8 +234,7 @@ impl IssuesView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the "new issue" dialog: a title and a content input that submit
|
/// Open the new issue dialog, a title and a content input.
|
||||||
/// through [`RepoStore::open_issue`] when confirmed.
|
|
||||||
pub(super) fn open_new_issue_dialog(store: Entity<RepoStore>, window: &mut Window, cx: &mut App) {
|
pub(super) fn open_new_issue_dialog(store: Entity<RepoStore>, window: &mut Window, cx: &mut App) {
|
||||||
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Issue title"));
|
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Issue title"));
|
||||||
let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue..."));
|
let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue..."));
|
||||||
@@ -332,9 +323,9 @@ impl Render for IssuesView {
|
|||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let filter = self.filter;
|
let filter = self.filter;
|
||||||
|
|
||||||
// Rebuild the filtered rows and header counts only when the store
|
// Rows and counts are rebuilt only when the store refreshed or filter changed.
|
||||||
// refreshed or the filter changed; other renders reuse the cache.
|
|
||||||
let version = self.store.read(cx).version();
|
let version = self.store.read(cx).version();
|
||||||
|
|
||||||
if self.cache_key != Some((version, filter)) {
|
if self.cache_key != Some((version, filter)) {
|
||||||
let store = self.store.read(cx);
|
let store = self.store.read(cx);
|
||||||
let mut counts = (0usize, 0usize, 0usize);
|
let mut counts = (0usize, 0usize, 0usize);
|
||||||
@@ -359,8 +350,8 @@ impl Render for IssuesView {
|
|||||||
|
|
||||||
let count = self.visible_issues.len();
|
let count = self.visible_issues.len();
|
||||||
|
|
||||||
// The virtual list's item count comes from `item_sizes`; rebuild it
|
// The virtual list's item count comes from `item_sizes`.
|
||||||
// whenever the filtered issue count changes.
|
// Rebuild it whenever the filtered issue count changes.
|
||||||
if count != self.issue_len {
|
if count != self.issue_len {
|
||||||
self.issue_len = count;
|
self.issue_len = count;
|
||||||
self.item_sizes = Rc::new(vec![size(px(0.), px(ISSUE_ROW_HEIGHT)); count]);
|
self.item_sizes = Rc::new(vec![size(px(0.), px(ISSUE_ROW_HEIGHT)); count]);
|
||||||
@@ -371,7 +362,7 @@ impl Render for IssuesView {
|
|||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
.image_cache(image_cache("issues", MAX_IMAGES))
|
.image_cache(gpui::retain_all("issues"))
|
||||||
.child(self.render_header(cx))
|
.child(self.render_header(cx))
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
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::path::PathBuf;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
@@ -7,97 +6,68 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
|||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||||
ScrollStrategy, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, relative,
|
SharedString, Size, Task, WeakEntity, Window, div, px, relative, size,
|
||||||
size,
|
|
||||||
};
|
};
|
||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
use gpui_component::clipboard::Clipboard;
|
use gpui_component::clipboard::Clipboard;
|
||||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||||
use gpui_component::form::{field, v_form};
|
use gpui_component::form::{field, v_form};
|
||||||
use gpui_component::input::{Textarea, TextareaState};
|
use gpui_component::input::{Textarea, TextareaState};
|
||||||
use gpui_component::list::ListItem;
|
|
||||||
use gpui_component::scroll::{ScrollableElement, Scrollbar};
|
use gpui_component::scroll::{ScrollableElement, Scrollbar};
|
||||||
use gpui_component::spinner::Spinner;
|
use gpui_component::spinner::Spinner;
|
||||||
use gpui_component::tab::{Tab, TabBar};
|
use gpui_component::tab::{Tab, TabBar};
|
||||||
use gpui_component::tag::Tag;
|
|
||||||
use gpui_component::tree::{TreeEntry, TreeState, tree};
|
|
||||||
use gpui_component::{
|
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,
|
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_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_state::{Backend, GitStore, ProfileStore, RepoStore};
|
||||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge};
|
||||||
use signed_ui::{UserAvatar, placeholder, status_badge, tree_row};
|
|
||||||
use utils::{relative_time, relative_time_secs};
|
use utils::{relative_time, relative_time_secs};
|
||||||
|
|
||||||
use super::diff::CommitDiffView;
|
use super::diff::{CommitDiffView, DiffPane};
|
||||||
use super::helpers::{
|
use super::helpers::{comment_form, comments_section, pr_roots, sidebar_section};
|
||||||
DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Width of the changed-files column.
|
/// Height of one commit row in the commits tab's virtual list.
|
||||||
const TREE_WIDTH: f32 = 260.;
|
const ROW_HEIGHT: f32 = 37.;
|
||||||
|
|
||||||
/// 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.;
|
|
||||||
|
|
||||||
/// Detail panel of a single pull request.
|
/// Detail panel of a single pull request.
|
||||||
pub struct PullRequestDetailView {
|
pub struct PullRequestDetailView {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
/// Dock area new panels (commit diffs) are added to.
|
/// Dock area where new panels, e.g. commit diffs, are added.
|
||||||
dock_area: WeakEntity<DockArea>,
|
dock_area: WeakEntity<DockArea>,
|
||||||
/// Repo store holding the PR, its status and comments.
|
/// Repo store holding the PR, its status and comments.
|
||||||
store: Entity<RepoStore>,
|
store: Entity<RepoStore>,
|
||||||
/// Event id of the root PR event (kind 1618; updates are revisions).
|
/// Event id of the root PR event, kind 1618.
|
||||||
|
/// Updates are revisions.
|
||||||
pr_id: EventId,
|
pr_id: EventId,
|
||||||
/// Input state of the "leave a comment" textarea.
|
/// Input state of the comment textarea.
|
||||||
comment_input: Entity<TextareaState>,
|
comment_input: Entity<TextareaState>,
|
||||||
/// Display name of the repository, for panels opened from here.
|
/// Display name of the repository, for panels opened from here.
|
||||||
repo_name: SharedString,
|
repo_name: SharedString,
|
||||||
/// Local clone the PR's git changes come from; `None` while the diff is
|
/// Local clone the PR's git changes come from.
|
||||||
/// parsed from the nostr patch set (no commit diff viewer then).
|
|
||||||
worktree: Option<PathBuf>,
|
worktree: Option<PathBuf>,
|
||||||
/// Root PR's content, shown as plain text.
|
/// Root PR's content, shown as plain text.
|
||||||
description: SharedString,
|
description: SharedString,
|
||||||
/// Tip commit of the PR: the latest update's `c` tag, else the root's.
|
/// Tip commit of the PR, the latest update's `c` tag or the root's.
|
||||||
current_commit: Option<SharedString>,
|
current_commit: Option<SharedString>,
|
||||||
/// Commits of the patch series, in patch order (oldest first).
|
/// Commits of the patch series, in patch order, oldest first.
|
||||||
commits: Vec<FileCommit>,
|
commits: Vec<FileCommit>,
|
||||||
/// Parsed file changes of the patch; `None` while loading or on failure.
|
|
||||||
diff: Option<CommitDiff>,
|
|
||||||
/// The patch is being parsed on a background task.
|
/// The patch is being parsed on a background task.
|
||||||
loading: bool,
|
loading: bool,
|
||||||
error: Option<SharedString>,
|
error: Option<SharedString>,
|
||||||
/// Active header tab: 0 = Discussion, 1 = Files, 2 = Commits.
|
/// Active header tab, 0 = Discussion, 1 = Files, 2 = Commits.
|
||||||
active_tab: usize,
|
active_tab: usize,
|
||||||
/// Changed-files explorer state.
|
/// Changed-files explorer and per-file diff, like the commit and compare views.
|
||||||
tree_state: Entity<TreeState>,
|
pane: Entity<DiffPane>,
|
||||||
/// Path of the file whose diff is shown in the detail column.
|
/// Per-row heights of the commits tab's virtual list, built when the patch series loads.
|
||||||
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.
|
|
||||||
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
|
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
/// Virtual list state of the commits tab.
|
/// Virtual list state of the commits tab.
|
||||||
commit_scroll_handle: VirtualListScrollHandle,
|
commit_scroll_handle: VirtualListScrollHandle,
|
||||||
/// Comment bodies as shared strings, keyed by comment event ID, so
|
/// In-flight tasks, finished tasks are pruned on every push.
|
||||||
/// 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.
|
|
||||||
tasks: Vec<Task<Result<(), anyhow::Error>>>,
|
tasks: Vec<Task<Result<(), anyhow::Error>>>,
|
||||||
/// Subscriptions keeping the view live as the store refreshes.
|
|
||||||
_subscriptions: Vec<Subscription>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PullRequestDetailView {
|
impl PullRequestDetailView {
|
||||||
@@ -108,26 +78,12 @@ impl PullRequestDetailView {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> 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 =
|
let comment_input =
|
||||||
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
|
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
|
||||||
|
|
||||||
// Same display name as the repo detail panel's title.
|
|
||||||
let repo_name = store
|
|
||||||
.read(cx)
|
|
||||||
.announcement
|
|
||||||
.as_ref()
|
|
||||||
.map(|announcement| {
|
|
||||||
announcement
|
|
||||||
.name
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
// Re-render when the store refreshes (new comments, status changes).
|
|
||||||
let subscriptions = vec![cx.observe(&store, |_this, _store, cx| cx.notify())];
|
|
||||||
|
|
||||||
// Defer loading until the window is ready, like the commit diff view.
|
// Defer loading until the window is ready, like the commit diff view.
|
||||||
cx.defer_in(window, |this, window, cx| {
|
cx.defer_in(window, |this, window, cx| {
|
||||||
this.load(window, cx);
|
this.load(window, cx);
|
||||||
@@ -144,29 +100,17 @@ impl PullRequestDetailView {
|
|||||||
description: SharedString::default(),
|
description: SharedString::default(),
|
||||||
current_commit: None,
|
current_commit: None,
|
||||||
commits: Vec::new(),
|
commits: Vec::new(),
|
||||||
diff: None,
|
|
||||||
loading: true,
|
loading: true,
|
||||||
error: None,
|
error: None,
|
||||||
active_tab: 0,
|
active_tab: 0,
|
||||||
tree_state,
|
pane,
|
||||||
selected_file: None,
|
|
||||||
rows: Vec::new(),
|
|
||||||
item_sizes: Rc::new(Vec::new()),
|
|
||||||
scroll_handle: VirtualListScrollHandle::new(),
|
|
||||||
commit_item_sizes: Rc::new(Vec::new()),
|
commit_item_sizes: Rc::new(Vec::new()),
|
||||||
commit_scroll_handle: VirtualListScrollHandle::new(),
|
commit_scroll_handle: VirtualListScrollHandle::new(),
|
||||||
contents: HashMap::new(),
|
|
||||||
tasks: Vec::new(),
|
tasks: Vec::new(),
|
||||||
_subscriptions: subscriptions,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot the PR events from the store, then compute the file changes
|
/// Snapshot the PR events from the store.
|
||||||
/// and commit list on a background task and populate the tree.
|
|
||||||
///
|
|
||||||
/// The changes come from the PR's patch set (NIP-34 `e`-linked patch
|
|
||||||
/// events) when present; otherwise from the git repository (`c`,
|
|
||||||
/// `clone` and `merge-base` tags), diffing the `merge-base..tip` range.
|
|
||||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.loading = true;
|
self.loading = true;
|
||||||
self.error = None;
|
self.error = None;
|
||||||
@@ -176,6 +120,7 @@ impl PullRequestDetailView {
|
|||||||
|
|
||||||
let (description, patch, current_commit, merge_base, clone_urls, addr, has_patch_link) = {
|
let (description, patch, current_commit, merge_base, clone_urls, addr, has_patch_link) = {
|
||||||
let store = self.store.read(cx);
|
let store = self.store.read(cx);
|
||||||
|
|
||||||
let Some(root) = store
|
let Some(root) = store
|
||||||
.pull_requests
|
.pull_requests
|
||||||
.iter()
|
.iter()
|
||||||
@@ -186,19 +131,24 @@ impl PullRequestDetailView {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let update = latest_update(store.pull_requests.iter(), root);
|
let update = latest_update(store.pull_requests.iter(), root);
|
||||||
|
|
||||||
let tip = update
|
let tip = update
|
||||||
.and_then(current_commit_of)
|
.and_then(current_commit_of)
|
||||||
.or_else(|| current_commit_of(root));
|
.or_else(|| current_commit_of(root));
|
||||||
|
|
||||||
let base = update
|
let base = update
|
||||||
.and_then(merge_base_of)
|
.and_then(merge_base_of)
|
||||||
.or_else(|| merge_base_of(root));
|
.or_else(|| merge_base_of(root));
|
||||||
|
|
||||||
let clone_urls = clone_urls_of(root).or_else(|| {
|
let clone_urls = clone_urls_of(root).or_else(|| {
|
||||||
store
|
store
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|a| a.clone.iter().map(ToString::to_string).collect())
|
.map(|a| a.clone.iter().map(ToString::to_string).collect())
|
||||||
});
|
});
|
||||||
|
|
||||||
(
|
(
|
||||||
root.content.clone(),
|
root.content.clone(),
|
||||||
pull_request_patch(root, store.patches.iter()),
|
pull_request_patch(root, store.patches.iter()),
|
||||||
@@ -209,16 +159,17 @@ impl PullRequestDetailView {
|
|||||||
root.tags.event_ids().next().is_some(),
|
root.tags.event_ids().next().is_some(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
self.description = description.into();
|
self.description = description.into();
|
||||||
|
|
||||||
let task = cx.spawn_in(window, async move |this, cx| {
|
let task = cx.spawn_in(window, async move |this, cx| {
|
||||||
// Parse the nostr patch set first.
|
|
||||||
let nostr_diff = cx
|
let nostr_diff = cx
|
||||||
.background_spawn({
|
.background_spawn({
|
||||||
let patch = patch.clone();
|
let patch = patch.clone();
|
||||||
async move { patch_diffs(&patch) }
|
async move { patch_diffs(&patch) }
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let nostr_commits = cx
|
let nostr_commits = cx
|
||||||
.background_spawn({
|
.background_spawn({
|
||||||
let patch = patch.clone();
|
let patch = patch.clone();
|
||||||
@@ -226,13 +177,13 @@ impl PullRequestDetailView {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// PRs without patch events (e.g. published by ngit) carry their
|
// PRs without patch events, e.g. published by ngit, carry their changes in git.
|
||||||
// changes in the git repository: fetch the clone and diff the
|
// Fetch the clone and diff the `merge-base..tip` range.
|
||||||
// `merge-base..tip` range.
|
|
||||||
let use_nostr = match &nostr_diff {
|
let use_nostr = match &nostr_diff {
|
||||||
Ok(diff) => has_patch_link || !diff.files.is_empty(),
|
Ok(diff) => has_patch_link || !diff.files.is_empty(),
|
||||||
Err(_) => true,
|
Err(_) => true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let git = if use_nostr {
|
let git = if use_nostr {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -241,19 +192,22 @@ impl PullRequestDetailView {
|
|||||||
let clone_urls = clone_urls.clone();
|
let clone_urls = clone_urls.clone();
|
||||||
let base = merge_base.clone();
|
let base = merge_base.clone();
|
||||||
let tip = current_commit.clone();
|
let tip = current_commit.clone();
|
||||||
|
|
||||||
Some(
|
Some(
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
||||||
|
|
||||||
let workdir = repo
|
let workdir = repo
|
||||||
.workdir()
|
.workdir()
|
||||||
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
|
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
|
||||||
.to_path_buf();
|
.to_path_buf();
|
||||||
|
|
||||||
let tip =
|
let tip =
|
||||||
tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
|
tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
|
||||||
|
|
||||||
let base = match base {
|
let base = match base {
|
||||||
Some(base) => base,
|
Some(base) => base,
|
||||||
// No `merge-base` tag: use the merge base of the
|
// No `merge-base` tag. Use the merge base of the tip and the default branch.
|
||||||
// tip with the default branch.
|
|
||||||
None => {
|
None => {
|
||||||
let head = repo
|
let head = repo
|
||||||
.head_id()
|
.head_id()
|
||||||
@@ -262,9 +216,11 @@ impl PullRequestDetailView {
|
|||||||
repo.merge_base(tip_id, head)?.to_string()
|
repo.merge_base(tip_id, head)?.to_string()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?;
|
let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?;
|
||||||
let commits =
|
let commits =
|
||||||
signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?;
|
signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?;
|
||||||
|
|
||||||
Ok::<_, anyhow::Error>((diff, commits, workdir))
|
Ok::<_, anyhow::Error>((diff, commits, workdir))
|
||||||
})
|
})
|
||||||
.await,
|
.await,
|
||||||
@@ -281,37 +237,18 @@ impl PullRequestDetailView {
|
|||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.worktree = worktree;
|
this.worktree = worktree;
|
||||||
this.current_commit = current_commit.map(SharedString::from);
|
this.current_commit = current_commit.map(SharedString::from);
|
||||||
this.commit_item_sizes =
|
this.commit_item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]);
|
||||||
Rc::new(vec![size(px(0.), px(PR_COMMIT_ROW_HEIGHT)); commits.len()]);
|
|
||||||
this.commits = commits;
|
this.commits = commits;
|
||||||
|
|
||||||
match diff {
|
match diff {
|
||||||
Ok(diff) => {
|
Ok(diff) => {
|
||||||
let mut paths: Vec<PathBuf> = diff
|
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
|
||||||
.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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
this.error = Some(error.to_string().into());
|
this.error = Some(error.to_string().into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -322,27 +259,6 @@ impl PullRequestDetailView {
|
|||||||
self.tasks.push(task);
|
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.
|
/// Open the diff of `commit_id` in the bottom dock of the area.
|
||||||
fn open_commit_diff(
|
fn open_commit_diff(
|
||||||
&mut self,
|
&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 {
|
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let active = self.active_tab;
|
let active = self.active_tab;
|
||||||
let files_count = self.diff.as_ref().map(|diff| diff.files.len());
|
let files_count = self.pane.read(cx).diff().map(|diff| diff.files.len());
|
||||||
let commits_count = if self.commits.is_empty() {
|
let commits_count = if self.commits.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -589,29 +310,19 @@ impl PullRequestDetailView {
|
|||||||
Tab::new()
|
Tab::new()
|
||||||
.label("Files")
|
.label("Files")
|
||||||
.when_some(files_count, |this, count| {
|
.when_some(files_count, |this, count| {
|
||||||
this.suffix(
|
this.suffix(CountBadge::new(count))
|
||||||
Tag::secondary()
|
|
||||||
.xsmall()
|
|
||||||
.child(SharedString::from(count.to_string())),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Tab::new()
|
Tab::new()
|
||||||
.label("Commits")
|
.label("Commits")
|
||||||
.when_some(commits_count, |this, count| {
|
.when_some(commits_count, |this, count| {
|
||||||
this.suffix(
|
this.suffix(CountBadge::new(count))
|
||||||
Tag::secondary()
|
|
||||||
.xsmall()
|
|
||||||
.child(SharedString::from(count.to_string())),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discussion tab: author, description and comments like the issue
|
|
||||||
/// panel, with the comment form at the end and a sidebar on the right.
|
|
||||||
fn render_discussion(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_discussion(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
if self.loading {
|
if self.loading {
|
||||||
return v_flex()
|
return v_flex()
|
||||||
@@ -686,111 +397,49 @@ impl PullRequestDetailView {
|
|||||||
this.child(div().text_sm().child(self.description.clone()))
|
this.child(div().text_sm().child(self.description.clone()))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.child(self.render_comments(&root_id, cx))
|
.child(comments_section(&self.store, root_id, cx))
|
||||||
.child(self.render_form(&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()
|
.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 {
|
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()
|
h_flex()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.w_full()
|
.w_full()
|
||||||
.min_h_0()
|
.min_h_0()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.child(self.render_tree_column(cx))
|
.child(self.pane.clone())
|
||||||
.child(self.render_detail_column(cx))
|
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full-height Commits tab: every commit of the patch series, or a
|
/// Full-height Commits tab.
|
||||||
/// status message while loading / when there are none.
|
///
|
||||||
|
/// Every commit of the patch series, or a status message while loading or empty.
|
||||||
fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
if self.loading {
|
if self.loading {
|
||||||
return v_flex()
|
return v_flex()
|
||||||
@@ -838,8 +487,9 @@ impl PullRequestDetailView {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of the commits tab: id, summary, author and time. Clicking a
|
/// One row of the commits tab, id, summary, author and time.
|
||||||
/// row opens the commit's diff in the bottom dock.
|
///
|
||||||
|
/// Clicking a row opens the commit's diff in the bottom dock.
|
||||||
fn render_commit_row(
|
fn render_commit_row(
|
||||||
&self,
|
&self,
|
||||||
ix: usize,
|
ix: usize,
|
||||||
@@ -852,7 +502,7 @@ impl PullRequestDetailView {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.id(ix)
|
.id(ix)
|
||||||
.px_4()
|
.px_4()
|
||||||
.h(px(PR_COMMIT_ROW_HEIGHT))
|
.h(px(ROW_HEIGHT))
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.items_center()
|
.items_center()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
@@ -882,8 +532,8 @@ impl PullRequestDetailView {
|
|||||||
.child(SharedString::from(meta)),
|
.child(SharedString::from(meta)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
// Commits parsed from the nostr patch set may not exist in any
|
// Commits parsed from the nostr patch set may not exist in any local clone.
|
||||||
// local clone; only git-backed PRs open a diff viewer.
|
// Only git-backed PRs open a diff viewer.
|
||||||
.when_some(self.worktree.clone(), |this, worktree| {
|
.when_some(self.worktree.clone(), |this, worktree| {
|
||||||
this.on_click(cx.listener(move |this, _event, window, cx| {
|
this.on_click(cx.listener(move |this, _event, window, cx| {
|
||||||
this.open_commit_diff(worktree.clone(), &id, window, cx);
|
this.open_commit_diff(worktree.clone(), &id, window, cx);
|
||||||
@@ -892,117 +542,7 @@ impl PullRequestDetailView {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One comment card, same design as the issue panel: avatar, author,
|
/// Always-visible header with a status badge and title, like the issue panel.
|
||||||
/// "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.
|
|
||||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let current_commit = self.current_commit.clone();
|
let current_commit = self.current_commit.clone();
|
||||||
let (title, status, branch, author) = {
|
let (title, status, branch, author) = {
|
||||||
@@ -1022,7 +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 backend = Backend::global(cx);
|
||||||
let can_update = backend.read(cx).current_user() == Some(author);
|
let can_update = backend.read(cx).current_user() == Some(author);
|
||||||
|
|
||||||
@@ -1104,8 +644,7 @@ impl PullRequestDetailView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the "update pull request" dialog: a patch input that submits a new
|
/// Open the update pull request dialog.
|
||||||
/// revision through [`RepoStore::update_pull_request`] when confirmed.
|
|
||||||
fn open_update_pull_request_dialog(
|
fn open_update_pull_request_dialog(
|
||||||
store: Entity<RepoStore>,
|
store: Entity<RepoStore>,
|
||||||
root: Event,
|
root: Event,
|
||||||
@@ -1115,8 +654,8 @@ fn open_update_pull_request_dialog(
|
|||||||
let patch = cx.new(|cx| {
|
let patch = cx.new(|cx| {
|
||||||
TextareaState::new(window, cx).placeholder("Paste the updated `git format-patch` output...")
|
TextareaState::new(window, cx).placeholder("Paste the updated `git format-patch` output...")
|
||||||
});
|
});
|
||||||
// Both the dialog body and the submit button capture the root event;
|
// Both the dialog body and submit button capture the root event.
|
||||||
// share it instead of cloning into each closure.
|
// Share it instead of cloning into each closure.
|
||||||
let root = Rc::new(root);
|
let root = Rc::new(root);
|
||||||
|
|
||||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||||
@@ -1166,20 +705,9 @@ fn open_update_pull_request_dialog(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One sidebar section title.
|
/// The `c` tag of a PR event, the commit the proposal points at.
|
||||||
fn sidebar_title(text: &str, cx: &App) -> AnyElement {
|
fn current_commit_of(root: &Event) -> Option<String> {
|
||||||
div()
|
root.tags
|
||||||
.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
|
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||||
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
|
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
|
/// The `merge-base` tag of a PR event, as hex.
|
||||||
/// target branch), as hex.
|
///
|
||||||
|
/// The most recent common ancestor with the target branch.
|
||||||
fn merge_base_of(event: &Event) -> Option<String> {
|
fn merge_base_of(event: &Event) -> Option<String> {
|
||||||
event
|
event
|
||||||
.tags
|
.tags
|
||||||
@@ -1199,8 +728,9 @@ fn merge_base_of(event: &Event) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `clone` tag of a PR event (URLs where the proposed branch can be
|
/// The `clone` tag of a PR event.
|
||||||
/// fetched), or `None` if the PR has none.
|
///
|
||||||
|
/// URLs where the proposed branch can be fetched, or `None` if the PR has none.
|
||||||
fn clone_urls_of(event: &Event) -> Option<Vec<String>> {
|
fn clone_urls_of(event: &Event) -> Option<Vec<String>> {
|
||||||
event
|
event
|
||||||
.tags
|
.tags
|
||||||
@@ -1222,9 +752,7 @@ fn branch_name_of(event: &Event) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The latest PR update (kind 1619) revising `root`, found via its NIP-22
|
/// The latest PR update, kind 1619, revising `root`.
|
||||||
/// `E` tag pointing at the root PR event. Only updates by the PR author
|
|
||||||
/// count: the tip of a PR is only mutable by its author (NIP-34).
|
|
||||||
fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &Event) -> Option<&'a Event> {
|
fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &Event) -> Option<&'a Event> {
|
||||||
let root_hex = root.id.to_hex();
|
let root_hex = root.id.to_hex();
|
||||||
events
|
events
|
||||||
@@ -1238,8 +766,9 @@ fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &Event) -> O
|
|||||||
.max_by_key(|e| e.created_at)
|
.max_by_key(|e| e.created_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One-line commit metadata for the commits list: author and relative time,
|
/// One-line commit metadata for the commits list.
|
||||||
/// whichever is available.
|
///
|
||||||
|
/// Author and relative time, whichever is available.
|
||||||
fn commit_meta(commit: &FileCommit) -> String {
|
fn commit_meta(commit: &FileCommit) -> String {
|
||||||
let author = commit.author.trim();
|
let author = commit.author.trim();
|
||||||
let time = commit.time > 0;
|
let time = commit.time > 0;
|
||||||
@@ -1281,7 +810,7 @@ impl Focusable for PullRequestDetailView {
|
|||||||
impl Render for PullRequestDetailView {
|
impl Render for PullRequestDetailView {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(image_cache("pull-request-detail", MAX_IMAGES))
|
.image_cache(gpui::retain_all("pull-request-detail"))
|
||||||
.id("pull-request-detail")
|
.id("pull-request-detail")
|
||||||
.size_full()
|
.size_full()
|
||||||
.min_h_0()
|
.min_h_0()
|
||||||
@@ -1357,8 +886,7 @@ mod tests {
|
|||||||
created_at,
|
created_at,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
// An update revising a different PR must be ignored even though it
|
// An update revising a different PR must be ignored even though it is newer.
|
||||||
// is newer.
|
|
||||||
let unrelated = signed(
|
let unrelated = signed(
|
||||||
Kind::GitPullRequestUpdate,
|
Kind::GitPullRequestUpdate,
|
||||||
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
|
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
|
||||||
@@ -1386,8 +914,8 @@ mod tests {
|
|||||||
.finalize(&other)
|
.finalize(&other)
|
||||||
.expect("signed event");
|
.expect("signed event");
|
||||||
|
|
||||||
// The tip of a PR is only mutable by its author: a newer update
|
// The tip of a PR is only mutable by its author.
|
||||||
// from anyone else must not win.
|
// A newer update from anyone else must not win.
|
||||||
assert!(latest_update([&stranger, &root].into_iter(), &root).is_none());
|
assert!(latest_update([&stranger, &root].into_iter(), &root).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use assets::CustomIconName;
|
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::prelude::*;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||||
@@ -16,7 +16,6 @@ use gpui_component::{
|
|||||||
use nostr::prelude::{EventId, Kind};
|
use nostr::prelude::{EventId, Kind};
|
||||||
use signed_core::{RepoStatus, activity_subject};
|
use signed_core::{RepoStatus, activity_subject};
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileStore, RepoStore};
|
||||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
|
||||||
use signed_ui::{DropdownButton, SegmentButton, UserAvatar, placeholder, status_badge};
|
use signed_ui::{DropdownButton, SegmentButton, UserAvatar, placeholder, status_badge};
|
||||||
use utils::relative_time;
|
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::pull_request_detail::PullRequestDetailView;
|
||||||
use super::send_patch::open_send_patch_panel;
|
use super::send_patch::open_send_patch_panel;
|
||||||
|
|
||||||
/// Height of one pull request row in the virtual list; same layout as an
|
/// Height of one pull request row in the virtual list.
|
||||||
/// issue row.
|
const ROW_HEIGHT: f32 = 73.;
|
||||||
const PR_ROW_HEIGHT: f32 = 73.;
|
|
||||||
|
|
||||||
/// Status filter of the pull request list, chosen via the header's filter
|
/// Status filter of the pull request list, chosen via the header's filter buttons.
|
||||||
/// buttons.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum PullRequestFilter {
|
enum PullRequestFilter {
|
||||||
/// Every pull request, regardless of status.
|
/// Every pull request, regardless of status.
|
||||||
@@ -70,17 +67,11 @@ pub struct PullRequestsView {
|
|||||||
filter: PullRequestFilter,
|
filter: PullRequestFilter,
|
||||||
/// Per-row heights of the virtual list.
|
/// Per-row heights of the virtual list.
|
||||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
/// Number of rows [`Self::item_sizes`] was built for (the filtered
|
/// The filtered pull request count [`Self::item_sizes`] was built for.
|
||||||
/// pull request count); rebuilt on change.
|
|
||||||
pr_len: usize,
|
pr_len: usize,
|
||||||
/// Indices into the store's `pull_requests` matching [`Self::filter`]
|
/// Indices into the store's `pull_requests` matching [`Self::filter`].
|
||||||
/// (root PR events only; updates are revisions of the root); the
|
|
||||||
/// virtual list renders this slice. Rebuilt only when the store
|
|
||||||
/// version or the filter changes, keyed by [`Self::cache_key`].
|
|
||||||
visible_prs: Vec<usize>,
|
visible_prs: Vec<usize>,
|
||||||
/// Header counts `(total, open, closed, draft, merged)` of the root
|
/// Header counts `(total, open, closed, draft, merged)`.
|
||||||
/// pull requests only (revisions are not separate PRs), rebuilt with
|
|
||||||
/// [`Self::visible_prs`].
|
|
||||||
counts: (usize, usize, usize, usize, usize),
|
counts: (usize, usize, usize, usize, usize),
|
||||||
/// Store version and filter the cached rows/counts were built from.
|
/// Store version and filter the cached rows/counts were built from.
|
||||||
cache_key: Option<(u64, PullRequestFilter)>,
|
cache_key: Option<(u64, PullRequestFilter)>,
|
||||||
@@ -92,10 +83,11 @@ impl PullRequestsView {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
dock_area: WeakEntity<DockArea>,
|
dock_area: WeakEntity<DockArea>,
|
||||||
store: Entity<RepoStore>,
|
store: Entity<RepoStore>,
|
||||||
repo_name: SharedString,
|
|
||||||
_window: &mut Window,
|
_window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let repo_name = store.read(cx).name();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
dock_area,
|
dock_area,
|
||||||
@@ -111,7 +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(
|
fn open_pull_request_detail(
|
||||||
&mut self,
|
&mut self,
|
||||||
pr_id: EventId,
|
pr_id: EventId,
|
||||||
@@ -133,12 +125,13 @@ impl PullRequestsView {
|
|||||||
});
|
});
|
||||||
|
|
||||||
dock_area.update(cx, |dock_area, 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 pull request list; `ix` is the row index and
|
/// Render one row of the pull request list.
|
||||||
/// `pr_ix` the index of the pull request in the store's `pull_requests`.
|
///
|
||||||
|
/// `ix` is the row index, `pr_ix` the index in the store's `pull_requests`.
|
||||||
fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let pr = &self.store.read(cx).pull_requests[pr_ix];
|
let pr = &self.store.read(cx).pull_requests[pr_ix];
|
||||||
let pr_id = pr.id;
|
let pr_id = pr.id;
|
||||||
@@ -203,8 +196,7 @@ impl PullRequestsView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
// Counts of the last list rebuild (`render` rebuilds first when the
|
// Counts of the last list rebuild.
|
||||||
// store version or filter changed, so this is never stale).
|
|
||||||
let (total, open, closed, draft, merged) = self.counts;
|
let (total, open, closed, draft, merged) = self.counts;
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -339,9 +331,9 @@ impl Render for PullRequestsView {
|
|||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let filter = self.filter;
|
let filter = self.filter;
|
||||||
|
|
||||||
// Rebuild the filtered rows and header counts only when the store
|
// Rows and counts are rebuilt only when the store refreshed or filter changed.
|
||||||
// refreshed or the filter changed; other renders reuse the cache.
|
|
||||||
let version = self.store.read(cx).version();
|
let version = self.store.read(cx).version();
|
||||||
|
|
||||||
if self.cache_key != Some((version, filter)) {
|
if self.cache_key != Some((version, filter)) {
|
||||||
let store = self.store.read(cx);
|
let store = self.store.read(cx);
|
||||||
let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
|
let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
|
||||||
@@ -350,43 +342,43 @@ impl Render for PullRequestsView {
|
|||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(ix, pr)| {
|
.filter_map(|(ix, pr)| {
|
||||||
// Kind-30620 patches are revisions of a root PR (NIP-34),
|
|
||||||
// not separate pull requests: count only root events, or
|
|
||||||
// the header counts inflate with every revision (which
|
|
||||||
// also default to `Open` in `status_of`).
|
|
||||||
if pr.kind != Kind::GitPullRequest {
|
if pr.kind != Kind::GitPullRequest {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let status = store.status_of(pr);
|
let status = store.status_of(pr);
|
||||||
counts.0 += 1;
|
counts.0 += 1;
|
||||||
|
|
||||||
match status {
|
match status {
|
||||||
RepoStatus::Open => counts.1 += 1,
|
RepoStatus::Open => counts.1 += 1,
|
||||||
RepoStatus::Closed => counts.2 += 1,
|
RepoStatus::Closed => counts.2 += 1,
|
||||||
RepoStatus::Draft => counts.3 += 1,
|
RepoStatus::Draft => counts.3 += 1,
|
||||||
RepoStatus::Applied => counts.4 += 1,
|
RepoStatus::Applied => counts.4 += 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
filter.matches(status).then_some(ix)
|
filter.matches(status).then_some(ix)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
self.counts = counts;
|
self.counts = counts;
|
||||||
self.cache_key = Some((version, filter));
|
self.cache_key = Some((version, filter));
|
||||||
}
|
}
|
||||||
|
|
||||||
let count = self.visible_prs.len();
|
let count = self.visible_prs.len();
|
||||||
|
|
||||||
// The virtual list's item count comes from `item_sizes`; rebuild it
|
// The virtual list's item count comes from `item_sizes`.
|
||||||
// whenever the filtered pull request count changes.
|
// Rebuild it whenever the filtered pull request count changes.
|
||||||
if count != self.pr_len {
|
if count != self.pr_len {
|
||||||
self.pr_len = count;
|
self.pr_len = count;
|
||||||
self.item_sizes = Rc::new(vec![size(px(0.), px(PR_ROW_HEIGHT)); count]);
|
self.item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); count]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let sizes = self.item_sizes.clone();
|
let sizes = self.item_sizes.clone();
|
||||||
let scroll_handle = self.scroll_handle.clone();
|
let scroll_handle = self.scroll_handle.clone();
|
||||||
let view = cx.entity().clone();
|
let view = cx.entity().clone();
|
||||||
|
|
||||||
// Non-fatal warnings and errors of the last action (e.g. creating
|
// Non-fatal warnings and errors of the last action, like creating or updating a PR.
|
||||||
// or updating a PR), shown as dismissible banners above the list.
|
// Shown as dismissible banners above the list.
|
||||||
let (last_error, last_warning) = {
|
let (last_error, last_warning) = {
|
||||||
let store = self.store.read(cx);
|
let store = self.store.read(cx);
|
||||||
(store.last_error.clone(), store.last_warning.clone())
|
(store.last_error.clone(), store.last_warning.clone())
|
||||||
@@ -394,7 +386,7 @@ impl Render for PullRequestsView {
|
|||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.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| {
|
.on_action(cx.listener(|this, action: &RepoAction, window, cx| {
|
||||||
if action == &RepoAction::SendPatch {
|
if action == &RepoAction::SendPatch {
|
||||||
open_send_patch_panel(this.dock_area.clone(), this.store.clone(), window, cx);
|
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::prelude::*;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
|
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
|
||||||
Subscription, WeakEntity, Window, div, px,
|
WeakEntity, Window, div, px,
|
||||||
};
|
};
|
||||||
use gpui_base::{Button as BaseButton, StyledExt};
|
use gpui_base::{Button as BaseButton, StyledExt};
|
||||||
use gpui_component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
use gpui_component::input::{Input, InputState, Textarea, TextareaState};
|
||||||
use gpui_component::scroll::ScrollableElement;
|
use gpui_component::scroll::ScrollableElement;
|
||||||
use gpui_component::spinner::Spinner;
|
use gpui_component::spinner::Spinner;
|
||||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
|
||||||
@@ -19,17 +19,16 @@ pub struct SendPatchView {
|
|||||||
store: Entity<RepoStore>,
|
store: Entity<RepoStore>,
|
||||||
/// Display name of the repository, for the panel title.
|
/// Display name of the repository, for the panel title.
|
||||||
repo_name: SharedString,
|
repo_name: SharedString,
|
||||||
/// Title input (required).
|
/// Title input, required.
|
||||||
subject: Entity<InputState>,
|
subject: Entity<InputState>,
|
||||||
/// Description input (optional).
|
/// Description input, optional.
|
||||||
description: Entity<TextareaState>,
|
description: Entity<TextareaState>,
|
||||||
/// The pasted `git format-patch` output (required).
|
/// The pasted `git format-patch` output, required.
|
||||||
patch: Entity<TextareaState>,
|
patch: Entity<TextareaState>,
|
||||||
/// A submit is in flight.
|
/// A submit is in flight.
|
||||||
submitting: bool,
|
submitting: bool,
|
||||||
/// Error of the last submit attempt (keeps the panel open).
|
/// Error of the last submit attempt, it keeps the panel open.
|
||||||
error: Option<SharedString>,
|
error: Option<SharedString>,
|
||||||
_subscriptions: Vec<Subscription>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SendPatchView {
|
impl SendPatchView {
|
||||||
@@ -41,22 +40,14 @@ impl SendPatchView {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
let repo_name = store.read(cx).name();
|
let repo_name = store.read(cx).name();
|
||||||
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
|
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
|
||||||
|
|
||||||
let description = cx
|
let description = cx
|
||||||
.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change (optional)"));
|
.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change (optional)"));
|
||||||
|
|
||||||
let patch = cx.new(|cx| {
|
let patch = cx.new(|cx| {
|
||||||
TextareaState::new(window, cx).placeholder("diff --git a/file.txt b/file.txt\nindex 1234567..abcdefg 100644\n--- a/file.txt\n+++ b/file.txt")
|
TextareaState::new(window, cx).placeholder("diff --git a/file.txt b/file.txt\nindex 1234567..abcdefg 100644\n--- a/file.txt\n+++ b/file.txt")
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-evaluate the Send button's enabled state as the inputs change.
|
|
||||||
let subscriptions = vec![
|
|
||||||
cx.subscribe(&subject, |_this, _state, _event: &InputEvent, cx| {
|
|
||||||
cx.notify();
|
|
||||||
}),
|
|
||||||
cx.subscribe(&patch, |_this, _state, _event: &InputEvent, cx| {
|
|
||||||
cx.notify();
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
dock_area,
|
dock_area,
|
||||||
@@ -67,24 +58,23 @@ impl SendPatchView {
|
|||||||
patch,
|
patch,
|
||||||
submitting: false,
|
submitting: false,
|
||||||
error: None,
|
error: None,
|
||||||
_subscriptions: subscriptions,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish the pull request from the pasted patch. The store validates
|
/// Publish the pull request from the pasted patch.
|
||||||
/// synchronously (patch shape, per-part size, sign-in); on failure the
|
|
||||||
/// panel stays open with the error inline, on success it closes — async
|
|
||||||
/// publish failures surface in the pull request list's banner.
|
|
||||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if self.submitting {
|
if self.submitting {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let subject = self.subject.read(cx).value().to_string();
|
let subject = self.subject.read(cx).value().to_string();
|
||||||
let description = self.description.read(cx).value().to_string();
|
let description = self.description.read(cx).value().to_string();
|
||||||
let patch = self.patch.read(cx).value().to_string();
|
let patch = self.patch.read(cx).value().to_string();
|
||||||
|
|
||||||
if patch.is_empty() {
|
if patch.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let store = self.store.clone();
|
let store = self.store.clone();
|
||||||
let dock_area = self.dock_area.clone();
|
let dock_area = self.dock_area.clone();
|
||||||
let entity = cx.entity().clone();
|
let entity = cx.entity().clone();
|
||||||
@@ -93,8 +83,7 @@ impl SendPatchView {
|
|||||||
self.error = None;
|
self.error = None;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
// Errors the store detects before publishing are returned
|
// Errors the store detects before publishing.
|
||||||
// synchronously through `last_error`.
|
|
||||||
let sync_error = store.update(cx, |store, cx| {
|
let sync_error = store.update(cx, |store, cx| {
|
||||||
store.open_pull_request(
|
store.open_pull_request(
|
||||||
(!subject.is_empty()).then_some(subject),
|
(!subject.is_empty()).then_some(subject),
|
||||||
@@ -128,6 +117,7 @@ impl SendPatchView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 panel = cx.new(|cx| SendPatchView::new(dock_area.clone(), store, window, cx));
|
||||||
|
|
||||||
let _ = dock_area.update(cx, |dock_area, 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_core::Announcement;
|
||||||
use signed_state::{ProfileStore, RepoListStore, Timestamp};
|
use signed_state::{ProfileStore, RepoListStore, Timestamp};
|
||||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
|
||||||
use signed_ui::{SegmentButton, UserAvatar};
|
use signed_ui::{SegmentButton, UserAvatar};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
@@ -24,13 +23,13 @@ use super::open_repo_panel;
|
|||||||
const COLUMNS: usize = 2;
|
const COLUMNS: usize = 2;
|
||||||
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.;
|
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.;
|
||||||
|
|
||||||
/// How many of the newest repositories the "Recent" sort shows.
|
/// How many of the newest repositories the `Recent` sort shows.
|
||||||
const RECENT_COUNT: usize = 10;
|
const RECENT_COUNT: usize = 10;
|
||||||
|
|
||||||
/// Sort of the explore list, chosen via the header's filter buttons.
|
/// Sort of the explore list, chosen via the header's filter buttons.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
enum RepoFilter {
|
enum RepoFilter {
|
||||||
/// Every repository, newest first (the store's default order).
|
/// Every repository in the store's default order, newest first.
|
||||||
All,
|
All,
|
||||||
#[default]
|
#[default]
|
||||||
/// Repositories ranked by total issues + pull requests + commits.
|
/// Repositories ranked by total issues + pull requests + commits.
|
||||||
@@ -40,15 +39,15 @@ enum RepoFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RepoFilter {
|
impl RepoFilter {
|
||||||
/// Indices into the store's `announcements` included by this filter, in
|
/// Indices into the store's `announcements` this filter includes, in display order.
|
||||||
/// display order, narrowed to repositories whose name (or id) contains
|
///
|
||||||
/// `query`; an empty query matches everything.
|
/// Narrowed to repositories whose name or id contains `query`.
|
||||||
fn visible(self, store: &RepoListStore, query: &str) -> Vec<usize> {
|
fn visible(self, store: &RepoListStore, query: &str) -> Vec<usize> {
|
||||||
let announcements = &store.announcements;
|
let announcements = &store.announcements;
|
||||||
let mut indices: Vec<usize> = (0..announcements.len()).collect();
|
let mut indices: Vec<usize> = (0..announcements.len()).collect();
|
||||||
|
|
||||||
// Narrow by the search query first, so "Recent" limits the matches
|
// Narrow by the search query first.
|
||||||
// and "Popular" ranks them.
|
// Recent then limits the matches and Popular ranks them.
|
||||||
let query = query.trim().to_lowercase();
|
let query = query.trim().to_lowercase();
|
||||||
if !query.is_empty() {
|
if !query.is_empty() {
|
||||||
indices.retain(|&ix| {
|
indices.retain(|&ix| {
|
||||||
@@ -93,10 +92,9 @@ pub struct RepoListView {
|
|||||||
filter: RepoFilter,
|
filter: RepoFilter,
|
||||||
/// Per-row heights of the virtual list.
|
/// Per-row heights of the virtual list.
|
||||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
/// Number of rows [`Self::item_sizes`] was built for (the filtered repo count).
|
/// Number of rows [`Self::item_sizes`] was built for, the filtered repo count.
|
||||||
repo_len: usize,
|
repo_len: usize,
|
||||||
/// Indices into the store's `announcements` matching [`Self::filter`],
|
/// Indices matching [`Self::filter`] into the store's `announcements`.
|
||||||
/// in display order; the virtual list renders this slice.
|
|
||||||
visible: Vec<usize>,
|
visible: Vec<usize>,
|
||||||
/// Search box filtering repositories by name.
|
/// Search box filtering repositories by name.
|
||||||
search: Entity<InputState>,
|
search: Entity<InputState>,
|
||||||
@@ -121,8 +119,8 @@ impl RepoListView {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep the visible slice and row sizes in sync with the store,
|
// Keep the visible slice and row sizes in sync with the store.
|
||||||
// so newly announced repositories appear without waiting for a click.
|
// Newly announced repositories appear without waiting for a click.
|
||||||
let subscription = cx.observe(&store, |this, _store, cx| {
|
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||||
this.rebuild_rows(cx);
|
this.rebuild_rows(cx);
|
||||||
});
|
});
|
||||||
@@ -141,16 +139,17 @@ impl RepoListView {
|
|||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Seed the rows right away; the store may already hold announcements
|
// Seed the rows right away.
|
||||||
// (it loaded before the panel opened), and the first render must not
|
// The store may already hold announcements from before the panel opened.
|
||||||
// depend on a later store update.
|
// The first render must not depend on a later store update.
|
||||||
this.rebuild_rows(cx);
|
this.rebuild_rows(cx);
|
||||||
|
|
||||||
this
|
this
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the current
|
/// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the store.
|
||||||
/// store contents, [`Self::filter`] and the search query.
|
///
|
||||||
|
/// Uses the store contents, [`Self::filter`] and the search query.
|
||||||
fn rebuild_rows(&mut self, cx: &mut Context<Self>) {
|
fn rebuild_rows(&mut self, cx: &mut Context<Self>) {
|
||||||
let filter = self.filter;
|
let filter = self.filter;
|
||||||
let query = self.search.read(cx).value();
|
let query = self.search.read(cx).value();
|
||||||
@@ -188,12 +187,14 @@ impl RepoListView {
|
|||||||
|
|
||||||
let name = announcement
|
let name = announcement
|
||||||
.name
|
.name
|
||||||
.clone()
|
.as_deref()
|
||||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
|
.map(SharedString::from)
|
||||||
|
.unwrap_or(SharedString::from(announcement.id.clone()));
|
||||||
|
|
||||||
let description = announcement
|
let description = announcement
|
||||||
.description
|
.description
|
||||||
.clone()
|
.as_deref()
|
||||||
|
.map(SharedString::from)
|
||||||
.unwrap_or(SharedString::from("No description"));
|
.unwrap_or(SharedString::from("No description"));
|
||||||
|
|
||||||
let activity = last_activity
|
let activity = last_activity
|
||||||
@@ -201,8 +202,8 @@ impl RepoListView {
|
|||||||
.map(|label| SharedString::from(format!("Updated {label}")))
|
.map(|label| SharedString::from(format!("Updated {label}")))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Fork badge: the upstream's display name when its announcement is
|
// The fork badge shows the upstream name when its announcement is known locally.
|
||||||
// known locally, otherwise its repository id from the `u` tag.
|
// Otherwise it shows the repository id from the `u` tag.
|
||||||
let fork_label: Option<SharedString> =
|
let fork_label: Option<SharedString> =
|
||||||
announcement.upstream.as_ref().and_then(|upstream| {
|
announcement.upstream.as_ref().and_then(|upstream| {
|
||||||
let addr = upstream.addr.as_ref()?;
|
let addr = upstream.addr.as_ref()?;
|
||||||
@@ -214,7 +215,8 @@ impl RepoListView {
|
|||||||
.find(|a| a.addr() == *addr)
|
.find(|a| a.addr() == *addr)
|
||||||
.map(|a| {
|
.map(|a| {
|
||||||
a.name
|
a.name
|
||||||
.clone()
|
.as_deref()
|
||||||
|
.map(SharedString::from)
|
||||||
.unwrap_or_else(|| SharedString::from(a.id.clone()))
|
.unwrap_or_else(|| SharedString::from(a.id.clone()))
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| SharedString::from(addr.identifier.clone()));
|
.unwrap_or_else(|| SharedString::from(addr.identifier.clone()));
|
||||||
@@ -347,8 +349,7 @@ impl RepoListView {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One segmented filter button of the header, styled like the issues
|
/// One segmented header filter button, like the issues list's status filter buttons.
|
||||||
/// list's status filter buttons.
|
|
||||||
fn filter_button(
|
fn filter_button(
|
||||||
&self,
|
&self,
|
||||||
filter: RepoFilter,
|
filter: RepoFilter,
|
||||||
@@ -398,7 +399,7 @@ impl Render for RepoListView {
|
|||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.relative()
|
.relative()
|
||||||
.image_cache(image_cache("repos", MAX_IMAGES))
|
.image_cache(gpui::retain_all("repos"))
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(self.render_header(count, cx))
|
.child(self.render_header(count, cx))
|
||||||
.when(!has_repos, |this| {
|
.when(!has_repos, |this| {
|
||||||
|
|||||||
@@ -1,31 +1,26 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use dock::DockArea;
|
use dock::DockArea;
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px};
|
use gpui::{App, Entity, PathPromptOptions, WeakEntity, Window, div, px};
|
||||||
use gpui_base::input::TextareaState;
|
use gpui_base::input::TextareaState;
|
||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||||
use gpui_component::form::{field, v_form};
|
use gpui_component::form::{field, v_form};
|
||||||
use gpui_component::input::{Input, InputState, Textarea};
|
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 settings::SettingsStore;
|
||||||
use signed_core::Announcement;
|
use signed_core::Announcement;
|
||||||
use signed_state::Backend;
|
use signed_state::{Backend, CheckoutsStore};
|
||||||
|
|
||||||
use super::super::open_repo_panel;
|
use super::super::open_repo_panel;
|
||||||
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
|
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
|
||||||
|
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||||
|
|
||||||
/// Shared state for the Create Repository dialog, so async results can be rendered.
|
/// Shared state for the Create Repository dialog, so async results can be rendered.
|
||||||
#[derive(Default)]
|
pub type CreateRepoState = DialogProgress;
|
||||||
pub struct CreateRepoState {
|
|
||||||
pub busy: bool,
|
|
||||||
pub error: Option<SharedString>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open the Create Repository dialog.
|
/// Open the Create Repository dialog.
|
||||||
///
|
|
||||||
/// The dialog loads the user's default grasp servers (kind `10317` grasp
|
|
||||||
/// list) and falls back to the shared defaults when none are set. On
|
|
||||||
/// success the dialog closes and the new repository opens in the dock.
|
|
||||||
pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
|
pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
|
||||||
let settings = SettingsStore::global(cx);
|
let settings = SettingsStore::global(cx);
|
||||||
let default_folder = settings
|
let default_folder = settings
|
||||||
@@ -56,7 +51,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
|
|||||||
|
|
||||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||||
const DESC: &str = "Publish a new repository to your grasp servers.";
|
const DESC: &str = "Publish a new repository to your grasp servers.";
|
||||||
const FOLDER_NOTE: &str = "Where the repository is stored.";
|
const FOLDER_NOTE: &str = "Where the repository's working copy is created.";
|
||||||
|
|
||||||
let name_input = name_input.clone();
|
let name_input = name_input.clone();
|
||||||
let desc_input = desc_input.clone();
|
let desc_input = desc_input.clone();
|
||||||
@@ -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)),
|
.child(grasp_servers_field(&grasp_state, &relay_input, cx)),
|
||||||
)
|
)
|
||||||
.children(error.map(|message| {
|
.children(error_row(&error, cx))
|
||||||
div().text_sm().text_color(cx.theme().danger).child(message)
|
|
||||||
}))
|
|
||||||
.child(
|
.child(
|
||||||
DialogFooter::new().justify_end().child(
|
DialogFooter::new().justify_end().child(
|
||||||
Button::new("create")
|
Button::new("create")
|
||||||
@@ -134,6 +127,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
|
|||||||
.on_click({
|
.on_click({
|
||||||
let name_input = name_input.clone();
|
let name_input = name_input.clone();
|
||||||
let desc_input = desc_input.clone();
|
let desc_input = desc_input.clone();
|
||||||
|
let folder_input = folder_input.clone();
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let grasp_state = grasp_state.clone();
|
let grasp_state = grasp_state.clone();
|
||||||
let dock_area = dock_area.clone();
|
let dock_area = dock_area.clone();
|
||||||
@@ -142,6 +136,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
|
|||||||
create_repository(
|
create_repository(
|
||||||
name_input.clone(),
|
name_input.clone(),
|
||||||
desc_input.clone(),
|
desc_input.clone(),
|
||||||
|
folder_input.clone(),
|
||||||
state.clone(),
|
state.clone(),
|
||||||
grasp_state.clone(),
|
grasp_state.clone(),
|
||||||
dock_area.clone(),
|
dock_area.clone(),
|
||||||
@@ -156,10 +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
|
/// Pick the repository's storage folder with the platform's native folder picker.
|
||||||
/// the platform's native folder picker, and show the result in the disabled
|
|
||||||
/// folder input. The picked folder is remembered in the settings so it
|
|
||||||
/// becomes the default next time.
|
|
||||||
fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||||
let handle = window.window_handle();
|
let handle = window.window_handle();
|
||||||
let folder_input = folder_input.clone();
|
let folder_input = folder_input.clone();
|
||||||
@@ -194,10 +186,14 @@ fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mu
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the create-repository flow; closes the dialog and opens the new repository on success.
|
/// Run the create-repository flow.
|
||||||
|
///
|
||||||
|
/// Opens the new working copy and the repository panel on success.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn create_repository(
|
fn create_repository(
|
||||||
name_input: Entity<InputState>,
|
name_input: Entity<InputState>,
|
||||||
desc_input: Entity<TextareaState>,
|
desc_input: Entity<TextareaState>,
|
||||||
|
folder_input: Entity<InputState>,
|
||||||
state: Entity<CreateRepoState>,
|
state: Entity<CreateRepoState>,
|
||||||
grasp_state: Entity<GraspServersState>,
|
grasp_state: Entity<GraspServersState>,
|
||||||
dock_area: WeakEntity<DockArea>,
|
dock_area: WeakEntity<DockArea>,
|
||||||
@@ -206,48 +202,47 @@ fn create_repository(
|
|||||||
) {
|
) {
|
||||||
let name = name_input.read(cx).value().trim().to_owned();
|
let name = name_input.read(cx).value().trim().to_owned();
|
||||||
let description = desc_input.read(cx).value().trim().to_owned();
|
let description = desc_input.read(cx).value().trim().to_owned();
|
||||||
|
let folder = PathBuf::from(folder_input.read(cx).value().trim());
|
||||||
let servers = grasp_state.read(cx).grasp_servers.clone();
|
let servers = grasp_state.read(cx).grasp_servers.clone();
|
||||||
|
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| state.fail("Repository name is required"));
|
||||||
state.error = Some("Repository name is required".into());
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if servers.is_empty() {
|
if servers.is_empty() {
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| state.fail("Add at least one grasp server"));
|
||||||
state.error = Some("Add at least one grasp server".into());
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| state.begin());
|
||||||
state.busy = true;
|
|
||||||
state.error = None;
|
|
||||||
});
|
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let task = backend.update(cx, |backend, cx| {
|
let task = backend.update(cx, |backend, cx| {
|
||||||
backend.create_repository(&name, &description, servers, cx)
|
backend.create_repository(&name, &description, folder, servers, cx)
|
||||||
});
|
});
|
||||||
|
|
||||||
let handle = window.window_handle();
|
let handle = window.window_handle();
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let dock_area = dock_area.clone();
|
let dock_area = dock_area.clone();
|
||||||
|
|
||||||
cx.spawn(async move |cx| match task.await {
|
cx.spawn(async move |cx| match task.await {
|
||||||
Ok(announcement) => {
|
Ok((announcement, local_path)) => {
|
||||||
cx.update_window(handle, |_, window, cx| {
|
cx.update_window(handle, |_, window, cx| {
|
||||||
window.close_dialog(cx);
|
window.close_dialog(cx);
|
||||||
|
// Record the new working copy as a checkout of this repository.
|
||||||
|
// The New PR panel then pre-fills it.
|
||||||
|
let checkouts = CheckoutsStore::global(cx);
|
||||||
|
checkouts.update(cx, |store, cx| {
|
||||||
|
store.record(local_path.clone(), announcement.addr(), cx);
|
||||||
|
});
|
||||||
|
cx.open_with_system(&local_path);
|
||||||
open_repo(dock_area, announcement, window, cx);
|
open_repo(dock_area, announcement, window, cx);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
cx.update_window(handle, |_, _window, cx| {
|
cx.update_window(handle, |_, _window, cx| {
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| state.fail(e.to_string()));
|
||||||
state.busy = false;
|
|
||||||
state.error = Some(e.to_string().into());
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,27 +6,24 @@ use gpui_component::input::{Input, InputState};
|
|||||||
use gpui_component::{ActiveTheme, IconName, Sizable, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, IconName, Sizable, h_flex, v_flex};
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
|
use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
|
||||||
use signed_core::filters;
|
|
||||||
use signed_state::Backend;
|
use signed_state::Backend;
|
||||||
|
|
||||||
/// State of the grasp-server section of a publish dialog, so async
|
/// State of the grasp-server section of a publish dialog, so async results can be rendered.
|
||||||
/// results can be rendered.
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct GraspServersState {
|
pub struct GraspServersState {
|
||||||
/// The user's grasp list (kind `10317`) is being loaded.
|
/// The user's grasp list of kind `10317` is being loaded.
|
||||||
pub loading_servers: bool,
|
pub loading_servers: bool,
|
||||||
pub grasp_servers: Vec<RelayUrl>,
|
pub grasp_servers: Vec<RelayUrl>,
|
||||||
/// Whether the grasp server section is shown; defaults to shown.
|
/// Whether the grasp server section is shown. Defaults to shown.
|
||||||
pub servers_enabled: bool,
|
pub servers_enabled: bool,
|
||||||
/// Error of the last grasp-server edit (e.g. an invalid relay URL).
|
/// Error of the last grasp-server edit, an invalid relay URL for example.
|
||||||
pub error: Option<SharedString>,
|
pub error: Option<SharedString>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GraspServersState {
|
impl GraspServersState {
|
||||||
/// Defaults until the user's grasp list arrives; replaced by it when it lists any servers.
|
/// Defaults used until the user's grasp list loads, which replaces them when non-empty.
|
||||||
///
|
///
|
||||||
/// The servers come from the persisted settings, falling back to the
|
/// Persisted settings supply the defaults, an empty list falls back to the built-ins.
|
||||||
/// built-in defaults when the configured list is empty.
|
|
||||||
pub fn new_default(settings: &GraspServersSettings) -> Self {
|
pub fn new_default(settings: &GraspServersSettings) -> Self {
|
||||||
let urls: Vec<String> = if settings.default_servers.is_empty() {
|
let urls: Vec<String> = if settings.default_servers.is_empty() {
|
||||||
DEFAULT_GRASP_SERVERS
|
DEFAULT_GRASP_SERVERS
|
||||||
@@ -48,10 +45,7 @@ impl GraspServersState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The "Grasp servers" form field shared by the publish dialogs: an
|
/// The Grasp servers form field shared by the publish dialogs.
|
||||||
/// expandable toggle, the configured servers (each removable) and an
|
|
||||||
/// add-relay input, with a loading hint while the user's grasp list
|
|
||||||
/// (kind `10317`) is being fetched.
|
|
||||||
pub fn grasp_servers_field(
|
pub fn grasp_servers_field(
|
||||||
state: &Entity<GraspServersState>,
|
state: &Entity<GraspServersState>,
|
||||||
relay_input: &Entity<InputState>,
|
relay_input: &Entity<InputState>,
|
||||||
@@ -143,7 +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(
|
fn render_server_row(
|
||||||
ix: usize,
|
ix: usize,
|
||||||
relay: &RelayUrl,
|
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 {
|
fn display_server(relay: &RelayUrl) -> SharedString {
|
||||||
relay
|
relay
|
||||||
.domain()
|
.domain()
|
||||||
@@ -190,7 +184,7 @@ fn display_server(relay: &RelayUrl) -> SharedString {
|
|||||||
.unwrap_or_else(|| SharedString::from(relay.to_string()))
|
.unwrap_or_else(|| SharedString::from(relay.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the relay input (accepting a bare host) and append it to the list.
|
/// Parse the relay input, accepting a bare host, and append it to the list.
|
||||||
fn add_relay(
|
fn add_relay(
|
||||||
state: &Entity<GraspServersState>,
|
state: &Entity<GraspServersState>,
|
||||||
input: &Entity<InputState>,
|
input: &Entity<InputState>,
|
||||||
@@ -226,8 +220,9 @@ fn add_relay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the user's grasp list (kind `10317`) from the local database and
|
/// Load the user's grasp list of kind `10317` from the local database.
|
||||||
/// replace the defaults with it when it lists any servers.
|
///
|
||||||
|
/// It replaces the defaults when it lists any servers.
|
||||||
pub fn load_user_grasp_servers(
|
pub fn load_user_grasp_servers(
|
||||||
state: Entity<GraspServersState>,
|
state: Entity<GraspServersState>,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
@@ -242,30 +237,7 @@ pub fn load_user_grasp_servers(
|
|||||||
let handle = window.window_handle();
|
let handle = window.window_handle();
|
||||||
|
|
||||||
cx.spawn(async move |cx| {
|
cx.spawn(async move |cx| {
|
||||||
let result: anyhow::Result<Vec<RelayUrl>> = async {
|
let result = signed_state::user_grasp_list_servers(client, user).await;
|
||||||
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 _ = cx.update_window(handle, |_, _window, cx| {
|
let _ = cx.update_window(handle, |_, _window, cx| {
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| {
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ use gpui::{App, Window, px};
|
|||||||
use gpui_component::WindowExt;
|
use gpui_component::WindowExt;
|
||||||
|
|
||||||
/// Open the Import Identity dialog.
|
/// Open the Import Identity dialog.
|
||||||
///
|
|
||||||
/// Currently a placeholder — the dialog only shows a title for now.
|
|
||||||
pub fn open(window: &mut Window, cx: &mut App) {
|
pub fn open(window: &mut Window, cx: &mut App) {
|
||||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||||
dialog.title("Import identity").width(px(400.))
|
dialog.title("Import identity").width(px(400.))
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use assets::CustomIconName;
|
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::prelude::*;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ObjectFit, Render,
|
AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ObjectFit, Render,
|
||||||
SharedString, Subscription, WeakEntity, Window, div, img, px, uniform_list,
|
SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list, white,
|
||||||
};
|
};
|
||||||
use gpui_base::Button as BaseButton;
|
use gpui_base::Button as BaseButton;
|
||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
use gpui_component::input::InputState;
|
use gpui_component::input::InputState;
|
||||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
||||||
use signed_core::{Announcement, identifier_from_name};
|
use signed_core::{Announcement, RepoAddr, identifier_from_name};
|
||||||
use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore, RepoListStore};
|
use signed_state::{
|
||||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore,
|
||||||
|
};
|
||||||
use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
|
use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
|
||||||
|
|
||||||
use super::{RepoDetailView, RepoListView, open_repo_panel};
|
use super::{RepoDetailView, RepoListView, open_repo_panel};
|
||||||
@@ -30,87 +34,176 @@ mod settings_dialog;
|
|||||||
|
|
||||||
use self::onboarding_dialog::OnboardingState;
|
use self::onboarding_dialog::OnboardingState;
|
||||||
|
|
||||||
/// Left-dock panel with navigation entries. Entries open content panels in
|
|
||||||
/// the dock area.
|
|
||||||
pub struct SidebarPanel {
|
pub struct SidebarPanel {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
dock_area: WeakEntity<DockArea>,
|
dock_area: WeakEntity<DockArea>,
|
||||||
explore: Option<WeakEntity<RepoListView>>,
|
explore: Option<WeakEntity<RepoListView>>,
|
||||||
logged_in: bool,
|
/// Artwork for the sign-in screen.
|
||||||
/// 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.
|
|
||||||
banner: SharedString,
|
banner: SharedString,
|
||||||
/// Observes the local-repository scan so new discoveries re-render.
|
/// The signed-in user's announced repositories, newest first.
|
||||||
_local_repos_subscription: Subscription,
|
announcements: Arc<Vec<Announcement>>,
|
||||||
_subscription: Subscription,
|
/// 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 {
|
impl SidebarPanel {
|
||||||
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
|
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 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| {
|
let mut subscriptions = Vec::new();
|
||||||
match event {
|
|
||||||
BackendEvent::SignerChanged => {
|
// Identity changes swap the whole sidebar between the sign-in screen and the signed-in content.
|
||||||
this.logged_in = backend.read(cx).current_user().is_some();
|
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||||
this.refresh_my_repos(cx);
|
let signer_changed = matches!(event, BackendEvent::SignerChanged);
|
||||||
}
|
let signer_required = matches!(event, BackendEvent::SignerRequired);
|
||||||
BackendEvent::SignerRequired => {
|
|
||||||
this.logged_in = false;
|
if !signer_changed && !signer_required {
|
||||||
this.banner = pick_banner();
|
return;
|
||||||
this.my_repos = None;
|
|
||||||
this.my_repos_subscription = None;
|
|
||||||
}
|
|
||||||
_ => return,
|
|
||||||
}
|
}
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
let local_repos_subscription = cx.observe(&local_repos_store, |_, _, cx| {
|
if signer_required {
|
||||||
cx.notify();
|
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(),
|
focus_handle: cx.focus_handle(),
|
||||||
dock_area,
|
dock_area,
|
||||||
logged_in,
|
|
||||||
explore: None,
|
explore: None,
|
||||||
my_repos: None,
|
|
||||||
my_repos_subscription: None,
|
|
||||||
banner: pick_banner(),
|
banner: pick_banner(),
|
||||||
_local_repos_subscription: local_repos_subscription,
|
announcements: Arc::new(Vec::new()),
|
||||||
_subscription: subscription,
|
local_repos: Arc::new(Vec::new()),
|
||||||
|
scanning: false,
|
||||||
|
unpushed: HashMap::new(),
|
||||||
|
_subscriptions: subscriptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
if logged_in {
|
// Seed the snapshot right away.
|
||||||
panel.refresh_my_repos(cx);
|
// 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.
|
/// The sidebar renders only its own derived fields, never the stores
|
||||||
fn refresh_my_repos(&mut self, cx: &mut Context<Self>) {
|
/// directly. Because the panel is a cached view, a store update alone does
|
||||||
self.my_repos_subscription = None;
|
/// 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 backend = Backend::global(cx);
|
||||||
let author = backend.read(cx).current_user();
|
let user = backend.read(cx).current_user();
|
||||||
self.my_repos = author.map(|author| cx.new(|cx| RepoListStore::new(Some(author), cx)));
|
|
||||||
|
|
||||||
if let Some(store) = self.my_repos.as_ref() {
|
let repo_list = RepoListStore::global(cx);
|
||||||
self.my_repos_subscription = Some(cx.observe(store, |_, _, cx| cx.notify()));
|
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
|
/// Recompute the badge counts from the global checkouts store's ready-to-push statuses
|
||||||
/// area. No-op if it's already open.
|
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>) {
|
pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if self
|
if self
|
||||||
.explore
|
.explore
|
||||||
@@ -125,7 +218,7 @@ impl SidebarPanel {
|
|||||||
self.explore = Some(panel.downgrade());
|
self.explore = Some(panel.downgrade());
|
||||||
|
|
||||||
let _ = self.dock_area.update(cx, |dock_area, cx| {
|
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_repo_panel(&self.dock_area, announcement, window, &mut *cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a local repository's detail view in the dock's center; the
|
/// Open a local repository's detail view in the dock's center.
|
||||||
/// detail view offers to publish it to NIP-34.
|
///
|
||||||
|
/// The detail view offers to publish it to NIP-34.
|
||||||
fn open_local_repo(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
|
fn open_local_repo(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let detail =
|
let detail =
|
||||||
cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, window, cx));
|
cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, window, cx));
|
||||||
|
|
||||||
let _ = self.dock_area.update(cx, |dock_area, cx| {
|
self.dock_area
|
||||||
dock_area.add_panel_view(
|
.update(cx, |dock_area, cx| {
|
||||||
panel_handle(detail),
|
add_center_panel(dock_area, panel_handle(detail), window, cx);
|
||||||
DockPlacement::Center,
|
})
|
||||||
None,
|
.ok();
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The "All Repositories" section: header with the create button and
|
fn render_repos(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
/// the current user's repositories below it, lazily rendered through a
|
let announcements = self.announcements.clone();
|
||||||
/// [`uniform_list`], followed by the local git repositories discovered
|
let local_repos = self.local_repos.clone();
|
||||||
/// by the startup scan.
|
let scanning = self.scanning;
|
||||||
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;
|
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.px_2()
|
.px_2()
|
||||||
@@ -234,58 +319,36 @@ impl SidebarPanel {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.when_some(store, |builder, store| {
|
.map(|this| {
|
||||||
let announcements = store.read(cx).announcements.clone();
|
// Merged list, the user's NIP-34 repositories and local repositories discovered.
|
||||||
// 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.
|
|
||||||
let total = announcements.len() + local_repos.len();
|
let total = announcements.len() + local_repos.len();
|
||||||
|
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
builder.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.px_2()
|
.px_2()
|
||||||
.py_1()
|
.py_1()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.child(if scanning {
|
.map(|this| {
|
||||||
"Scanning for local repositories…"
|
if scanning {
|
||||||
} else {
|
this.child("Scanning for local repositories…")
|
||||||
"No repositories yet"
|
} else {
|
||||||
|
this.child("No repositories yet")
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
builder.child(
|
this.child(
|
||||||
uniform_list(
|
uniform_list(
|
||||||
"repos",
|
"repos",
|
||||||
total,
|
total,
|
||||||
cx.processor(move |this, range: Range<usize>, _window, cx| {
|
cx.processor(move |this, range: Range<usize>, _, cx| {
|
||||||
range
|
range
|
||||||
.map(|ix| {
|
.map(|ix| {
|
||||||
this.render_repo_row_at(
|
this.render_repo_at(&announcements, &local_repos, ix, cx)
|
||||||
&announcements,
|
.into_any_element()
|
||||||
&local_repos,
|
|
||||||
ix,
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
.into_any_element()
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}),
|
}),
|
||||||
@@ -297,9 +360,8 @@ impl SidebarPanel {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of the merged sidebar list: a NIP-34 repository or a local
|
/// One row of the merged sidebar list, a NIP-34 or a local repository.
|
||||||
/// repository.
|
fn render_repo_at(
|
||||||
fn render_repo_row_at(
|
|
||||||
&self,
|
&self,
|
||||||
announcements: &[Announcement],
|
announcements: &[Announcement],
|
||||||
local_repos: &[PathBuf],
|
local_repos: &[PathBuf],
|
||||||
@@ -323,42 +385,60 @@ impl SidebarPanel {
|
|||||||
announcement: &Announcement,
|
announcement: &Announcement,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> impl IntoElement {
|
) -> impl IntoElement {
|
||||||
let name = announcement
|
let name = announcement.name().map(SharedString::from);
|
||||||
.name
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
|
|
||||||
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
|
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
|
||||||
let announcement = announcement.clone();
|
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)),
|
cx.listener(move |this, _ev, window, cx| this.open_repo(&announcement, window, cx)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One local repository row: a deterministic pixel avatar seeded from
|
/// One local repository row.
|
||||||
/// 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
|
/// The directory name and a warning suffix, the repo is not yet set up for NIP-34.
|
||||||
/// detail view, which offers to initialize it.
|
|
||||||
fn render_local_row(&self, path: &Path, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render_local_row(&self, path: &Path, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let name = path
|
let name = path
|
||||||
.file_name()
|
.file_name()
|
||||||
.map(|name| name.to_string_lossy().into_owned())
|
.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 path = path.to_path_buf();
|
||||||
|
let avatar = PixelAvatar::new(path.to_string_lossy());
|
||||||
|
|
||||||
NavItem::new(
|
NavItem::new(format!("local-repo:{}", path.display()), name, avatar)
|
||||||
format!("local-repo:{}", path.display()),
|
.suffix(
|
||||||
name,
|
Icon::new(IconName::TriangleAlert)
|
||||||
PixelAvatar::new(path.to_string_lossy()),
|
.small()
|
||||||
)
|
.text_color(cx.theme().warning),
|
||||||
.suffix(
|
)
|
||||||
Icon::new(IconName::TriangleAlert)
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
.small()
|
this.open_local_repo(path.clone(), window, cx);
|
||||||
.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.
|
/// Show the Import Identity dialog.
|
||||||
@@ -366,7 +446,7 @@ impl SidebarPanel {
|
|||||||
import_dialog::open(window, cx);
|
import_dialog::open(window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the user avatar and name in the sidebar, wrapped in the window titlebar drag area.
|
/// Render the user avatar and name in the sidebar, inside the titlebar drag area.
|
||||||
fn render_user(
|
fn render_user(
|
||||||
&self,
|
&self,
|
||||||
profile: &Profile,
|
profile: &Profile,
|
||||||
@@ -396,8 +476,7 @@ impl SidebarPanel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sign-in placeholder shown while logged out: banner artwork behind a
|
/// Sign-in placeholder shown while logged out.
|
||||||
/// scrim so the CTA buttons stay readable in both themes.
|
|
||||||
fn render_sign_in(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
|
fn render_sign_in(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
|
||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
@@ -503,10 +582,6 @@ impl Focusable for SidebarPanel {
|
|||||||
|
|
||||||
impl Render for SidebarPanel {
|
impl Render for SidebarPanel {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
if !self.logged_in {
|
|
||||||
return self.render_sign_in(window, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let profile_store = ProfileStore::global(cx);
|
let profile_store = ProfileStore::global(cx);
|
||||||
|
|
||||||
@@ -515,10 +590,14 @@ impl Render for SidebarPanel {
|
|||||||
.current_user()
|
.current_user()
|
||||||
.map(|public_key| profile_store.read(cx).get(&public_key));
|
.map(|public_key| profile_store.read(cx).get(&public_key));
|
||||||
|
|
||||||
|
if profile.is_none() {
|
||||||
|
return self.render_sign_in(window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.image_cache(image_cache("sidebar", MAX_IMAGES))
|
.image_cache(gpui::retain_all("sidebar"))
|
||||||
.bg(cx.theme().sidebar)
|
.bg(cx.theme().sidebar)
|
||||||
.text_color(cx.theme().sidebar_foreground)
|
.text_color(cx.theme().sidebar_foreground)
|
||||||
.child(
|
.child(
|
||||||
@@ -561,7 +640,7 @@ impl Render for SidebarPanel {
|
|||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(self.render_my_repos(cx)),
|
.child(self.render_repos(cx)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
use gpui::prelude::*;
|
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::button::{Button, ButtonVariants};
|
||||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||||
use gpui_component::form::{field, v_form};
|
use gpui_component::form::{field, v_form};
|
||||||
use gpui_component::input::{Input, InputState};
|
use gpui_component::input::{Input, InputState};
|
||||||
use gpui_component::{ActiveTheme, Disableable, WindowExt};
|
use gpui_component::{Disableable, WindowExt};
|
||||||
use signed_state::Backend;
|
use signed_state::Backend;
|
||||||
|
|
||||||
|
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||||
|
|
||||||
/// Shared state for the Onboarding dialog, so async results can be rendered.
|
/// Shared state for the Onboarding dialog, so async results can be rendered.
|
||||||
#[derive(Default)]
|
pub type OnboardingState = DialogProgress;
|
||||||
pub struct OnboardingState {
|
|
||||||
pub busy: bool,
|
|
||||||
pub error: Option<SharedString>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open the Onboarding dialog for creating a new identity.
|
/// Open the Onboarding dialog for creating a new identity.
|
||||||
///
|
|
||||||
/// The caller is responsible for creating the input and state entities and
|
|
||||||
/// passing them in. This function only builds the dialog UI and wires up
|
|
||||||
/// the continue-button handler.
|
|
||||||
pub fn open(
|
pub fn open(
|
||||||
name_input: Entity<InputState>,
|
name_input: Entity<InputState>,
|
||||||
pass_input: Entity<InputState>,
|
pass_input: Entity<InputState>,
|
||||||
@@ -66,9 +60,7 @@ pub fn open(
|
|||||||
)
|
)
|
||||||
.child(field().required(true).child(Input::new(&repass_input))),
|
.child(field().required(true).child(Input::new(&repass_input))),
|
||||||
)
|
)
|
||||||
.children(error.map(|message| {
|
.children(error_row(&error, cx))
|
||||||
div().text_sm().text_color(cx.theme().danger).child(message)
|
|
||||||
}))
|
|
||||||
.child(
|
.child(
|
||||||
DialogFooter::new().justify_end().child(
|
DialogFooter::new().justify_end().child(
|
||||||
Button::new("continue")
|
Button::new("continue")
|
||||||
@@ -91,17 +83,12 @@ pub fn open(
|
|||||||
|
|
||||||
if pass != repass {
|
if pass != repass {
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| {
|
||||||
state.busy = false;
|
state.fail("Passphrases do not match");
|
||||||
state.error =
|
|
||||||
Some("Passphrases do not match".into());
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| state.begin());
|
||||||
state.busy = true;
|
|
||||||
state.error = None;
|
|
||||||
});
|
|
||||||
|
|
||||||
let task = backend.update(cx, |backend, cx| {
|
let task = backend.update(cx, |backend, cx| {
|
||||||
backend.create_identity(&name, &pass, cx)
|
backend.create_identity(&name, &pass, cx)
|
||||||
@@ -119,8 +106,7 @@ pub fn open(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
cx.update_window(handle, |_, _window, cx| {
|
cx.update_window(handle, |_, _window, cx| {
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| {
|
||||||
state.busy = false;
|
state.fail(e.to_string());
|
||||||
state.error = Some(e.to_string().into());
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
use assets::CustomIconName;
|
use assets::CustomIconName;
|
||||||
use gpui::prelude::*;
|
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::button::{Button, ButtonVariants};
|
||||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||||
use gpui_component::form::{field, v_form};
|
use gpui_component::form::{field, v_form};
|
||||||
use gpui_component::input::{Input, InputEvent, InputState};
|
use gpui_component::input::{Input, InputEvent, InputState};
|
||||||
use gpui_component::{ActiveTheme, Disableable, WindowExt};
|
use gpui_component::{Disableable, WindowExt};
|
||||||
use signed_state::Backend;
|
use signed_state::Backend;
|
||||||
|
|
||||||
|
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||||
|
|
||||||
/// Shared state for the passphrase dialog, so async results can be rendered.
|
/// Shared state for the passphrase dialog, so async results can be rendered.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct PassphraseState {
|
pub struct PassphraseState {
|
||||||
pub busy: bool,
|
/// Progress of the unlock flow.
|
||||||
pub error: Option<SharedString>,
|
pub progress: DialogProgress,
|
||||||
/// Keeps the Enter-to-submit subscription alive while the dialog is open.
|
/// Keeps the Enter-to-submit subscription alive while the dialog is open.
|
||||||
_enter_subscription: Option<Subscription>,
|
_enter_subscription: Option<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the dialog asking for the passphrase that protects the stored
|
/// Open the dialog asking for the passphrase that protects the stored identity.
|
||||||
/// NIP-49 encrypted identity (`ncryptsec1...`).
|
|
||||||
///
|
|
||||||
/// Called when the backend emits [`signed_state::BackendEvent::PassphraseRequired`].
|
|
||||||
pub fn open(window: &mut Window, cx: &mut App) {
|
pub fn open(window: &mut Window, cx: &mut App) {
|
||||||
let pass_input = cx.new(|cx| {
|
let pass_input = cx.new(|cx| {
|
||||||
InputState::new(window, cx)
|
InputState::new(window, cx)
|
||||||
@@ -53,8 +52,8 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
|||||||
.overlay_closable(false)
|
.overlay_closable(false)
|
||||||
.keyboard(false)
|
.keyboard(false)
|
||||||
.content(move |content, _window, cx| {
|
.content(move |content, _window, cx| {
|
||||||
let busy = state.read(cx).busy;
|
let busy = state.read(cx).progress.busy;
|
||||||
let error = state.read(cx).error.clone();
|
let error = state.read(cx).progress.error.clone();
|
||||||
|
|
||||||
content
|
content
|
||||||
.child(
|
.child(
|
||||||
@@ -73,9 +72,7 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
|||||||
.child(Input::new(&pass_input)),
|
.child(Input::new(&pass_input)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.children(error.map(|message| {
|
.children(error_row(&error, cx))
|
||||||
div().text_sm().text_color(cx.theme().danger).child(message)
|
|
||||||
}))
|
|
||||||
.child(
|
.child(
|
||||||
DialogFooter::new().justify_end().child(
|
DialogFooter::new().justify_end().child(
|
||||||
Button::new("unlock")
|
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;
|
/// Submit the passphrase to the backend.
|
||||||
/// on failure the error is rendered inline and the dialog stays open.
|
|
||||||
fn unlock(
|
fn unlock(
|
||||||
pass_input: &Entity<InputState>,
|
pass_input: &Entity<InputState>,
|
||||||
state: &Entity<PassphraseState>,
|
state: &Entity<PassphraseState>,
|
||||||
@@ -111,15 +107,12 @@ fn unlock(
|
|||||||
|
|
||||||
if pass.is_empty() {
|
if pass.is_empty() {
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| {
|
||||||
state.error = Some("Passphrase must not be empty".into());
|
state.progress.fail("Passphrase must not be empty");
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| state.progress.begin());
|
||||||
state.busy = true;
|
|
||||||
state.error = None;
|
|
||||||
});
|
|
||||||
|
|
||||||
let task = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx));
|
let task = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx));
|
||||||
let handle = *handle;
|
let handle = *handle;
|
||||||
@@ -134,10 +127,7 @@ fn unlock(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
cx.update_window(handle, |_this, _window, cx| {
|
cx.update_window(handle, |_this, _window, cx| {
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| state.progress.fail(e.to_string()));
|
||||||
state.busy = false;
|
|
||||||
state.error = Some(e.to_string().into());
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.ok();
|
.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::cell::Cell;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
@@ -55,8 +48,7 @@ fn theme_options(cx: &App) -> (Vec<SelectOption>, Vec<SelectOption>) {
|
|||||||
(light, dark)
|
(light, dark)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stateful controls of the settings dialog, created once when it opens so
|
/// Stateful controls of the settings dialog, created once when it opens.
|
||||||
/// their values survive re-renders of the dialog content.
|
|
||||||
struct SettingsControls {
|
struct SettingsControls {
|
||||||
appearance: Entity<SelectState<Vec<SelectOption>>>,
|
appearance: Entity<SelectState<Vec<SelectOption>>>,
|
||||||
light_theme: Entity<SelectState<Vec<SelectOption>>>,
|
light_theme: Entity<SelectState<Vec<SelectOption>>>,
|
||||||
@@ -66,8 +58,7 @@ struct SettingsControls {
|
|||||||
radius: Entity<InputState>,
|
radius: Entity<InputState>,
|
||||||
radius_lg: Entity<InputState>,
|
radius_lg: Entity<InputState>,
|
||||||
grasp_server_input: Entity<InputState>,
|
grasp_server_input: Entity<InputState>,
|
||||||
/// The effective default create-repository folder, shown in the disabled
|
/// The effective default create-repository folder, shown in the disabled input.
|
||||||
/// folder selector.
|
|
||||||
default_folder: Entity<InputState>,
|
default_folder: Entity<InputState>,
|
||||||
/// Keeps the control subscriptions alive for the dialog's lifetime.
|
/// Keeps the control subscriptions alive for the dialog's lifetime.
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
@@ -276,28 +267,19 @@ impl SettingsControls {
|
|||||||
/// Open the Settings dialog.
|
/// Open the Settings dialog.
|
||||||
pub fn open(window: &mut Window, cx: &mut App) {
|
pub fn open(window: &mut Window, cx: &mut App) {
|
||||||
let controls = Rc::new(SettingsControls::new(window, cx));
|
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| {
|
window.open_dialog(cx, move |dialog, _window, cx| {
|
||||||
let dialog_state = dialog_state.clone();
|
let controls = controls.clone();
|
||||||
dialog
|
dialog
|
||||||
.title("Settings")
|
.title("Settings")
|
||||||
.width(px(650.))
|
.width(px(650.))
|
||||||
.h(px(560.))
|
.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
|
/// The settings content, one section per related setting.
|
||||||
/// horizontal separator lines.
|
/// Sections are divided by horizontal separator lines.
|
||||||
fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement {
|
fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement {
|
||||||
let store = SettingsStore::global(cx);
|
let store = SettingsStore::global(cx);
|
||||||
let settings = store.read(cx).settings().clone();
|
let settings = store.read(cx).settings().clone();
|
||||||
@@ -325,8 +307,7 @@ fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Theme configuration: the theme names in the registry plus
|
/// Theme configuration, the registry theme names plus tweaks the app customizes at startup.
|
||||||
/// the visual tweaks the application customizes at startup.
|
|
||||||
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
|
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
@@ -397,7 +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(
|
fn grasp_servers_section(
|
||||||
settings: &Settings,
|
settings: &Settings,
|
||||||
controls: &SettingsControls,
|
controls: &SettingsControls,
|
||||||
@@ -413,8 +394,8 @@ fn grasp_servers_section(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The editable list of default grasp servers plus an add-relay input,
|
/// The editable list of default grasp servers plus an add-relay input.
|
||||||
/// styled like the grasp-server section of the publish dialogs.
|
/// Styled like the grasp-server section of the publish dialogs.
|
||||||
fn grasp_server_editor(
|
fn grasp_server_editor(
|
||||||
servers: &[String],
|
servers: &[String],
|
||||||
controls: &SettingsControls,
|
controls: &SettingsControls,
|
||||||
@@ -478,8 +459,8 @@ fn grasp_server_editor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The bare host of a grasp server (defaults are entered without a scheme),
|
/// The bare host of a grasp server, defaults are entered without a scheme.
|
||||||
/// matching how the publish dialogs display servers.
|
/// Matches how the publish dialogs display servers.
|
||||||
fn display_server(server: &str) -> SharedString {
|
fn display_server(server: &str) -> SharedString {
|
||||||
RelayUrl::parse(server)
|
RelayUrl::parse(server)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -513,8 +494,8 @@ fn repositories_section(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The editable list of scan directories plus an add-directory button,
|
/// The editable list of scan directories plus an add-directory button.
|
||||||
/// styled like the grasp-server list.
|
/// Styled like the grasp-server list.
|
||||||
fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement {
|
fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
.w_full()
|
.w_full()
|
||||||
@@ -566,8 +547,8 @@ fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The default-folder selector: a disabled input showing the effective
|
/// The default-folder selector, a disabled input plus a picker button.
|
||||||
/// folder plus a picker button, matching the create-repository dialog.
|
/// Matches the create-repository dialog.
|
||||||
fn folder_selector(controls: &SettingsControls) -> impl IntoElement {
|
fn folder_selector(controls: &SettingsControls) -> impl IntoElement {
|
||||||
let default_folder = controls.default_folder.clone();
|
let default_folder = controls.default_folder.clone();
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -590,8 +571,8 @@ fn folder_selector(controls: &SettingsControls) -> impl IntoElement {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the server input (accepting a bare host) and append it to the
|
/// Parse the server input and append it to the default grasp servers.
|
||||||
/// default grasp servers.
|
/// A bare host is accepted.
|
||||||
fn add_server(input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
fn add_server(input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||||
let value = input.read(cx).value().trim().to_owned();
|
let value = input.read(cx).value().trim().to_owned();
|
||||||
if value.is_empty() {
|
if value.is_empty() {
|
||||||
@@ -660,8 +641,8 @@ fn add_scan_path(cx: &mut App) {
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prompt for the folder the Create Repository dialog should default to,
|
/// Prompt for the Create Repository dialog's default folder.
|
||||||
/// remembering it in the settings and showing it in the disabled input.
|
/// Remember it in the settings and show it in the disabled input.
|
||||||
fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||||
let handle = window.window_handle();
|
let handle = window.window_handle();
|
||||||
let default_folder = default_folder.clone();
|
let default_folder = default_folder.clone();
|
||||||
@@ -695,8 +676,9 @@ fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Windo
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wire a number input to the settings: steps clamp and persist, typed
|
/// Wire a number input to the settings.
|
||||||
/// changes parse, clamp and persist.
|
/// Step actions clamp and persist the value.
|
||||||
|
/// Typed changes parse, clamp and persist.
|
||||||
fn wire_number_input(
|
fn wire_number_input(
|
||||||
state: &Entity<InputState>,
|
state: &Entity<InputState>,
|
||||||
subscriptions: &mut Vec<Subscription>,
|
subscriptions: &mut Vec<Subscription>,
|
||||||
|
|||||||
@@ -45,10 +45,7 @@ impl Workspace {
|
|||||||
|
|
||||||
let mut subscriptions = vec![];
|
let mut subscriptions = vec![];
|
||||||
|
|
||||||
// A bottom/right dock whose last panel was dragged away is removed
|
// A bottom or right dock whose last panel was dragged away is removed entirely.
|
||||||
// entirely: base keeps the emptied region, which would otherwise
|
|
||||||
// linger as a bare strip. Deferred, because the event arrives while
|
|
||||||
// the area is mid-update.
|
|
||||||
let dock_for_pruning = dock.clone();
|
let dock_for_pruning = dock.clone();
|
||||||
subscriptions.push(cx.subscribe_in(
|
subscriptions.push(cx.subscribe_in(
|
||||||
&dock,
|
&dock,
|
||||||
@@ -78,9 +75,7 @@ impl Workspace {
|
|||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
|
||||||
// Ask for the passphrase when the stored identity is NIP-49
|
// Ask for the passphrase when the stored identity is NIP-49 encrypted.
|
||||||
// encrypted. Subscribed via the window, since opening a dialog
|
|
||||||
// needs one.
|
|
||||||
let passphrase_subscription =
|
let passphrase_subscription =
|
||||||
window.subscribe(&backend, cx, |_backend, event, window, cx| {
|
window.subscribe(&backend, cx, |_backend, event, window, cx| {
|
||||||
if matches!(event, BackendEvent::PassphraseRequired) {
|
if matches!(event, BackendEvent::PassphraseRequired) {
|
||||||
@@ -88,9 +83,8 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// The event may have fired before this window existed (the backend
|
// The event may have fired before this window existed.
|
||||||
// is initialized before the first window opens); fall back to the
|
// Fall back to the backend state in that case.
|
||||||
// backend state in that case.
|
|
||||||
if backend.read(cx).passphrase_required() {
|
if backend.read(cx).passphrase_required() {
|
||||||
passphrase_dialog::open(window, cx);
|
passphrase_dialog::open(window, cx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,8 @@ workspace = { path = "../crates/workspace" }
|
|||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui_platform.workspace = true
|
gpui_platform.workspace = true
|
||||||
gpui_linux.workspace = true
|
|
||||||
gpui_windows.workspace = true
|
|
||||||
gpui_macos.workspace = true
|
|
||||||
dock = { workspace = true }
|
dock = { workspace = true }
|
||||||
gpui-component.workspace = true
|
gpui-component.workspace = true
|
||||||
reqwest_client.workspace = true
|
reqwest_client.workspace = true
|
||||||
log.workspace = true
|
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|||||||
+10
-8
@@ -17,14 +17,14 @@ fn main() {
|
|||||||
gpui_component::init(cx);
|
gpui_component::init(cx);
|
||||||
theme::init(cx);
|
theme::init(cx);
|
||||||
|
|
||||||
// Load the persisted settings before applying the theme,
|
// Load the persisted settings before applying the theme.
|
||||||
// so the stored appearance and theme configuration take effect at startup.
|
// Stored appearance and theme settings then take effect at startup.
|
||||||
let store = cx.new(|cx| SettingsStore::new(paths::settings_file(), cx));
|
let store = cx.new(|cx| SettingsStore::new(paths::settings_file(), cx));
|
||||||
SettingsStore::set_global(store.clone(), cx);
|
SettingsStore::set_global(store.clone(), cx);
|
||||||
let settings = store.read(cx).settings().clone();
|
let settings = store.read(cx).settings().clone();
|
||||||
|
|
||||||
// Register the built-in "Signed" theme (light + dark variants)
|
// Register the built-in Signed theme, light and dark variants.
|
||||||
// and make it the active theme, following the stored appearance.
|
// The stored appearance then selects the active theme.
|
||||||
let registry = ThemeRegistry::global_mut(cx);
|
let registry = ThemeRegistry::global_mut(cx);
|
||||||
for (name, content) in Assets.themes() {
|
for (name, content) in Assets.themes() {
|
||||||
if let Err(err) = registry.load_themes_from_str(&content) {
|
if let Err(err) = registry.load_themes_from_str(&content) {
|
||||||
@@ -74,11 +74,13 @@ fn main() {
|
|||||||
|
|
||||||
// Connects relays and restores the session.
|
// Connects relays and restores the session.
|
||||||
std::fs::create_dir_all(paths::nostr_dir()).ok();
|
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();
|
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");
|
cx.set_app_identity("su.reya.signed", "Signed");
|
||||||
|
|
||||||
|
|||||||
+72
-38
@@ -11,23 +11,22 @@ state.
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
A["New pull request dialog"] --> B{"Patch source"}
|
A["New pull request panel"] --> B{"Compare source"}
|
||||||
B -->|"Paste"| C["Paste git format-patch output"]
|
B -->|"Local checkout"| C["Pick folder (or auto-prefilled from remembered checkouts)"]
|
||||||
B -->|"Local checkout"| D["Browse for checkout"]
|
B -->|"Announced fork"| D["Pick fork repo + branch"]
|
||||||
D --> E["Defaults: source = current branch, target = announced HEAD"]
|
D --> D1["Ensure base mirror (GitCache), fetch origin"]
|
||||||
E --> F["Generate: merge-base plus format-patch base..tip"]
|
D1 --> D2["Import fork heads as refs/fork/<owner>/<id>/*"]
|
||||||
F --> G["Apply check vs mirror clone - non-blocking warning"]
|
C --> E["Defaults: target = announced HEAD, source = current branch / fork main"]
|
||||||
C --> H["Submit"]
|
E --> F["merge-base + commits + diff of target..source (Files/Commits tabs)"]
|
||||||
F --> H
|
F --> H["Submit: format-patch base..tip at publish time"]
|
||||||
G --> H
|
|
||||||
H --> I["split_patch_series: one part per commit"]
|
H --> I["split_patch_series: one part per commit"]
|
||||||
I --> J{"Any part over 60 KB?"}
|
I --> J{"Any part over 60 KB?"}
|
||||||
J -->|"Yes"| K["Refuse with message"]
|
J -->|"Yes"| K["Refuse with message"]
|
||||||
J -->|"No"| L["tip = last part's From commit"]
|
J -->|"No"| L["tip = last part's From commit"]
|
||||||
L --> M["Publish kind-1617 patch series: first has t root, later parts e-reply chained"]
|
L --> M["Publish kind-1617 patch series: first has t root, later parts e-reply chained"]
|
||||||
M --> N["Build kind-1618 PR event: c = tip, e = root patch, branch-name, merge-base"]
|
M --> N["Build kind-1618 PR event: c = tip, e = root patch, branch-name, merge-base, clone"]
|
||||||
N --> O["Sign early - learn the event id"]
|
N --> O["Sign early - learn the event id"]
|
||||||
O --> P["Push tip to refs/nostr/event-id on every announced grasp server"]
|
O --> P["Push tip to refs/nostr/event-id: author /prs/ grasp servers first, then the announced servers"]
|
||||||
P -->|"All rejected"| Q["last_warning banner in PR list"]
|
P -->|"All rejected"| Q["last_warning banner in PR list"]
|
||||||
P --> R["Publish kind-1618 PR event"]
|
P --> R["Publish kind-1618 PR event"]
|
||||||
Q --> R
|
Q --> R
|
||||||
@@ -50,47 +49,81 @@ graph TD
|
|||||||
|
|
||||||
Key points of the write side:
|
Key points of the write side:
|
||||||
|
|
||||||
- **Merge base**: only computable in the local-checkout path
|
- **Compare sources** (NIP-34 / GRASP-06 native, no fork identity on the
|
||||||
(`signed_git::merge_base`); the paste path publishes none. The dialog
|
wire):
|
||||||
reuses it at submit only while the patch textarea is unchanged.
|
- *Local checkout*: both branch selectors list a picked folder's
|
||||||
|
branches; all git ops run in that folder. Checkouts of the target repo
|
||||||
|
are remembered (folder pick + app clones) and matched implicitly
|
||||||
|
(origin URL or EUC against the announcement), so the panel prefills the
|
||||||
|
freshest one - no folder dialog for the common case.
|
||||||
|
- *Announced fork*: the fork's heads are fetched into the target repo's
|
||||||
|
GitCache mirror under `refs/fork/<owner-hex>/<id>/*` (private
|
||||||
|
namespace; the browser never sees them). "Merge Into" lists the
|
||||||
|
mirror's `refs/remotes/origin/*`, "Pull From" the imported fork
|
||||||
|
branches, and every git op - merge-base, range diff/commits,
|
||||||
|
format-patch, tip push - runs in the mirror, which holds both
|
||||||
|
histories. Fork candidates are announcements related to the target by
|
||||||
|
`u` tag or shared EUC, own forks first, without `clone` URLs excluded.
|
||||||
|
- **GRASP-06 hosting**: the tip is pushed under `refs/nostr/<event-id>`
|
||||||
|
(nak's convention) to the *author's* grasp servers first -
|
||||||
|
`https://<host>/prs/<author-npub>/<repo-id>.git`, resolved from the
|
||||||
|
author's kind-10317 grasp list, falling back to the settings defaults -
|
||||||
|
then to the base repository's announced grasp servers. The `clone` tag
|
||||||
|
lists those `/prs/` URLs first, then the announced clone URLs (fixed
|
||||||
|
before signing; dead URLs are inert, the patches stay the source of
|
||||||
|
truth). Contributing therefore never depends on the other project's
|
||||||
|
servers accepting a push.
|
||||||
- **Patch series**: each commit becomes its own kind-1617 event so no event
|
- **Patch series**: each commit becomes its own kind-1617 event so no event
|
||||||
grows past NIP-34's 60 KB guidance; the PR's `c` tag carries the *last*
|
grows past NIP-34's 60 KB guidance; the PR's `c` tag carries the *last*
|
||||||
commit of the series (the tip), and each part carries its own
|
commit of the series (the tip), and each part carries its own
|
||||||
`commit`/`r` tags.
|
`commit`/`r` tags.
|
||||||
- **Push before publish**: the tip is pushed to every announced grasp
|
- **Push before publish**: failure is non-fatal - the patch events remain
|
||||||
server under `refs/nostr/<event-id>` (nak's convention) so the announced
|
the source of truth - and surfaces as a `last_warning` banner.
|
||||||
`clone` URLs really can serve the commit. Failure is non-fatal — the
|
- **1619 updates are paste-only today** (no repo path holds the new tip's
|
||||||
patch events remain the source of truth — and surfaces as a
|
objects), so updates are not pushed; hosting them is deferred until the
|
||||||
`last_warning` banner.
|
update dialog gains a local-checkout source.
|
||||||
|
|
||||||
## Creating a pull request - event ordering
|
## Creating a pull request - event ordering
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant User
|
participant User
|
||||||
participant App
|
participant P as Base mirror (GitCache)
|
||||||
participant Checkout as Local checkout
|
participant F as Fork grasp server
|
||||||
participant Grasp as Grasp servers
|
participant A as Author grasp (GRASP-06 /prs/)
|
||||||
participant Relays as Nostr relays
|
participant B as Base repo grasps
|
||||||
|
participant R as Nostr relays
|
||||||
|
|
||||||
User->>App: pick checkout and branches, Generate
|
User->>P: ensure mirror (fork mode) / pick local checkout
|
||||||
App->>Checkout: merge-base(source, target)
|
P-->>F: fetch fork heads -> refs/fork/... (fork mode)
|
||||||
Checkout-->>App: base commit
|
User->>P: merge-base, range commits, range diff
|
||||||
App->>Checkout: format-patch base..tip
|
User->>P: submit: format-patch base..compare-ref
|
||||||
Checkout-->>App: patch series
|
|
||||||
App->>App: split series, check per-part size
|
|
||||||
loop each patch of the series
|
loop each patch of the series
|
||||||
App->>Relays: publish kind-1617 (first: t root, later: e reply)
|
User->>R: publish kind-1617 (first: t root, later: e reply)
|
||||||
end
|
end
|
||||||
App->>App: build and sign kind-1618 PR event
|
User->>User: build and sign kind-1618 (clone = /prs/ URLs + announced)
|
||||||
App->>Grasp: push tip to refs/nostr/event-id
|
User->>A: push tip to refs/nostr/event-id (author servers, first)
|
||||||
Grasp-->>App: accepted or rejected (best-effort)
|
User->>B: push tip to refs/nostr/event-id (best-effort)
|
||||||
App->>Relays: publish kind-1618 PR event
|
A-->>User: accepted or rejected (all rejected -> warning)
|
||||||
|
User->>R: publish kind-1618 PR event
|
||||||
opt draft
|
opt draft
|
||||||
App->>Relays: publish kind-1633 draft status
|
User->>R: publish kind-1633 draft status
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Ready to contribute (suggestions)
|
||||||
|
|
||||||
|
Local checkouts are matched to announced repositories (remembered records
|
||||||
|
freshest-first ∪ scanned matches by origin URL or EUC). While a repository's
|
||||||
|
detail panel is open, each associated checkout is checked off the main
|
||||||
|
thread: current branch vs its base (announced HEAD, else `main`, else the
|
||||||
|
first branch), commits ahead, dirty worktrees excluded. A banner in the
|
||||||
|
repository panel then offers a prefilled New PR panel for the first branch
|
||||||
|
that is ahead with **no open PR by you** proposing it (`branch-name` tag,
|
||||||
|
falling back to the `c` tip tag) - NIP-34-native dedupe, refreshed
|
||||||
|
periodically and whenever the checkouts/announcements change. The panel
|
||||||
|
never submits anything on its own; suggestions only navigate and prefill.
|
||||||
|
|
||||||
## Updating and merging
|
## Updating and merging
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -100,7 +133,7 @@ sequenceDiagram
|
|||||||
participant Maintainer
|
participant Maintainer
|
||||||
participant Clone as Mirror clone
|
participant Clone as Mirror clone
|
||||||
|
|
||||||
Note over Author,Relays: Update - PR author only
|
Note over Author,Relays: Update - PR author only (paste flow, no push yet)
|
||||||
Author->>Relays: publish revision patch series (t root-revision, e reply to original root)
|
Author->>Relays: publish revision patch series (t root-revision, e reply to original root)
|
||||||
Author->>Relays: publish kind-1619 update (E/P tags, c = new tip)
|
Author->>Relays: publish kind-1619 update (E/P tags, c = new tip)
|
||||||
|
|
||||||
@@ -132,8 +165,9 @@ Reader rules that keep the flow consistent:
|
|||||||
|
|
||||||
- **Status**: only status events by the root author or a repository
|
- **Status**: only status events by the root author or a repository
|
||||||
maintainer count; the newest wins, `Open` is the default.
|
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.
|
stranger's update is ignored.
|
||||||
- **Diff**: the patch set is preferred (NIP-34 `e`-linked chain); PRs from
|
- **Diff**: the patch set is preferred (NIP-34 `e`-linked chain); PRs from
|
||||||
other clients without patch events fall back to diffing
|
other clients without patch events fall back to diffing
|
||||||
`merge-base..tip` in the local clone.
|
`merge-base..tip` in the local clone. Fetching tips from `clone` URLs
|
||||||
|
(ngit `pr checkout` analog) is not implemented yet.
|
||||||
|
|||||||
@@ -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