chore: improce performance (#9)

Reviewed-on: https://git.reya.su/reya/signed/pulls/9
This commit was merged in pull request #9.
This commit is contained in:
2026-09-01 02:05:24 +00:00
parent d2468545d6
commit 05ab543fa0
24 changed files with 729 additions and 555 deletions
Generated
+71 -69
View File
@@ -1238,7 +1238,7 @@ dependencies = [
[[package]]
name = "collections"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"gpui_util",
"indexmap",
@@ -1704,7 +1704,7 @@ dependencies = [
[[package]]
name = "derive_refineable"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"proc-macro2",
"quote",
@@ -3484,7 +3484,7 @@ dependencies = [
[[package]]
name = "gpui"
version = "0.2.2"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"accesskit",
"anyhow",
@@ -3555,17 +3555,21 @@ dependencies = [
[[package]]
name = "gpui-base"
version = "0.5.2"
source = "git+https://github.com/longbridge/gpui-component#b4393e22d3a04a880bc187dfdabca9b1eeee270a"
source = "git+https://github.com/longbridge/gpui-component#c5ade4889153094b23bf438ada7e58a23c0a96e6"
dependencies = [
"aho-corasick",
"anyhow",
"async-channel",
"chrono",
"futures",
"gpui",
"gpui_macros",
"gpui_platform",
"html5ever",
"instant",
"lsp-types",
"markdown",
"markup5ever_rcdom",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
@@ -3587,25 +3591,22 @@ dependencies = [
[[package]]
name = "gpui-component"
version = "0.5.2"
source = "git+https://github.com/longbridge/gpui-component#b4393e22d3a04a880bc187dfdabca9b1eeee270a"
source = "git+https://github.com/longbridge/gpui-component#c5ade4889153094b23bf438ada7e58a23c0a96e6"
dependencies = [
"anyhow",
"chrono",
"core-text",
"enum-iterator",
"futures",
"gpui",
"gpui-base",
"gpui-component-assets",
"gpui-component-macros",
"gpui_macros",
"html5ever",
"instant",
"itertools 0.13.0",
"log",
"lsp-types",
"markdown",
"markup5ever_rcdom",
"notify",
"num-traits",
"objc2 0.6.4",
@@ -3667,7 +3668,7 @@ dependencies = [
[[package]]
name = "gpui-component-assets"
version = "0.5.1"
source = "git+https://github.com/longbridge/gpui-component#b4393e22d3a04a880bc187dfdabca9b1eeee270a"
source = "git+https://github.com/longbridge/gpui-component#c5ade4889153094b23bf438ada7e58a23c0a96e6"
dependencies = [
"anyhow",
"gpui",
@@ -3681,7 +3682,7 @@ dependencies = [
[[package]]
name = "gpui-component-macros"
version = "0.5.1"
source = "git+https://github.com/longbridge/gpui-component#b4393e22d3a04a880bc187dfdabca9b1eeee270a"
source = "git+https://github.com/longbridge/gpui-component#c5ade4889153094b23bf438ada7e58a23c0a96e6"
dependencies = [
"proc-macro2",
"quote",
@@ -3691,7 +3692,7 @@ dependencies = [
[[package]]
name = "gpui_apple"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"anyhow",
"block",
@@ -3714,7 +3715,7 @@ dependencies = [
[[package]]
name = "gpui_linux"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"accesskit",
"accesskit_unix",
@@ -3760,7 +3761,7 @@ dependencies = [
[[package]]
name = "gpui_macos"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"accesskit",
"accesskit_macos",
@@ -3806,7 +3807,7 @@ dependencies = [
[[package]]
name = "gpui_macros"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -3817,7 +3818,7 @@ dependencies = [
[[package]]
name = "gpui_platform"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"console_error_panic_hook",
"gpui",
@@ -3830,7 +3831,7 @@ dependencies = [
[[package]]
name = "gpui_shared_string"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"schemars",
"serde",
@@ -3840,7 +3841,7 @@ dependencies = [
[[package]]
name = "gpui_util"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"anyhow",
"log",
@@ -3850,7 +3851,7 @@ dependencies = [
[[package]]
name = "gpui_web"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"anyhow",
"console_error_panic_hook",
@@ -3874,7 +3875,7 @@ dependencies = [
[[package]]
name = "gpui_wgpu"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"anyhow",
"bytemuck",
@@ -3900,7 +3901,7 @@ dependencies = [
[[package]]
name = "gpui_windows"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"accesskit",
"accesskit_windows",
@@ -4093,9 +4094,9 @@ dependencies = [
[[package]]
name = "hermit-abi"
version = "0.5.2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]]
name = "hex"
@@ -4204,7 +4205,7 @@ dependencies = [
[[package]]
name = "http_client"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"anyhow",
"async-compression",
@@ -4224,7 +4225,7 @@ dependencies = [
[[package]]
name = "http_client_tls"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"log",
"rustls",
@@ -5043,9 +5044,9 @@ dependencies = [
[[package]]
name = "lyon_algorithms"
version = "1.0.20"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8575c0d003ae459399623c4def180c63b77f343b1a7fee64f249b349e7699a31"
checksum = "cdfa8785f95e57914ddb35e3b59994aeba6f5e79e9cfd03da1c269f010f36009"
dependencies = [
"lyon_path",
"num-traits",
@@ -5074,9 +5075,9 @@ dependencies = [
[[package]]
name = "lyon_tessellation"
version = "1.0.20"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e43b7e44161571868f5c931d12583592c223c5583eef86b08aa02b7048a3552"
checksum = "dabea159dc6eea9171a541b458134b70ca95e3162d068132b2b3b9bc01aad06e"
dependencies = [
"float_next_after",
"lyon_path",
@@ -5180,7 +5181,7 @@ dependencies = [
[[package]]
name = "media"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"anyhow",
"bindgen",
@@ -5404,8 +5405,8 @@ dependencies = [
[[package]]
name = "nostr"
version = "0.45.3"
source = "git+https://github.com/rust-nostr/nostr#ea38c0e3b2fc2717a4a55d019560a5574baf8cd0"
version = "0.45.4"
source = "git+https://github.com/rust-nostr/nostr#472c8839ea3f532259435d0513bf155ddeb467ba"
dependencies = [
"aes",
"base64",
@@ -5431,7 +5432,7 @@ dependencies = [
[[package]]
name = "nostr-connect"
version = "0.45.1"
source = "git+https://github.com/rust-nostr/nostr#ea38c0e3b2fc2717a4a55d019560a5574baf8cd0"
source = "git+https://github.com/rust-nostr/nostr#472c8839ea3f532259435d0513bf155ddeb467ba"
dependencies = [
"async-utility",
"futures-core",
@@ -5445,7 +5446,7 @@ dependencies = [
[[package]]
name = "nostr-database"
version = "0.45.1"
source = "git+https://github.com/rust-nostr/nostr#ea38c0e3b2fc2717a4a55d019560a5574baf8cd0"
source = "git+https://github.com/rust-nostr/nostr#472c8839ea3f532259435d0513bf155ddeb467ba"
dependencies = [
"nostr",
"opaquerr",
@@ -5454,7 +5455,7 @@ dependencies = [
[[package]]
name = "nostr-gossip"
version = "0.45.0"
source = "git+https://github.com/rust-nostr/nostr#ea38c0e3b2fc2717a4a55d019560a5574baf8cd0"
source = "git+https://github.com/rust-nostr/nostr#472c8839ea3f532259435d0513bf155ddeb467ba"
dependencies = [
"nostr",
"opaquerr",
@@ -5463,7 +5464,7 @@ dependencies = [
[[package]]
name = "nostr-gossip-memory"
version = "0.45.0"
source = "git+https://github.com/rust-nostr/nostr#ea38c0e3b2fc2717a4a55d019560a5574baf8cd0"
source = "git+https://github.com/rust-nostr/nostr#472c8839ea3f532259435d0513bf155ddeb467ba"
dependencies = [
"indexmap",
"lru",
@@ -5475,7 +5476,7 @@ dependencies = [
[[package]]
name = "nostr-lmdb"
version = "0.45.2"
source = "git+https://github.com/rust-nostr/nostr#ea38c0e3b2fc2717a4a55d019560a5574baf8cd0"
source = "git+https://github.com/rust-nostr/nostr#472c8839ea3f532259435d0513bf155ddeb467ba"
dependencies = [
"async-utility",
"flatbuffers",
@@ -5490,7 +5491,7 @@ dependencies = [
[[package]]
name = "nostr-memory"
version = "0.45.1"
source = "git+https://github.com/rust-nostr/nostr#ea38c0e3b2fc2717a4a55d019560a5574baf8cd0"
source = "git+https://github.com/rust-nostr/nostr#472c8839ea3f532259435d0513bf155ddeb467ba"
dependencies = [
"btreecap",
"nostr",
@@ -5501,7 +5502,7 @@ dependencies = [
[[package]]
name = "nostr-sdk"
version = "0.45.2"
source = "git+https://github.com/rust-nostr/nostr#ea38c0e3b2fc2717a4a55d019560a5574baf8cd0"
source = "git+https://github.com/rust-nostr/nostr#472c8839ea3f532259435d0513bf155ddeb467ba"
dependencies = [
"async-utility",
"async-wsocket",
@@ -6228,7 +6229,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "perf"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"collections",
"serde",
@@ -7016,7 +7017,7 @@ dependencies = [
[[package]]
name = "refineable"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"derive_refineable",
]
@@ -7099,7 +7100,7 @@ dependencies = [
[[package]]
name = "reqwest_client"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"anyhow",
"bytes",
@@ -7477,7 +7478,7 @@ dependencies = [
[[package]]
name = "scheduler"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"async-task",
"backtrace",
@@ -7919,6 +7920,7 @@ dependencies = [
"anyhow",
"bitcoin_hashes 1.2.0",
"flume 0.11.1",
"futures",
"gpui",
"log",
"nostr",
@@ -8171,7 +8173,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "sum_tree"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"heapless 0.9.3",
"log",
@@ -8182,15 +8184,15 @@ dependencies = [
[[package]]
name = "sval"
version = "2.21.1"
version = "2.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec4a2a7d92fa86fcc6222e4c3845f8486cff899d9db32480b26c91a5dbf2e22d"
checksum = "b81b254da21fe1fcc4e3a74fe39b46e25e3a863078f8b71c954d47f84889dbc6"
[[package]]
name = "sval_buffer"
version = "2.21.1"
version = "2.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4324db9ac500c609d659b752edf9c8abbf2233f8afd61a503fd6f88ed625032"
checksum = "50be352d2822ffafb59e3e2ddac9d5ee60f2eeadbb7b5a2a951b9f3651e87a6f"
dependencies = [
"sval",
"sval_ref",
@@ -8199,18 +8201,18 @@ dependencies = [
[[package]]
name = "sval_dynamic"
version = "2.21.1"
version = "2.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4046add0eecf55e680b9e207edf5fc7737b18a1d950db363d97e7f1b2d7c629c"
checksum = "b048ca293b998d9a45659159f94a64063791e74cdc670164943dbb434405573d"
dependencies = [
"sval",
]
[[package]]
name = "sval_fmt"
version = "2.21.1"
version = "2.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "911a3486b5984a0a4f25edefcf2c2dba23654c29f63e75493b671d338bf24243"
checksum = "e6b5888e40f80568733217f27b7317b845f463400ced36c424b1a804730e53b2"
dependencies = [
"itoa",
"ryu",
@@ -8219,9 +8221,9 @@ dependencies = [
[[package]]
name = "sval_json"
version = "2.21.1"
version = "2.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da53aae7c737b5b5f1be4bcb0ff20e057bf6b2ee4e9d025560075c5830d09f95"
checksum = "e17664d6bb6b74947afaab9d7c991caa9bf5638d4dee16fcbef637f440796049"
dependencies = [
"itoa",
"ryu",
@@ -8230,9 +8232,9 @@ dependencies = [
[[package]]
name = "sval_nested"
version = "2.21.1"
version = "2.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df24df43cbdc4bb8c9f5ed19d0d57dc8f60a1a4259cdce52d597fe774ad3a71f"
checksum = "07c059969ca5ca163ea7fef6c9661758973d17691aba92abdcf5c428f4ec122c"
dependencies = [
"sval",
"sval_buffer",
@@ -8241,18 +8243,18 @@ dependencies = [
[[package]]
name = "sval_ref"
version = "2.21.1"
version = "2.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bebc17f0f1fad060e57b778728d41ef87627e9111a6365d7463472cb58fc1b3"
checksum = "42d6b29ff568c85c87561807f51d2adfff4b6016c6363133f7cd1652a12548f3"
dependencies = [
"sval",
]
[[package]]
name = "sval_serde"
version = "2.21.1"
version = "2.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f26fe3f6a68b40e6c8d654ea48c00e4316272fddf68c80493714c1b034ae70b"
checksum = "8f33ec9edc42b12764d5c90ca0a1d84189c6bde81ed27507f1e661c6e4e05853"
dependencies = [
"serde_core",
"sval",
@@ -9120,9 +9122,9 @@ dependencies = [
[[package]]
name = "tree-sitter-language"
version = "0.1.7"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081"
[[package]]
name = "tree-sitter-lua"
@@ -9555,7 +9557,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "util_macros"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"perf",
"quote",
@@ -9611,9 +9613,9 @@ dependencies = [
[[package]]
name = "value-bag-serde1"
version = "1.13.2"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "417d6197dd0ee696783d6be4276ac6ea74b985e00024c85ccfb37aff4f2bed82"
checksum = "27780e3aec98b13ac796bc0d5770205ee6e976d1ccb33a52da70b73ff0ff880d"
dependencies = [
"erased-serde",
"serde_core",
@@ -9622,9 +9624,9 @@ dependencies = [
[[package]]
name = "value-bag-sval2"
version = "1.13.2"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61f7251ecde2c9ed431bbe0659853e7991753447447bbf1ae59d8b31c578d4e"
checksum = "1c093d229c7ead9974b640e61170976e0eff3dac447cf6792c645f0b7f28b3de"
dependencies = [
"sval",
"sval_buffer",
@@ -11320,7 +11322,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]]
name = "zlog"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"anyhow",
"chrono",
@@ -11337,7 +11339,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "ztracing"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
dependencies = [
"tracing",
"tracing-subscriber",
@@ -11348,7 +11350,7 @@ dependencies = [
[[package]]
name = "ztracing_macro"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#399258feeaf90ad8a3a208c99221ee87b6452f38"
source = "git+https://github.com/zed-industries/zed#ce48461eaadd16c65c31f835511ab96bd3b6e746"
[[package]]
name = "zune-core"
+12 -19
View File
@@ -27,11 +27,7 @@ use crate::tiles::SignedTilesSkin;
use crate::{TAB_BAR_HEIGHT, panel_handle};
/// What every part of the skin reads, and the dock area it belongs to.
///
/// The renderer is the only skin-owned object in the picture, so the settings
/// the old `DockArea` carried live here. It is shared by reference with the
/// per-container renderers, which are built once each and outlive any one
/// frame.
/// Shared by reference with the per-container renderers.
pub(crate) struct SkinShared {
area: WeakEntity<DockArea>,
toggle_button_visible: Cell<bool>,
@@ -201,10 +197,9 @@ impl DockAreaRenderer for SignedDockSkin {
.into_any_element()
}
/// The "unknown panel" message the old `InvalidPanel` drew.
///
/// It answers `dump` with the state it was handed, so a layout written by
/// a build that knows the panel survives a load and save here.
/// The "unknown panel" message the old `InvalidPanel` drew. It answers
/// `dump` with the state it was handed, so a layout written by a build
/// that knows the panel survives a load and save here.
fn build_placeholder(
&self,
state: &PanelState,
@@ -246,12 +241,10 @@ impl SignedDockSkin {
}
}
/// Turns the window's mouse stream into dock resizing.
///
/// A resize is driven by pointer moves that land anywhere in the window, not
/// only on the handle, so it cannot be expressed as a listener on the handle
/// itself. This element paints nothing and exists for its `paint` hook, which
/// is the only place a window-level mouse listener can be registered.
/// Turns the window's mouse stream into dock resizing. A resize is driven
/// by pointer moves anywhere in the window, so this paints nothing and
/// exists for its `paint` hook — the only place a window-level mouse
/// listener can be registered.
struct DockResizeTracker {
dock: DockContext,
shared: Rc<SkinShared>,
@@ -317,10 +310,10 @@ impl Element for DockResizeTracker {
if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
return;
}
// Dragging a closed dock's handle reopens it, as the old dock
// did. The live state is read rather than the render-time
// snapshot in `dock`, which would still say closed for the
// rest of the frame and toggle it shut again on the next move.
// Dragging a closed dock's handle reopens it. The live
// state is read rather than the render-time snapshot in
// `dock`, which would still say closed for the rest of the
// frame and toggle it shut again on the next move.
let open = shared
.area()
.upgrade()
+4 -6
View File
@@ -7,12 +7,10 @@ use gpui_component::ActiveTheme as _;
use crate::Panel;
/// Stands in for a panel this build cannot construct — one whose `panel_name`
/// no [`PanelRegistry`](gpui_base::dock::PanelRegistry) builder answers to.
///
/// It reports the original [`PanelState`] from
/// [`dump`](gpui_base::dock::Panel::dump), so a layout written by a build that
/// knows the panel survives a load and a save here rather than losing it.
/// Stands in for a panel this build cannot construct. It reports the
/// original [`PanelState`] from [`dump`](gpui_base::dock::Panel::dump), so
/// a layout written by a build that knows the panel survives a load and
/// save here.
pub(crate) struct InvalidPanel {
name: SharedString,
focus_handle: FocusHandle,
+5 -7
View File
@@ -1,12 +1,10 @@
//! The Signed dock skin.
//!
//! The dock engine lives upstream: `gpui_base::dock` owns the layout tree,
//! the drags, the zoom and the persistence, and `gpui_component::dock`
//! supplies the default appearance. 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 — re-implemented against upstream's renderer
//! traits.
//! 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.
+23 -37
View File
@@ -84,11 +84,9 @@ impl Render for DragPanelPreview {
}
/// Where the zoom affordance goes for the group's displayed panel, or `None`
/// when there is none to offer.
///
/// Two questions, and both have to be asked. [`Panel::zoom_control`] says
/// *where* the control appears; [`gpui_base::dock::Panel::zoomable`] says
/// whether zooming happens at all, and base refuses a zoom that fails it.
/// when there is none to offer. Both [`Panel::zoom_control`] (where) and
/// [`gpui_base::dock::Panel::zoomable`] (whether) must pass; base refuses a
/// zoom that fails the latter.
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
let panel = group.active_panel()?;
panel
@@ -122,11 +120,8 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
}
}
/// One tab group's appearance.
///
/// Built per group — `DockAreaRenderer::tab_group_renderer` is called once
/// per container — so the tab bar's scroll position and the measured
/// title-bar geometry belong to the group they describe.
/// One tab group's appearance. Built once per container, so the tab bar's
/// scroll position and measured title-bar geometry belong to the group.
pub(crate) struct SignedTabGroupSkin {
shared: Rc<SkinShared>,
scroll_handle: ScrollHandle,
@@ -177,9 +172,8 @@ impl SignedTabGroupSkin {
/// The bottom or right dock whose root tab group this group is, if any.
///
/// Base bars a dock's only group from being dragged or closed, so the
/// dock cannot be emptied. A bottom/right panel is supposed to be
/// closable and movable, though — the vendored dock allowed exactly that
/// — so the skin recognizes the group and routes around the bar.
/// 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> {
let area = self.shared.area().upgrade()?;
let area = area.read(cx);
@@ -270,9 +264,7 @@ impl SignedTabGroupSkin {
}
/// The previous/next tab buttons shown in the tab bar's leading prefix.
///
/// Unlike the dock toggle button they always render, but are disabled at
/// the ends of the tab strip (or while the panel is collapsed).
/// Always rendered, disabled at the ends of the strip (or collapsed).
fn render_prev_next_tab_buttons(
&self,
group: &TabGroupContext,
@@ -405,10 +397,9 @@ impl SignedTabGroupSkin {
)
}
/// One tab of the pill strip.
///
/// While collapsed, tabs lose the active style and all interactions, and
/// the strip becomes the way a closed bottom dock is opened again.
/// One tab of the pill strip. While collapsed, tabs lose the active
/// style and all interactions, and the strip becomes the way a closed
/// bottom dock is opened again.
#[allow(clippy::too_many_arguments)]
fn render_tab(
&self,
@@ -578,10 +569,8 @@ impl TabGroupRenderer for SignedTabGroupSkin {
if group.panels().is_empty() {
return div().id("tab-panel");
}
// Closing the only panel of a bottom/right dock would leave an empty
// dock, which base refuses through the group. The skin removes the
// whole dock instead — the vendored dock's close took its split
// group away just the same.
// Closing the only panel of a bottom/right dock would leave an
// empty dock, which base refuses; the skin removes the dock instead.
let dock_to_remove = (group.panels().len() <= 1)
.then(|| self.is_dock_root_group(group, cx))
.flatten();
@@ -601,8 +590,8 @@ impl TabGroupRenderer for SignedTabGroupSkin {
this.on_action({
let group = group.clone();
move |_: &ToggleZoom, window, cx| {
// The affordance decides the control, so a panel that
// offers none is not zoomed *in* by the keybinding
// The affordance decides the control, so a panel
// offering none is not zoomed *in* by the keybinding
// either. Zooming out is never refused: a panel that
// stopped offering the control while zoomed would
// otherwise strand the user with no way back.
@@ -671,12 +660,10 @@ impl TabGroupRenderer for SignedTabGroupSkin {
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let is_bottom_dock = bottom_dock_button.is_some();
// macOS: the traffic lights overlay the window's top-left corner. Only
// the group whose tab bar actually sits under them must reserve the
// space: the left dock (sidebar) normally clears them, and when it is
// closed or absent it is the center's left-most, top-most tab group
// that is in the corner. A bottom or right dock is never there, and
// neither is the right panel of a center split.
// macOS: the traffic lights overlay the window's top-left corner.
// Only the group whose tab bar actually sits under them reserves the
// space the center's left-most, top-most group when the left dock
// is closed or absent.
let needs_traffic_light_padding = cfg!(target_os = "macos")
&& self.shared.area().upgrade().is_some_and(|area| {
let area = area.read(cx);
@@ -703,11 +690,10 @@ impl TabGroupRenderer for SignedTabGroupSkin {
self.scroll_handle.scroll_to_item(visible_ix);
}
// The tab strip lays out its scrollable content at content width, so
// the area after the last tab only spans `min_w_16` — the rest of the
// tab bar has no element at all. Cover that dead zone with a
// measured overlay so the whole non-interactive area can drag the
// window. Its span is [last tab's right edge, suffix's left edge].
// The tab strip lays out at content width, so the area after the
// last tab has no element. Cover that dead zone (last tab's right
// edge to suffix's left edge) with a measured overlay so the whole
// non-interactive area can drag the window.
let drag_overlay = match (
self.title_bar_bounds.get(),
self.title_bar_strip_bounds.get(),
+8 -13
View File
@@ -101,13 +101,11 @@ impl SignedTilesSkin {
})
}
/// The trailing controls of a tile's title bar.
///
/// A tile has no tab bar to hang a toolbar off, so this is where its zoom,
/// close and ellipsis menu live. The entries use click handlers rather
/// than the [`ToggleZoom`](crate::ToggleZoom) and
/// [`ClosePanel`](crate::ClosePanel) actions: those are dispatched to a
/// focused tab group, and a tile is not one.
/// The trailing controls of a tile's title bar: zoom, close and the
/// ellipsis menu. They use click handlers rather than the
/// [`ToggleZoom`](crate::ToggleZoom)/[`ClosePanel`](crate::ClosePanel)
/// actions, which are dispatched to a focused tab group — a tile is
/// not one.
fn render_tile_controls(
&self,
tile: &TileContext,
@@ -388,12 +386,9 @@ impl TilesRenderer for SignedTilesSkin {
.size_full()
}
/// The canvas scrollbar.
///
/// It has to be an overlay rather than one of the frame's own children:
/// the frame is the scroll container and base appends the tiles after
/// whatever the frame carries, so a scrollbar placed there would paint and
/// hit-test underneath every tile.
/// The canvas scrollbar. It must be an overlay: the frame is the scroll
/// container and base appends the tiles after it, so a scrollbar placed
/// inside would paint and hit-test underneath every tile.
fn render_overlay(
&self,
content: Size<Pixels>,
+37 -17
View File
@@ -1,3 +1,5 @@
use std::time::Duration;
use nostr::prelude::*;
use crate::RepoAddr;
@@ -40,8 +42,10 @@ pub fn activity(addr: &RepoAddr) -> Filter {
Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr)
}
/// Status events (`1630..=1633`) referencing a specific root event (`#e` tag).
pub fn statuses_for(root: EventId) -> Filter {
/// Status events (`1630..=1633`) referencing any of the given root events
/// (`#e` tag). Batched: one filter covers all roots, so a negentropy sync
/// reconciles them in a single session instead of one per root.
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
Filter::new()
.kinds([
Kind::GitStatusOpen,
@@ -49,16 +53,17 @@ pub fn statuses_for(root: EventId) -> Filter {
Kind::GitStatusClosed,
Kind::GitStatusDraft,
])
.event(root)
.events(roots)
}
/// Cover notes (kind 1624) and NIP-32 label events (kind 1985) referencing a
/// specific root event (`#e` tag), fetched per root like comments and
/// statuses because they carry no repository `a` tag.
pub fn annotations_for(root: EventId) -> Filter {
/// Cover notes (kind 1624) and NIP-32 label events (kind 1985) referencing
/// any of the given root events (`#e` tag), fetched per root like comments
/// and statuses because they carry no repository `a` tag. Batched, like
/// [`statuses_for`].
pub fn annotations_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
Filter::new()
.kinds([crate::COVER_NOTE_KIND, Kind::Label])
.event(root)
.events(roots)
}
/// A user's grasp list (kind `10317`).
@@ -71,11 +76,10 @@ pub fn grasp_list(public_key: PublicKey) -> Filter {
/// NIP-22 comments (kind `1111`) referencing any of the given root events
/// (issues, patches, PRs).
///
/// Comments are not addressed to the repository — they carry no `a` tag with
/// the repo coordinate — so they must be fetched by their root reference
/// instead. NIP-22 defines the uppercase `E` tag as the root of the thread
/// (used by ngit) while some clients (including Signed itself) reference the
/// root with a lowercase `e` tag, so both are matched.
/// Comments carry no repository `a` tag, so they must be fetched by their
/// root reference. NIP-22 defines the uppercase `E` tag as the thread root
/// (used by ngit), but some clients (including Signed) use a lowercase `e`
/// tag, so both are matched.
///
/// Returns two filters because `#E` and `#e` conditions would be ANDed if
/// combined into one.
@@ -110,12 +114,28 @@ pub fn all_announcements() -> Filter {
Filter::new().kind(Kind::GitRepoAnnouncement)
}
/// All deletion-related events (NIP-09 kind `5`, NIP-62 kind `62`).
/// How far back deletion requests are fetched and stored.
///
/// Unbounded, like [`all_announcements`]: deletion requests must be known
/// before any other event can be shown.
/// A deletion request can only target events created before it, and NIP-34
/// events are all far younger than this window, so older requests can never
/// match anything shown. Bounding the window keeps the kind-5/62 set (one of
/// the largest on public relays) from being fully reconciled on every sync.
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
/// `now` minus [`DELETIONS_LOOKBACK`], quantized to whole days so identical
/// filters hash the same and the backend's sync dedup can match them.
fn deletions_since() -> Timestamp {
let now = Timestamp::now().as_secs();
Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK
}
/// All deletion-related events (NIP-09 kind `5`, NIP-62 kind `62`) within
/// [`DELETIONS_LOOKBACK`]. Deletion requests must be known before any other
/// event can be shown.
pub fn deletions() -> Filter {
Filter::new().kinds([Kind::EventDeletion, Kind::RequestToVanish])
Filter::new()
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
.since(deletions_since())
}
/// Deletion events relevant to a single repository: requests authored by
+14 -25
View File
@@ -70,10 +70,9 @@ const SCAN_SKIPPED_DIRS: [&str; 1] = ["node_modules"];
/// Walk `root` recursively and collect the paths of git repositories
/// (directories containing a `.git` entry) below it.
///
/// Hidden entries and symlinks are skipped, and directories that are
/// themselves repositories are not descended into (so nested repositories,
/// like submodule worktrees, are not reported). Results are canonicalized,
/// deduplicated and sorted by path.
/// Hidden entries and symlinks are skipped; repositories are not descended
/// into, so nested ones (e.g. submodule worktrees) are not reported.
/// Results are canonicalized, deduplicated and sorted.
pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
let mut repos = Vec::new();
if !root.is_dir() {
@@ -121,11 +120,9 @@ pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
/// Clone a repository into `path` from the first working URL in
/// `clone_urls` (the announcement's `clone` tag), then fetch the
/// `refs/nostr/*` PR refs like the cache clone does. The destination must
/// not exist yet; it is created by the clone. The first URL that works
/// wins; when none do, the error of the last failing URL is returned.
/// not exist yet. When no URL works, the last error is returned.
///
/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache;
/// callers open it themselves if they need a [`gix::Repository`].
/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache.
pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> {
if path.exists() {
bail!("destination {} already exists", path.display());
@@ -218,8 +215,8 @@ fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
/// `README.md` derived from `name`/`description`, and create the initial
/// commit. Returns the initial commit id.
///
/// Uses the git CLI (like [`apply_patch`]) because it handles the plumbing
/// (index writes, ref updates, default branch selection) natively.
/// Uses the git CLI (like [`apply_patch`]), which handles index writes,
/// ref updates and default branch selection natively.
pub fn init_repository(path: &Path, name: &str, description: &str) -> Result<String> {
std::fs::create_dir_all(path)
.with_context(|| format!("failed to create {}", path.display()))?;
@@ -371,11 +368,8 @@ fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
}
/// Map an untrusted repository id (or display name) to a safe single path
/// component.
///
/// Replaces everything outside `[A-Za-z0-9._-]` with `_`, and rejects the
/// special components `.` and `..` so the id can't escape a directory it is
/// joined onto.
/// component: everything outside `[A-Za-z0-9._-]` becomes `_`, and the
/// special components `.` and `..` are rejected.
pub fn sanitize_path_component(id: &str) -> String {
let sanitized: String = id
.chars()
@@ -517,12 +511,9 @@ fn file_commit(commit: &gix::Commit<'_>, include_description: bool) -> Result<Fi
}
/// Find the most recent commit that changed `rel` (a path relative to the
/// worktree), like `git log -1 -- <rel>` does for non-merge commits.
///
/// Walks history from `HEAD` newest-first and returns the first commit whose
/// tree entry for `rel` differs from its first parent's; a merge that only
/// changed the file through its second parent is therefore not reported.
/// Returns `Ok(None)` if no commit touched the file (e.g. untracked files).
/// worktree), like `git log -1 -- <rel>`: the first commit, walking from
/// `HEAD` newest-first, whose tree entry for `rel` differs from its first
/// parent's. `Ok(None)` when no commit touched the file (e.g. untracked).
pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result<Option<FileCommit>> {
let rel = rel.to_path_buf();
Ok(last_commits(repo, std::slice::from_ref(&rel))?
@@ -727,10 +718,8 @@ pub struct CommitDiff {
}
/// The changes of the commit `id` (short or full) in the repository at
/// `workdir`, compared against its first parent (the empty tree for the root
/// commit), like `git show`. Directory entries and submodules are skipped;
/// their contents are reported as individual file changes. Files are sorted
/// by path.
/// `workdir`, compared against its first parent (the empty tree for the
/// root commit), like `git show`. Files are sorted by path.
pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> {
commit_diff(&open_with_cache(workdir)?, id)
}
+1
View File
@@ -18,6 +18,7 @@ bitcoin_hashes = "1"
gpui.workspace = true
flume.workspace = true
futures.workspace = true
anyhow.workspace = true
log.workspace = true
+117 -66
View File
@@ -1,7 +1,9 @@
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Duration;
use std::time::{Duration, Instant};
use anyhow::{Error, anyhow, bail};
use bitcoin_hashes::sha1::Hash as Sha1Hash;
@@ -32,6 +34,12 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
/// Relays used for indexing user's relay list (NIP-65).
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
/// How long an identical fetch/sync request is suppressed after it started.
/// A second panel for the same repository (or the global and per-author
/// list stores at login) doesn't duplicate a sync that just ran; after the
/// window, re-fetching is allowed again so data stays fresh.
const FETCH_DEDUP_WINDOW: Duration = Duration::from_secs(5 * 60);
#[derive(Debug, Clone)]
pub enum BackendEvent {
/// User has no signer configured.
@@ -84,6 +92,10 @@ pub struct Backend {
/// Whether the stored credential is NIP-49 encrypted and a passphrase
/// is still needed to resume the session.
passphrase_required: bool,
/// Fingerprints of recently started fetches/syncs (relay + filter set),
/// so duplicate requests within [`FETCH_DEDUP_WINDOW`] collapse into
/// one. Entries are pruned lazily on the next request.
recent_fetches: HashMap<u64, Instant>,
tasks: Vec<Task<Result<(), Error>>>,
}
@@ -134,6 +146,7 @@ impl Backend {
connected: false,
sync_progress: None,
passphrase_required: false,
recent_fetches: HashMap::new(),
tasks: vec![pump],
};
@@ -245,9 +258,8 @@ impl Backend {
/// Decrypt the NIP-49 encrypted credential stored in the keyring with
/// the given passphrase and resume the session.
///
/// The scrypt decryption runs off the UI thread. The returned task
/// yields the public key on success, or the failure reason (e.g. wrong
/// passphrase), so callers can render inline errors.
/// The scrypt decryption runs off the UI thread. The task yields the
/// public key, or the failure reason (e.g. wrong passphrase).
pub fn restore_with_passphrase(
&mut self,
password: &str,
@@ -286,9 +298,8 @@ impl Backend {
/// passphrase (NIP-49) and persist it in the keyring, then publish the
/// user's NIP-65 relay list, metadata and grasp list.
///
/// The heavy encryption runs off the UI thread. The returned task yields
/// the new public key on success, or the failure reason, so callers can
/// render progress and inline errors.
/// The encryption runs off the UI thread; the task yields the new
/// public key.
pub fn create_identity(
&mut self,
name: &str,
@@ -386,11 +397,8 @@ impl Backend {
/// a push for a not-yet-existing repository while that authorization is
/// pending (it expires after 30 minutes), like gitworkshop and ngit.
///
/// The git work (init, commit, push) runs on background threads. The
/// returned task yields the published announcement on success, so
/// callers can open the new repository right away. The announcement's
/// `relays` tag carries the grasp servers, which are also added to the
/// relay pool so the published events reach them.
/// The git work runs on background threads; the task yields the
/// published announcement.
pub fn create_repository(
&mut self,
name: &str,
@@ -435,8 +443,7 @@ impl Backend {
let servers = grasp_servers.clone();
cx.spawn(async move |this, cx| {
// 1. Initialize the local clone (main branch + README + initial
// commit) on a background thread.
// Initialize the local clone (main branch + README + initial commit).
let work = cx.background_spawn({
let path = path.clone();
let name = name.clone();
@@ -467,16 +474,14 @@ impl Backend {
let commit_sha =
Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid initial commit id"))?;
// 2. Ensure the grasp servers are in the relay pool; the nostr
// client queues events until each relay is connected.
// The nostr client queues events until each relay is connected.
this.update(cx, |this, cx| {
let urls: Vec<String> = servers.iter().map(ToString::to_string).collect();
this.add_relays(urls, cx);
})?;
// 3. Publish the announcement, then the state event, to the
// grasp relays. The state event is the push authorization
// ("purgatory"), so it must be accepted before step 4.
// The state event is the push authorization ("purgatory"), so
// it must be accepted before the push below.
let announcement = GitRepositoryAnnouncement {
id: repo_id.clone(),
name: Some(name.clone()),
@@ -522,9 +527,8 @@ impl Backend {
}
};
// 4. Push the initial commit to every grasp server. A server
// that fails to accept the push is logged, but the creation
// only fails when no server accepted it.
// Push to every grasp server; creation only fails when no
// server accepted it.
let push = cx.background_spawn({
let path = path.clone();
let owner = owner.clone();
@@ -555,14 +559,8 @@ impl Backend {
/// state to the grasp relays, then push every branch and tag to each
/// grasp server. Also points `origin` at the first grasp server.
///
/// The events must reach the grasp servers *before* the push, like
/// [`Self::create_repository`]: GRASP servers hold the signed state
/// event in "purgatory" and only accept a push while that
/// authorization is pending.
///
/// The git work (ref listing, push) runs on background threads. The
/// returned task yields the published announcement on success, so
/// callers can switch the repository into its NIP-34 mode.
/// Same ordering constraint as [`Self::create_repository`]: the state
/// event ("purgatory") must be accepted before the push.
pub fn publish_local_repo(
&mut self,
path: PathBuf,
@@ -586,9 +584,8 @@ impl Backend {
return Task::ready(Err(anyhow!("Sign in to publish a repository")));
};
// The repository identifier is derived from the name, like
// [`Self::create_repository`]: spaces become hyphens, other
// non-alphanumeric characters (except `/`) become hyphens.
// The repository identifier is derived from the name as in
// [`Self::create_repository`].
let repo_id = identifier_from_name(&name);
if repo_id.is_empty() || repo_id.len() > 100 {
@@ -607,8 +604,6 @@ impl Backend {
let servers = grasp_servers.clone();
cx.spawn(async move |this, cx| {
// 1. Read the local repository's refs (branches, tags, HEAD)
// and its root commit on a background thread.
let work = cx.background_spawn({
let path = path.clone();
async move {
@@ -619,16 +614,14 @@ impl Backend {
});
let (state, euc) = work.await?;
// 2. Ensure the grasp servers are in the relay pool; the nostr
// client queues events until each relay is connected.
// The nostr client queues events until each relay is connected.
this.update(cx, |this, cx| {
let urls: Vec<String> = servers.iter().map(ToString::to_string).collect();
this.add_relays(urls, cx);
})?;
// 3. Publish the announcement, then the state event, to the
// grasp relays. The state event is the push authorization
// ("purgatory"), so it must be accepted before step 4.
// The state event is the push authorization ("purgatory"), so
// it must be accepted before the push below.
let announcement = GitRepositoryAnnouncement {
id: repo_id.clone(),
name: Some(name.clone()),
@@ -672,10 +665,9 @@ impl Backend {
}
};
// 4. Push every branch and tag to each grasp server. A server
// that fails to accept the push is logged, but the init only
// fails when no server accepted it. An empty repository
// (no refs yet) has nothing to push.
// Push every branch and tag to each grasp server; the init
// only fails when no server accepted it. An empty repository
// has nothing to push.
if !refs.is_empty() {
let push = cx.background_spawn({
let path = path.clone();
@@ -697,8 +689,8 @@ impl Backend {
}
}
// 5. Point `origin` at the first grasp server so later pushes
// have a target, like the create flow.
// Point `origin` at the first grasp server so later pushes
// have a target.
if let Some(base) = servers.first().and_then(grasp_base_url) {
let url = format!("{base}/{owner}/{repo_id}.git");
let path = path.clone();
@@ -732,15 +724,13 @@ impl Backend {
let relays = announcement.relays.clone();
cx.spawn(async move |this, cx| {
// 1. Read the current refs of the local clone.
let work = cx.background_spawn({
let path = path.clone();
async move { signed_git::worktree_ref_state(&path) }
});
let state = work.await?;
// 2. Publish a fresh state event; grasp servers authorize a
// push by the state they have seen.
// Grasp servers authorize a push by the state they have seen.
let refs = state.refs.clone();
let head = state.head.clone();
this.update(cx, |this, cx| {
@@ -749,7 +739,6 @@ impl Backend {
})?
.await?;
// 3. Push every branch and tag to the announced grasp servers.
if !refs.is_empty() {
let push = cx.background_spawn({
let path = path.clone();
@@ -1106,25 +1095,53 @@ impl Backend {
}));
}
/// Whether an identical fetch was started within [`FETCH_DEDUP_WINDOW`]
/// and is still recent enough to suppress a duplicate. Records the
/// fingerprint (after pruning expired entries) when returning `false`.
fn fetch_recently_started(&mut self, fingerprint: u64) -> bool {
self.recent_fetches
.retain(|_, started| started.elapsed() < FETCH_DEDUP_WINDOW);
if self.recent_fetches.contains_key(&fingerprint) {
return true;
}
self.recent_fetches.insert(fingerprint, Instant::now());
false
}
/// Connect to relays announced by a repository (NIP-34 `relays` tag) and
/// fetch its events from them: a one-shot auto-closing subscription for
/// `filters`, plus a negentropy sync so issues, patches and PRs stored
/// only on those relays are not missed.
///
/// Best-effort: failures are logged, not surfaced, because the bootstrap
/// relays already cover the repository. The relays stay in the pool, so
/// events the user publishes for this repository also reach them.
/// Deduplicated: an identical request (same relays and filters) started
/// within [`FETCH_DEDUP_WINDOW`] is skipped, so a second panel for the
/// same repository doesn't re-run the fetch.
///
/// Best-effort: failures are logged, not surfaced. The relays stay in
/// the pool, so later publishes for this repository also reach them.
pub fn connect_repo_relays(
&mut self,
relays: Vec<RelayUrl>,
filters: Vec<Filter>,
cx: &mut Context<Self>,
) {
let relay_strs: Vec<&str> = relays.iter().map(|url| url.as_str()).collect();
let fingerprint = fetch_fingerprint(&relay_strs, &filters);
if self.fetch_recently_started(fingerprint) {
log::debug!("skipping duplicate repo relay fetch");
return;
}
let client = self.client.clone();
self.tasks.push(cx.spawn(async move |_this, _cx| {
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(e) = connect_repo_relays_only(&client, relays, filters).await {
log::warn!("repo relay fetch failed: {e}");
// Allow an immediate retry after a failure.
this.update(cx, |this, _cx| {
this.recent_fetches.remove(&fingerprint);
})
.ok();
}
Ok(())
}));
@@ -1152,7 +1169,17 @@ impl Backend {
/// reconciles the local database with the relays in both directions.
/// Emits [`BackendEvent::SyncProgress`] while running (throttled to
/// whole-percent changes) and [`BackendEvent::Synced`] on completion.
///
/// Deduplicated: an identical sync started within
/// [`FETCH_DEDUP_WINDOW`] is skipped. Observers still see the original
/// sync's progress and completion events.
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter));
if self.fetch_recently_started(fingerprint) {
log::debug!("skipping duplicate bootstrap sync");
return;
}
let client = self.client.clone();
self.sync_progress = Some((0, 0));
@@ -1210,6 +1237,8 @@ impl Backend {
Err(e) => {
this.update(cx, |this, cx| {
this.sync_progress = None;
// Allow an immediate retry after a failure.
this.recent_fetches.remove(&fingerprint);
cx.emit(BackendEvent::error(e.to_string()))
})?;
}
@@ -1221,10 +1250,9 @@ impl Backend {
/// Sign, broadcast and locally store an event. Emits
/// [`BackendEvent::Published`] on success so stores can refresh.
///
/// The returned task yields the outcome of this specific action, so
/// callers can show inline progress/errors instead of relying on
/// the global [`BackendEvent::Error`]. The task is owned by the caller;
/// dropping it cancels the publish.
/// The task yields the outcome of this specific action (for inline
/// progress/errors) and is owned by the caller; dropping it cancels
/// the publish.
pub fn send(
&mut self,
builder: EventBuilder,
@@ -1329,6 +1357,20 @@ impl Backend {
}
}
/// Fingerprint of a relay + filter set, for fetch dedup. Relays and
/// filters are sorted first so the fingerprint is order-independent.
fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 {
let mut relays: Vec<&str> = relays.to_vec();
relays.sort_unstable();
let mut filters: Vec<&Filter> = filters.iter().collect();
filters.sort_unstable();
let mut hasher = DefaultHasher::new();
relays.hash(&mut hasher);
filters.hash(&mut hasher);
hasher.finish()
}
/// Add the given relays, connect to them, and fetch the filters: a one-shot
/// subscription (auto-closing after EOSE) plus a negentropy sync per filter
/// as a second pass, so events that race with the subscription or relays
@@ -1343,10 +1385,15 @@ async fn connect_repo_relays_only(
return Ok(());
}
let mut added = false;
for url in &relays {
client.add_relay(url).await?;
added |= client.add_relay(url).await?;
}
// Connecting is only needed when the pool grew; connected relays no-op,
// but the call still iterates every relay in the pool.
if added {
client.connect().await;
}
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
@@ -1358,17 +1405,21 @@ async fn connect_repo_relays_only(
.collect();
client.subscribe(target).close_on(opts).await?;
for filter in filters {
// Sync the filters concurrently: each reconciles against every relay
// either way, and a relay without NEG-XX support otherwise serializes
// its initial timeout behind every other filter.
let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5));
if let Err(e) = client
.sync(filter)
.with(relays.iter())
.opts(sync_opts)
.await
{
let syncs = filters.into_iter().map(|filter| {
let client = &client;
let relays = &relays;
let sync_opts = sync_opts.clone();
async move {
if let Err(e) = client.sync(filter).with(relays.iter()).opts(sync_opts).await {
log::warn!("repo relay negentropy sync failed: {e}");
}
}
});
futures::future::join_all(syncs).await;
Ok(())
}
+4 -24
View File
@@ -34,7 +34,6 @@ pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
.install_default()
.ok();
// Initialize the nostr client and universal signer.
let (client, signer) = cx.foreground_executor().block_on(async move {
let path = db_path.as_ref().to_path_buf();
new_backend(path)
@@ -42,24 +41,18 @@ pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
.expect("failed to initialize nostr backend")
});
// Initialize the backend and stores.
let entity = cx.new(|cx| Backend::new(client, signer, cx));
Backend::set_global(entity.clone(), cx);
// Initialize the profile store.
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
// Start the explore list from the local database before
// the first window opens, relay syncs continue in the background,
// so the list never waits for them.
// Seed the explore list from the local database; relay syncs continue
// in the background.
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
// The clone cache is only meaningful on native platforms,
// the wasm build registers an empty store so `GitStore::global` still works.
// The clone cache is native-only; wasm registers an empty store so
// `GitStore::global` still works.
GitStore::set_global(PathBuf::new(), cx);
// Scan the default directories (Desktop, Documents) for local git
// repositories; the sidebar lists them next to the user's NIP-34 repos.
LocalReposStore::set_global(
cx.new(|cx| LocalReposStore::new(default_scan_paths(), cx)),
cx,
@@ -71,26 +64,13 @@ pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
/// Initialize the backend with an in-memory database on wasm.
#[cfg(target_arch = "wasm32")]
pub fn init(cx: &mut App) -> Entity<Backend> {
// Initialize the nostr client and universal signer.
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
// Initialize the backend and stores.
let entity = cx.new(|cx| Backend::new(client, signer, cx));
Backend::set_global(entity.clone(), cx);
// Initialize the profile store.
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
// Start the explore list from the local database before
// the first window opens, relay syncs continue in the background,
// so the list never waits for them.
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
// The clone cache is only meaningful on native platforms,
// the wasm build registers an empty store so `GitStore::global` still works.
GitStore::set_global(PathBuf::new(), cx);
// No filesystem scan on wasm: there are no local git repositories.
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
entity
+134 -66
View File
@@ -1,5 +1,5 @@
use std::borrow::Cow;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use anyhow::Error;
@@ -33,11 +33,21 @@ pub struct RepoStore {
pub pull_requests: Vec<Event>,
/// Comments on issues / PRs, oldest first.
pub comments: Vec<Event>,
statuses: Vec<Event>,
/// Resolved status per root event (issue / patch / PR), recomputed on
/// every refresh so render paths are HashMap lookups instead of
/// scanning all status events per root.
status_by_root: HashMap<EventId, RepoStatus>,
/// Open issue / root PR counts, computed with [`Self::status_by_root`]
/// on every refresh.
open_issue_count: usize,
open_pr_count: usize,
/// Kind-1624 cover notes and kind-1985 label events referencing this
/// repository's roots (ngit / GitWorkshop extensions).
cover_notes: Vec<Event>,
labels: Vec<Event>,
/// Incremented on every applied refresh; views key their derived-data
/// caches to it instead of recomputing on every render.
version: u64,
/// Error of the last action initiated from this store, if any.
pub last_error: Option<String>,
/// Relays announced by this repository (NIP-34 `relays` tag) that we
@@ -111,9 +121,12 @@ impl RepoStore {
patches: Vec::new(),
pull_requests: Vec::new(),
comments: Vec::new(),
statuses: Vec::new(),
status_by_root: HashMap::new(),
open_issue_count: 0,
open_pr_count: 0,
cover_notes: Vec::new(),
labels: Vec::new(),
version: 0,
last_error: None,
repo_relays: HashSet::new(),
root_fetches: HashSet::new(),
@@ -141,8 +154,13 @@ impl RepoStore {
/// deletions targeting it.
fn repo_filters(addr: &RepoAddr) -> Vec<Filter> {
let mut filters = vec![
filters::announcement(addr),
filters::state(addr),
// Announcement and state share author and identifier, so they
// combine into one filter: one fewer negentropy reconciliation
// per relay when fetching from the repo's announced relays.
Filter::new()
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
.author(addr.public_key)
.identifier(addr.identifier.clone()),
filters::activity(addr),
];
// Deletion requests (NIP-09/62) must be known before any event of
@@ -293,7 +311,7 @@ impl RepoStore {
.chain(&pull_requests)
.map(|e| e.id);
for root in roots {
for event in db.query(filters::statuses_for(root)).await? {
for event in db.query(filters::statuses_for([root])).await? {
if seen_statuses.insert(event.id) {
statuses.push(event);
}
@@ -312,7 +330,7 @@ impl RepoStore {
.chain(&pull_requests)
.map(|e| e.id);
for root in roots {
for event in db.query(filters::annotations_for(root)).await? {
for event in db.query(filters::annotations_for([root])).await? {
if deletions.is_deleted(&event) {
continue;
}
@@ -331,13 +349,36 @@ impl RepoStore {
sort_newest_first(&mut cover_notes);
sort_newest_first(&mut labels);
// Resolve every root's status once here; render paths do
// HashMap lookups instead of scanning all status events per
// root (quadratic, with an allocation per pair).
let maintainers = announcement
.as_ref()
.map(Announcement::effective_maintainers)
.unwrap_or_default();
let status_by_root =
resolve_statuses(&issues, &patches, &pull_requests, &statuses, &maintainers);
let open_issue_count = issues
.iter()
.filter(|issue| status_of(&status_by_root, issue) == RepoStatus::Open)
.count();
let open_pr_count = pull_requests
.iter()
.filter(|pr| {
pr.kind == Kind::GitPullRequest
&& status_of(&status_by_root, pr) == RepoStatus::Open
})
.count();
Ok::<_, Error>((
announcement,
state,
issues,
patches,
pull_requests,
statuses,
status_by_root,
open_issue_count,
open_pr_count,
comments,
cover_notes,
labels,
@@ -353,7 +394,9 @@ impl RepoStore {
issues,
patches,
pull_requests,
statuses,
status_by_root,
open_issue_count,
open_pr_count,
comments,
cover_notes,
labels,
@@ -389,9 +432,12 @@ impl RepoStore {
this.patches = patches;
this.pull_requests = pull_requests;
this.comments = comments;
this.statuses = statuses;
this.status_by_root = status_by_root;
this.open_issue_count = open_issue_count;
this.open_pr_count = open_pr_count;
this.cover_notes = cover_notes;
this.labels = labels;
this.version = this.version.wrapping_add(1);
// Comments, statuses without an `a` tag, cover notes and
// labels are not addressed to the repository, so fetch them
@@ -411,25 +457,18 @@ impl RepoStore {
.collect();
if !new_roots.is_empty() {
this.root_fetches.extend(new_roots.iter().copied());
let comment_filters = filters::comments_for(new_roots.clone());
let status_filters: Vec<Filter> = new_roots
.iter()
.copied()
.map(filters::statuses_for)
.collect();
let annotation_filters: Vec<Filter> = new_roots
.into_iter()
.map(filters::annotations_for)
.collect();
// Batch the per-root filters: one statuses filter and one
// annotations filter covering all new roots, instead of
// one filter per root (each filter is a separate
// negentropy reconciliation per relay).
let mut root_filters = filters::comments_for(new_roots.clone());
root_filters.push(filters::statuses_for(new_roots.iter().copied()));
root_filters.push(filters::annotations_for(new_roots));
let announced: Vec<RelayUrl> = this.repo_relays.iter().cloned().collect();
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| {
backend.subscribe_bootstrap(comment_filters.clone(), cx);
backend.connect_repo_relays(announced.clone(), comment_filters, cx);
backend.subscribe_bootstrap(status_filters.clone(), cx);
backend.connect_repo_relays(announced.clone(), status_filters, cx);
backend.subscribe_bootstrap(annotation_filters.clone(), cx);
backend.connect_repo_relays(announced, annotation_filters, cx);
backend.subscribe_bootstrap(root_filters.clone(), cx);
backend.connect_repo_relays(announced, root_filters, cx);
});
}
@@ -454,20 +493,17 @@ impl RepoStore {
}));
}
/// Resolve the status of a root event (issue / patch / PR) per NIP-34.
/// Resolve the status of a root event (issue / patch / PR) per NIP-34:
/// a lookup into the map built on the last refresh.
pub fn status_of(&self, root: &Event) -> RepoStatus {
let maintainers = self
.announcement
.as_ref()
.map(Announcement::effective_maintainers)
.unwrap_or_default();
status_of(&self.status_by_root, root)
}
let events = self
.statuses
.iter()
.filter(|e| signed_core::references_root(e, &root.id));
signed_core::resolve_status(events, &root.pubkey, &maintainers)
/// Refresh generation, incremented on every applied refresh. Views use
/// it to key their derived-data caches (filtered lists, counts) so
/// renders that change nothing stay O(1).
pub fn version(&self) -> u64 {
self.version
}
/// The effective cover note of `root` (kind 1624), if any: the latest
@@ -509,21 +545,16 @@ impl RepoStore {
/// Number of open issues: issues whose resolved status is
/// [`RepoStatus::Open`] (issues without status events default to open).
/// Cached on the last refresh.
pub fn issue_count(&self) -> usize {
self.issues
.iter()
.filter(|issue| self.status_of(issue) == RepoStatus::Open)
.count()
self.open_issue_count
}
/// Number of open pull requests: root PR events (not PR updates, whose
/// status is carried by the root) with a resolved status of
/// [`RepoStatus::Open`].
/// [`RepoStatus::Open`]. Cached on the last refresh.
pub fn pull_request_count(&self) -> usize {
self.pull_requests
.iter()
.filter(|pr| pr.kind == Kind::GitPullRequest && self.status_of(pr) == RepoStatus::Open)
.count()
self.open_pr_count
}
/// Whether `user` is the author (owner) of this repository: the public
@@ -586,15 +617,12 @@ impl RepoStore {
/// (kind 1617) carrying the `git format-patch` output, which the PR
/// references via an `e` tag (NIP-34).
///
/// The patch is published first and the PR is sent once the patch
/// event's id is known, so the two always arrive together. The proposed
/// commit is parsed from the patch's `From <commit>` header; publishing
/// without one is refused, because the PR's `c` tag (and the patch's
/// `commit`/`r` tags) must carry a real commit id for other NIP-34
/// clients to verify and apply the proposal. The PR's `clone` tag
/// carries the repository's announced mirror URLs (the commit may not be
/// pushed there yet; the linked patch is the source of truth until a
/// push backend exists).
/// The patch is published first so the PR can reference its id. The
/// proposed commit is parsed from the patch's `From <commit>` header;
/// without one publishing is refused, because the PR's `c` tag must
/// carry a real commit id for other NIP-34 clients to verify and apply
/// the proposal. The `clone` tag carries the announced mirror URLs; the
/// linked patch is the source of truth until the commit is pushed there.
pub fn open_pull_request(
&mut self,
subject: Option<String>,
@@ -783,10 +811,9 @@ impl RepoStore {
/// the merged status.
///
/// Only the repository author may merge. The clone is created on demand
/// from the announcement's clone URLs when the repository hasn't been
/// mirrored locally yet. Patch application runs on a background thread
/// (`git am`); failures (e.g. a patch that no longer applies) surface in
/// [`Self::last_error`] and no status is sent.
/// from the announcement's clone URLs when needed. Patch application
/// (`git am`) runs on a background thread; failures (e.g. a patch that
/// no longer applies) surface in [`Self::last_error`].
pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context<Self>) {
self.last_error = None;
@@ -860,6 +887,49 @@ where
events.into_iter().max_by_key(|e| e.created_at)
}
/// Status of `root` from the precomputed map; roots without status events
/// default to [`RepoStatus::Open`], like [`signed_core::resolve_status`].
fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, root: &Event) -> RepoStatus {
status_by_root
.get(&root.id)
.copied()
.unwrap_or(RepoStatus::Open)
}
/// Resolve the status of every root event in one pass: status events are
/// indexed by the root they reference (`e`/`E` tag), then each root
/// resolves against its own slice. O(roots + statuses) instead of the
/// O(roots × statuses) of resolving per root on demand.
fn resolve_statuses(
issues: &[Event],
patches: &[Event],
pull_requests: &[Event],
statuses: &[Event],
maintainers: &[PublicKey],
) -> HashMap<EventId, RepoStatus> {
let mut by_root: HashMap<EventId, Vec<&Event>> = HashMap::new();
for event in statuses {
for tag in event.tags.iter() {
if matches!(tag.kind(), "e" | "E")
&& let Some(id) = tag.content().and_then(|hex| EventId::from_hex(hex).ok())
{
by_root.entry(id).or_default().push(event);
}
}
}
issues
.iter()
.chain(patches)
.chain(pull_requests)
.map(|root| {
let events = by_root.get(&root.id).map(Vec::as_slice).unwrap_or(&[]);
let status = signed_core::resolve_status(events.iter().copied(), &root.pubkey, maintainers);
(root.id, status)
})
.collect()
}
fn sort_newest_first(events: &mut [Event]) {
events.sort_by_key(|e| std::cmp::Reverse(e.created_at));
}
@@ -876,12 +946,10 @@ fn patch_current_commit(patch: &str) -> Option<&str> {
hex.split_whitespace().next().filter(|hex| hex.len() == 40)
}
/// Build a NIP-22 kind-1111 comment using the SDK's [`CommentBuilder`]:
/// uppercase `E`/`K`/`P` tags scope the thread root, lowercase `e`/`k`/`p`
/// tags the direct parent (`parent`, or the root itself for a top-level
/// comment). An `a` tag with the repository coordinate is added so Signed's
/// own activity subscriptions also match the comment (it is not part of
/// NIP-22).
/// Build a NIP-22 kind-1111 comment: uppercase `E`/`K`/`P` tags scope the
/// thread root, lowercase `e`/`k`/`p` the direct parent (or the root for a
/// top-level comment). An `a` tag with the repository coordinate (not part
/// of NIP-22) is added so Signed's own activity subscriptions also match.
fn comment_builder(
root: &Event,
parent: Option<&Event>,
+1 -2
View File
@@ -22,8 +22,7 @@ impl Global for GlobalRepoListStore {}
/// Counts of NIP-34 activity events per repository, used to rank the
/// explore list by popularity. Each patch event is a pushed commit (or a
/// small commit series), which is the closest cross-repository proxy for
/// commit count available from event data alone.
/// small series), the closest proxy for commit count in the event data.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RepoActivityCounts {
/// Root `30611` issue events addressed to the repository.
@@ -39,12 +39,9 @@ pub(super) enum FileContent {
/// A markdown document loaded into a persistent [`TextViewState`].
///
/// The state is owned by the view rather than created per render (as the
/// stateless `text::markdown` helper does), so it survives branch switches
/// in the content pane. GPUI's keyed element state is dropped as soon as the
/// element is absent for a single frame, which would otherwise re-parse the
/// whole document on the main thread every time the pane switches between
/// the README, a file preview, and the loading spinner.
/// The state is owned by the view rather than created per render: GPUI
/// drops keyed element state after one absent frame, which would re-parse
/// the whole document on every pane switch (README / file / spinner).
pub(super) struct MarkdownView {
/// Source path; `None` means the repository README.
pub(super) path: Option<SharedString>,
@@ -53,11 +50,7 @@ pub(super) struct MarkdownView {
/// A code file loaded into a persistent [`InputState`], rendered as a
/// disabled (read-only) code editor with syntax highlighting, line numbers
/// and search.
///
/// Same persistence rationale as [`MarkdownView`]: the state lives as long
/// as this view, so re-viewing the same file does not re-parse it, and
/// parsing happens on a background task inside the editor.
/// and search. Persistent for the same reason as [`MarkdownView`].
pub(super) struct CodeView {
/// Source path, relative to the worktree root.
pub(super) path: SharedString,
@@ -230,9 +223,7 @@ impl RepoDetailView {
/// Load `text` into the persistent markdown TextView state.
///
/// The state is created empty and fed via `push_str`, which parses on a
/// background task: switching files never blocks the main thread, and
/// the state lives as long as this view, so re-viewing the same document
/// does not re-parse it.
/// background task, so switching files never blocks the main thread.
pub(super) fn set_markdown(
&mut self,
path: Option<SharedString>,
@@ -269,10 +260,8 @@ impl RepoDetailView {
/// Load `text` into the persistent code editor state for `path`.
///
/// The state is created in code editor mode so the Input renders it as
/// a syntax-highlighted, read-only editor. Like [`set_markdown`], the
/// state lives as long as this view, so re-viewing the same file does
/// not re-parse it; the tree-sitter parse runs on a background task
/// inside the editor instead of blocking the main thread.
/// a syntax-highlighted, read-only editor; the tree-sitter parse runs
/// on a background task like [`set_markdown`]'s.
pub(super) fn set_code(
&mut self,
path: SharedString,
@@ -276,11 +276,8 @@ pub(super) fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
/// A split dropdown button built on `gpui_base::Popover`: an action element
/// with a separate caret trigger that opens a [`PopupMenu`].
///
/// The action and the caret are ordinary elements supplied by the caller, so
/// the look — icons, borders, hover states, sizes — stays fully in the
/// application. The component only owns the popover wiring: opening on caret
/// click, Escape/outside dismissal, focus movement into the menu, and the
/// menu entity's lifecycle.
/// The action and the caret are caller-supplied elements, so the look stays
/// in the application; this component only owns the popover wiring.
#[derive(IntoElement)]
pub(super) struct BaseDropdownButton {
id: ElementId,
@@ -493,11 +490,10 @@ impl ShareTargets {
}
}
/// One row of the share menu: a small title on top of the compact label,
/// with a copy button that flips to a check while the value is on the
/// clipboard. Clicking the row text copies and dismisses the menu; the copy
/// button stops propagation, so the menu stays open for further copies.
/// Both copy `copy`, never the truncated label.
/// One row of the share menu: a small title above the compact label, with
/// a copy button that flips to a check while the value is on the clipboard.
/// Clicking the row copies and dismisses the menu; the copy button stops
/// propagation so the menu stays open. Both copy `copy`, never the label.
pub(super) fn share_menu_row(
id: &'static str,
title: &'static str,
@@ -1,3 +1,5 @@
use std::collections::HashMap;
use assets::CustomIconName;
use dock::{BasePanel, Panel, PanelEvent};
use gpui::prelude::*;
@@ -27,6 +29,10 @@ pub struct IssueDetailView {
issue_id: EventId,
/// Input state of the "leave a comment" textarea.
comment_input: Entity<TextareaState>,
/// Issue/comment bodies as shared strings, keyed by event ID, so
/// re-renders don't clone full contents again (events are immutable,
/// so the cache never needs invalidation).
contents: HashMap<EventId, SharedString>,
}
impl IssueDetailView {
@@ -44,6 +50,7 @@ impl IssueDetailView {
store,
issue_id,
comment_input,
contents: HashMap::new(),
}
}
@@ -142,6 +149,13 @@ impl IssueDetailView {
let author = profile.name();
let picture = profile.picture();
let age = relative_time(comment.created_at);
// Comment bodies are cloned into shared strings once per
// comment, not on every render.
let content = self
.contents
.entry(comment.id)
.or_insert_with(|| SharedString::from(comment.content.clone()))
.clone();
v_flex()
.gap_1()
@@ -176,11 +190,7 @@ impl IssueDetailView {
.child(SharedString::from(age)),
),
)
.child(
div()
.text_sm()
.child(SharedString::from(comment.content.clone())),
)
.child(div().text_sm().child(content))
}))
.into_any_element()
}
@@ -284,6 +294,11 @@ impl Render for IssueDetailView {
let (title, author, picture, status, age, issue_id, content) = {
let profile_store = ProfileStore::global(cx);
let profile = profile_store.read(cx).get(&issue.pubkey);
let content = self
.contents
.entry(issue.id)
.or_insert_with(|| SharedString::from(issue.content.clone()))
.clone();
(
activity_subject(issue),
@@ -292,7 +307,7 @@ impl Render for IssueDetailView {
store.status_of(issue),
relative_time(issue.created_at),
issue.id,
issue.content.clone(),
content,
)
};
@@ -358,7 +373,7 @@ impl Render for IssueDetailView {
.child(SharedString::from(age)),
),
)
.child(div().text_sm().child(SharedString::from(&content))),
.child(div().text_sm().child(content)),
)
.child(self.render_comments(&issue_id, cx))
.child(self.render_form(&issue_id, cx)),
@@ -17,7 +17,7 @@ use gpui_component::scroll::Scrollbar;
use gpui_component::{
ActiveTheme, Icon, Sizable, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
};
use nostr::prelude::{Event, EventId};
use nostr::prelude::EventId;
use signed_core::{RepoStatus, activity_subject};
use signed_state::{ProfileStore, RepoStore};
use utils::relative_time;
@@ -26,10 +26,8 @@ use super::helpers::{placeholder, status_badge};
use super::issue_detail::IssueDetailView;
use crate::image_cache::{MAX_IMAGES, image_cache};
/// Height of one issue row in the virtual list: 8px vertical padding
/// (`py_2`) on top and bottom, a 32px title line (`h_8`) and a 24px meta
/// line (`h_6`), plus the 1px bottom border; the row totals 73px. The
/// status chip (`size_7`, 28px) is shorter than the content.
/// Height of one issue row in the virtual list: `py_2` padding, a 32px
/// title line (`h_8`), a 24px meta line (`h_6`) and the 1px bottom border.
const ISSUE_ROW_HEIGHT: f32 = 73.;
/// Status filter of the issues list, chosen via the header's filter buttons.
@@ -39,21 +37,18 @@ enum IssueFilter {
All,
/// Issues whose resolved status is [`RepoStatus::Open`].
Open,
/// Issues whose resolved status is
/// [`RepoStatus::Closed`] or [`RepoStatus::Applied`] (both are "done" states).
/// Issues whose resolved status is [`RepoStatus::Closed`] or
/// [`RepoStatus::Applied`] (both are "done" states).
Closed,
}
impl IssueFilter {
/// Whether `issue` (of `store`) is included by this filter.
fn matches(self, store: &RepoStore, issue: &Event) -> bool {
/// Whether an issue with `status` is included by this filter.
fn matches(self, status: RepoStatus) -> bool {
match self {
Self::All => true,
Self::Open => store.status_of(issue) == RepoStatus::Open,
Self::Closed => matches!(
store.status_of(issue),
RepoStatus::Closed | RepoStatus::Applied
),
Self::Open => status == RepoStatus::Open,
Self::Closed => matches!(status, RepoStatus::Closed | RepoStatus::Applied),
}
}
}
@@ -72,9 +67,15 @@ pub struct IssuesView {
item_sizes: Rc<Vec<Size<Pixels>>>,
/// Number of rows [`Self::item_sizes`] was built for (the filtered issue count).
issue_len: usize,
/// Indices into the store's `issues` matching [`Self::filter`], rebuilt
/// every render; the virtual list renders this slice.
/// Indices into the store's `issues` matching [`Self::filter`]; the
/// virtual list renders this slice. Rebuilt only when the store
/// version or the filter changes, keyed by [`Self::cache_key`].
visible_issues: Vec<usize>,
/// Header counts `(total, open, closed)`, rebuilt with
/// [`Self::visible_issues`].
counts: (usize, usize, usize),
/// Store version and filter the cached rows/counts were built from.
cache_key: Option<(u64, IssueFilter)>,
/// Virtual list state of the issues list.
scroll_handle: VirtualListScrollHandle,
}
@@ -96,6 +97,8 @@ impl IssuesView {
item_sizes: Rc::new(Vec::new()),
issue_len: 0,
visible_issues: Vec::new(),
counts: (0, 0, 0),
cache_key: None,
scroll_handle: VirtualListScrollHandle::new(),
}
}
@@ -188,19 +191,9 @@ impl IssuesView {
}
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let store = self.store.read(cx);
let (total, open, closed) =
store
.issues
.iter()
.fold(
(0usize, 0usize, 0usize),
|(total, open, closed), issue| match store.status_of(issue) {
RepoStatus::Open => (total + 1, open + 1, closed),
RepoStatus::Closed => (total + 1, open, closed + 1),
RepoStatus::Draft | RepoStatus::Applied => (total + 1, open, closed),
},
);
// Counts of the last list rebuild (`render` rebuilds first when the
// store version or filter changed, so this is never stale).
let (total, open, closed) = self.counts;
h_flex()
.px_4()
@@ -433,18 +426,30 @@ impl Render for IssuesView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let filter = self.filter;
// Indices of the issues matching the active filter; the virtual
// list renders this filtered slice.
self.visible_issues = {
// Rebuild the filtered rows and header counts only when the store
// refreshed or the filter changed; other renders reuse the cache.
let version = self.store.read(cx).version();
if self.cache_key != Some((version, filter)) {
let store = self.store.read(cx);
store
let mut counts = (0usize, 0usize, 0usize);
self.visible_issues = store
.issues
.iter()
.enumerate()
.filter(|(_, issue)| filter.matches(store, issue))
.map(|(ix, _)| ix)
.collect()
};
.filter_map(|(ix, issue)| {
let status = store.status_of(issue);
counts.0 += 1;
match status {
RepoStatus::Open => counts.1 += 1,
RepoStatus::Closed => counts.2 += 1,
RepoStatus::Draft | RepoStatus::Applied => {}
}
filter.matches(status).then_some(ix)
})
.collect();
self.counts = counts;
self.cache_key = Some((version, filter));
}
let count = self.visible_issues.len();
+92 -50
View File
@@ -27,7 +27,7 @@ use gpui_component::{
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled,
VirtualListScrollHandle, h_flex, v_flex,
};
use nostr::prelude::{RelayUrl, ToBech32};
use nostr::prelude::{EventId, RelayUrl, ToBech32};
use signed_core::Announcement;
use signed_git::{CommitList, FileCommit};
use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoStore};
@@ -98,6 +98,20 @@ struct RepoData {
head_commit: Option<FileCommit>,
}
/// Derived NIP-34 header data, cached so renders don't re-encode bech32
/// share targets and rebuild clone command strings on every frame.
struct HeaderCache {
/// Announcement event ID and owner NIP-05 this cache was built from;
/// rebuilt when either changes (a new announcement version, or the
/// owner's profile arriving with a NIP-05 identifier).
key: (EventId, Option<String>),
announcement: Rc<Announcement>,
share: Rc<ShareTargets>,
ngit_command: SharedString,
nak_command: SharedString,
git_commands: Rc<Vec<SharedString>>,
}
/// Detail view of a repository: header, stats, a file explorer with README
/// preview (cloned from the announcement's `clone` URLs), and metadata.
pub struct RepoDetailView {
@@ -170,6 +184,10 @@ pub struct RepoDetailView {
/// Bumped on every branch/tag switch; in-flight loads tagged with an
/// older generation are discarded when they complete.
ref_generation: u64,
/// Derived NIP-34 header data (share targets, clone commands),
/// rebuilt only when the announcement or the owner's NIP-05 changes
/// instead of on every render.
header_cache: Option<HeaderCache>,
/// 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<(), Error>>>,
@@ -298,6 +316,7 @@ impl RepoDetailView {
tag_select,
switching_ref: false,
ref_generation: 0,
header_cache: None,
focus_handle: cx.focus_handle(),
tasks: Vec::new(),
_subscriptions: subscriptions,
@@ -308,9 +327,7 @@ impl RepoDetailView {
/// (not yet published) repository is opened straight from disk. An
/// announced repository's local clone (if any) is loaded first without
/// touching the network, so an unreachable server can't block the
/// panel; a background fetch then refreshes the refs and commit list
/// (a fetch never changes the checked-out files, so the tree and
/// previews are left alone).
/// panel; a background fetch then refreshes the refs and commit list.
fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true;
self.error = None;
@@ -427,9 +444,9 @@ impl RepoDetailView {
return;
}
if let Ok(Some((branches, tags, current_branch, head_commit))) = refresh {
let branches: Vec<SharedString> =
branches.into_iter().map(Into::into).collect();
let tags: Vec<SharedString> = tags.into_iter().map(Into::into).collect();
let branches: Vec<SharedString> = branches.iter().map(Into::into).collect();
let tags: Vec<SharedString> = tags.iter().map(Into::into).collect();
this.branch_select.update(cx, |state, cx| {
state.set_items(SearchableVec::from(branches), window, cx);
if let Some(branch) = current_branch {
@@ -437,14 +454,22 @@ impl RepoDetailView {
state.set_selected_values(&[branch], window, cx);
}
});
this.tag_select.update(cx, |state, cx| {
state.set_items(SearchableVec::from(tags), window, cx);
});
let new_head_commit = head_commit.as_ref().map(|c| &c.id);
let current_head_commit = this.head_commit.as_ref().map(|c| &c.id);
let head_changed = new_head_commit != current_head_commit;
this.head_commit = head_commit;
// The fetch may have brought new commits: reload the list.
if head_changed || this.all_commits.is_none() {
this.all_commits = None;
this.loading_all_commits = false;
this.load_all_commits(cx);
}
cx.notify();
}
})?;
@@ -455,8 +480,8 @@ impl RepoDetailView {
self.tasks.push(task);
}
/// Apply the loaded repository data: explorer tree, README preview, ref
/// selectors and HEAD commit, then start the commit-list walk.
/// Apply the loaded repository data: explorer tree, README preview,
/// ref selectors and HEAD commit, then start the commit-list walk.
fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context<Self>) {
let RepoData {
tree,
@@ -479,10 +504,11 @@ impl RepoDetailView {
state.set_items(tree_items(tree, false), cx);
});
// Populate the branch/tag selectors with the local refs, selecting
// the branch HEAD points to.
// Populate the branch/tag selectors with the local refs,
// selecting the branch HEAD points to.
let branches: Vec<SharedString> = branches.into_iter().map(Into::into).collect();
let tags: Vec<SharedString> = tags.into_iter().map(Into::into).collect();
self.branch_select.update(cx, |state, cx| {
state.set_items(SearchableVec::from(branches), window, cx);
if let Some(branch) = current_branch {
@@ -490,11 +516,13 @@ impl RepoDetailView {
state.set_selected_values(&[branch], window, cx);
}
});
self.tag_select.update(cx, |state, cx| {
state.set_items(SearchableVec::from(tags), window, cx);
});
self.load_all_commits(cx);
if let Some((path, bytes)) = readme_path.zip(readme) {
self.readme_name = Some(path.to_string_lossy().into());
self.load_commit(&path.to_string_lossy(), cx);
@@ -504,10 +532,8 @@ impl RepoDetailView {
}
}
/// Clone the repository into a folder chosen by the user (outside the
/// cache), then open the new clone in the system file manager. Like
/// ngit's clone, this resolves the announcement's `clone` URLs and
/// clones from the first working git server.
/// Clone the repository into a folder chosen by the user (outside the cache),
/// then open the new clone in the system file manager.
fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.cloning {
return;
@@ -722,11 +748,8 @@ impl RepoDetailView {
/// Walk history once for every queued path on a background task, and
/// cache the latest commit touching each of them in [`Self::commits`]
/// (for the file header in the content column).
///
/// Batching shares one walk (and its object decodes) across all paths
/// queued while the previous walk was in flight, instead of walking the
/// full history per file.
/// (for the file header in the content column). Batching shares one
/// walk across all paths queued while the previous walk was in flight.
fn load_commits(&mut self, cx: &mut Context<Self>) {
if self.pending_commits.is_empty() || self.loading_commits {
return;
@@ -1029,8 +1052,7 @@ impl RepoDetailView {
/// Trigger body for the branch/tag selectors: the kind icon, the
/// selection (or placeholder) and the caret. `Combobox` replaces its
/// default trigger entirely, which is the only way to show an icon
/// inside the trigger label.
/// default trigger entirely, the only way to show an icon inside it.
fn render_ref_trigger(
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
icon: CustomIconName,
@@ -1201,7 +1223,7 @@ impl RepoDetailView {
/// The NIP-34 header (actions, issues/PR counts) or, for a local
/// repository that hasn't been published yet, the local header with an
/// Init button.
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
fn render_header(&mut self, cx: &mut Context<Self>) -> AnyElement {
if self.local_path.is_some() {
return self.render_local_header(cx);
}
@@ -1210,26 +1232,53 @@ impl RepoDetailView {
return div().into_any_element();
};
let store = store_entity.read(cx);
let Some(announcement) = store
.announcement
.as_ref()
.or(self.initial.as_ref())
.cloned()
else {
return div().into_any_element();
};
let issue_count = SharedString::from(store.issue_count().to_string());
let pr_count = SharedString::from(store.pull_request_count().to_string());
let Some(source) = store.announcement.as_ref().or(self.initial.as_ref()) else {
return div().into_any_element();
};
// The header derives bech32 share targets and clone command strings
// from the announcement; rebuild them only when the announcement or
// the owner's NIP-05 changes, not on every render.
let nip05 = ProfileStore::global(cx)
.read(cx)
.get(&source.owner)
.metadata()
.nip05
.clone()
.filter(|nip05| !nip05.trim().is_empty());
let key = (source.event_id, nip05);
if self
.header_cache
.as_ref()
.is_none_or(|cache| cache.key != key)
{
let announcement = source.clone();
let share = ShareTargets::from_announcement(&announcement);
let nostr_url = nostr_clone_url(&announcement, key.1.as_deref());
self.header_cache = Some(HeaderCache {
ngit_command: SharedString::from(format!("git clone {nostr_url}")),
nak_command: SharedString::from(format!("nak git clone {nostr_url}")),
git_commands: Rc::new(announcement.clone_urls()),
share: Rc::new(share),
announcement: Rc::new(announcement),
key,
});
}
let cache = self.header_cache.as_ref().expect("cache just built");
let announcement = cache.announcement.clone();
let share = cache.share.clone();
let ngit_command = cache.ngit_command.clone();
let nak_command = cache.nak_command.clone();
let git_commands = cache.git_commands.clone();
let name = self.display_name(cx);
let description = announcement.description();
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
let share = ShareTargets::from_announcement(&announcement);
let nostr_url = nostr_clone_url(&announcement, cx);
let ngit_command = SharedString::from(format!("git clone {nostr_url}"));
let nak_command = SharedString::from(format!("nak git clone {nostr_url}"));
let git_commands = announcement.clone_urls();
v_flex()
.on_action(
@@ -1923,9 +1972,7 @@ impl Render for RepoDetailView {
}
/// Read the worktree state of `repo` (no network): entries, README, refs
/// and HEAD commit. The tree is built off the main thread; the seeds are
/// plain owned strings and convert to `TreeItem`s (which hold `Rc` state)
/// on the main thread.
/// and HEAD commit.
fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> {
let entries = signed_git::worktree_entries(repo)?;
let tree = build_tree_items(&entries);
@@ -1961,16 +2008,11 @@ fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> {
/// The `nostr://...` clone URL of an announcement (NIP-34): the owner as a
/// NIP-05 identifier when known (npub otherwise), the first announced relay
/// as a hint, and the repository identifier.
fn nostr_clone_url(announcement: &Announcement, cx: &App) -> SharedString {
/// as a hint, and the repository identifier. `nip05` is the owner's
/// NIP-05 identifier from the profile store, already blank-filtered.
fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString {
let owner = announcement.owner;
let user = ProfileStore::global(cx)
.read(cx)
.get(&owner)
.metadata()
.nip05
.as_deref()
.filter(|nip05| !nip05.trim().is_empty())
let user = nip05
.map(str::to_owned)
.unwrap_or_else(|| owner.to_bech32().unwrap_or_else(|_| owner.to_hex()));
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::rc::Rc;
@@ -38,6 +39,10 @@ use crate::image_cache::{MAX_IMAGES, image_cache};
/// Width of the changed-files column.
const TREE_WIDTH: f32 = 260.;
/// Height of one commit row in the commits tab's virtual list: a single
/// text line plus the 1px bottom border.
const PR_COMMIT_ROW_HEIGHT: f32 = 37.;
/// Detail panel of a single pull request.
pub struct PullRequestDetailView {
focus_handle: FocusHandle,
@@ -77,6 +82,15 @@ pub struct PullRequestDetailView {
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>>>,
/// Virtual list state of the commits tab.
commit_scroll_handle: VirtualListScrollHandle,
/// Comment bodies as shared strings, keyed by comment event ID, so
/// re-renders don't clone full contents again (events are immutable,
/// so the cache never needs invalidation).
contents: HashMap<EventId, SharedString>,
/// In-flight tasks; finished tasks are pruned on every push, so the vec
/// stays bounded by the number of concurrent loads.
tasks: Vec<Task<Result<(), anyhow::Error>>>,
@@ -137,6 +151,9 @@ impl PullRequestDetailView {
rows: Vec::new(),
item_sizes: Rc::new(Vec::new()),
scroll_handle: VirtualListScrollHandle::new(),
commit_item_sizes: Rc::new(Vec::new()),
commit_scroll_handle: VirtualListScrollHandle::new(),
contents: HashMap::new(),
tasks: Vec::new(),
_subscriptions: subscriptions,
}
@@ -146,9 +163,8 @@ impl PullRequestDetailView {
/// 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 they live in the git repository
/// (`c`, `clone` and `merge-base` tags, per NIP-34), so the clone is
/// fetched and the `merge-base..tip` range is diffed.
/// 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>) {
self.loading = true;
self.error = None;
@@ -263,6 +279,8 @@ impl PullRequestDetailView {
this.loading = false;
this.worktree = worktree;
this.current_commit = current_commit.map(SharedString::from);
this.commit_item_sizes =
Rc::new(vec![size(px(0.), px(PR_COMMIT_ROW_HEIGHT)); commits.len()]);
this.commits = commits;
match diff {
Ok(diff) => {
@@ -799,15 +817,32 @@ impl PullRequestDetailView {
}
v_flex()
.relative()
.flex_1()
.w_full()
.min_h_0()
.overflow_y_scrollbar()
.children(
self.commits
.iter()
.enumerate()
.map(|(ix, commit)| self.render_commit_row(ix, commit, cx)),
.child(
v_virtual_list(
cx.entity().clone(),
"pr-commits",
self.commit_item_sizes.clone(),
move |this, range, _window, cx| {
range
.map(|ix| this.render_commit_row(ix, &this.commits[ix], cx))
.collect()
},
)
.track_scroll(&self.commit_scroll_handle)
.size_full(),
)
.child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(Scrollbar::vertical(&self.commit_scroll_handle)),
)
.into_any_element()
}
@@ -826,7 +861,7 @@ impl PullRequestDetailView {
h_flex()
.id(ix)
.px_4()
.py_2()
.h(px(PR_COMMIT_ROW_HEIGHT))
.gap_2()
.items_center()
.text_sm()
@@ -881,6 +916,13 @@ impl PullRequestDetailView {
let author = profile.name();
let picture = profile.picture();
let age = relative_time(comment.created_at);
// Comment bodies are cloned into shared strings once per
// comment, not on every render.
let content = self
.contents
.entry(comment.id)
.or_insert_with(|| SharedString::from(comment.content.clone()))
.clone();
v_flex()
.gap_1()
@@ -915,11 +957,7 @@ impl PullRequestDetailView {
.child(SharedString::from(age)),
),
)
.child(
div()
.text_sm()
.child(SharedString::from(comment.content.clone())),
)
.child(div().text_sm().child(content))
}))
.into_any_element()
}
@@ -17,7 +17,7 @@ use gpui_component::scroll::Scrollbar;
use gpui_component::{
ActiveTheme, Icon, Sizable, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
};
use nostr::prelude::{Event, EventId, Kind};
use nostr::prelude::{EventId, Kind};
use signed_core::{RepoStatus, activity_subject};
use signed_state::{ProfileStore, RepoStore};
use utils::relative_time;
@@ -26,11 +26,8 @@ use super::helpers::{placeholder, status_badge};
use super::pull_request_detail::PullRequestDetailView;
use crate::image_cache::{MAX_IMAGES, image_cache};
/// Height of one pull request row in the virtual list: same layout as an
/// issue row (8px vertical padding (`py_2`) on top and bottom, a 32px title
/// line (`h_8`) and a 24px meta line (`h_6`), plus the 1px bottom border),
/// so the row totals 73px. The status badge (`size_7`, 28px) is shorter
/// than the content.
/// Height of one pull request row in the virtual list; same layout as an
/// issue row.
const PR_ROW_HEIGHT: f32 = 73.;
/// Status filter of the pull request list, chosen via the header's filter
@@ -51,14 +48,14 @@ enum PullRequestFilter {
}
impl PullRequestFilter {
/// Whether `pr` (of `store`) is included by this filter.
fn matches(self, store: &RepoStore, pr: &Event) -> bool {
/// Whether a pull request with `status` is included by this filter.
fn matches(self, status: RepoStatus) -> bool {
match self {
Self::All => true,
Self::Open => store.status_of(pr) == RepoStatus::Open,
Self::Closed => store.status_of(pr) == RepoStatus::Closed,
Self::Draft => store.status_of(pr) == RepoStatus::Draft,
Self::Merged => store.status_of(pr) == RepoStatus::Applied,
Self::Open => status == RepoStatus::Open,
Self::Closed => status == RepoStatus::Closed,
Self::Draft => status == RepoStatus::Draft,
Self::Merged => status == RepoStatus::Applied,
}
}
}
@@ -79,10 +76,16 @@ pub struct PullRequestsView {
/// pull request count); rebuilt on change.
pr_len: usize,
/// Indices into the store's `pull_requests` matching [`Self::filter`]
/// (root PR events only; updates are revisions of the root and are not
/// listed separately), rebuilt every render; the virtual list renders
/// this slice.
/// (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>,
/// Header counts `(total, open, closed, draft, merged)` of the root
/// pull requests only (revisions are not separate PRs), rebuilt with
/// [`Self::visible_prs`].
counts: (usize, usize, usize, usize, usize),
/// Store version and filter the cached rows/counts were built from.
cache_key: Option<(u64, PullRequestFilter)>,
/// Virtual list state of the pull requests list.
scroll_handle: VirtualListScrollHandle,
}
@@ -104,6 +107,8 @@ impl PullRequestsView {
item_sizes: Rc::new(Vec::new()),
pr_len: 0,
visible_prs: Vec::new(),
counts: (0, 0, 0, 0, 0),
cache_key: None,
scroll_handle: VirtualListScrollHandle::new(),
}
}
@@ -206,16 +211,9 @@ impl PullRequestsView {
}
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let store = self.store.read(cx);
let (total, open, closed, draft, merged) = store.pull_requests.iter().fold(
(0usize, 0usize, 0usize, 0usize, 0usize),
|(total, open, closed, draft, merged), pr| match store.status_of(pr) {
RepoStatus::Open => (total + 1, open + 1, closed, draft, merged),
RepoStatus::Closed => (total + 1, open, closed + 1, draft, merged),
RepoStatus::Draft => (total + 1, open, closed, draft + 1, merged),
RepoStatus::Applied => (total + 1, open, closed, draft, merged + 1),
},
);
// Counts of the last list rebuild (`render` rebuilds first when the
// store version or filter changed, so this is never stale).
let (total, open, closed, draft, merged) = self.counts;
h_flex()
.px_4()
@@ -541,19 +539,38 @@ impl Render for PullRequestsView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let filter = self.filter;
// Indices of the root pull requests matching the active filter
// (updates are revisions of the root and are not listed
// separately); the virtual list renders this filtered slice.
self.visible_prs = {
// Rebuild the filtered rows and header counts only when the store
// refreshed or the filter changed; other renders reuse the cache.
let version = self.store.read(cx).version();
if self.cache_key != Some((version, filter)) {
let store = self.store.read(cx);
store
let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
self.visible_prs = store
.pull_requests
.iter()
.enumerate()
.filter(|(_, pr)| pr.kind == Kind::GitPullRequest && filter.matches(store, pr))
.map(|(ix, _)| ix)
.collect()
};
.filter_map(|(ix, pr)| {
// Kind-30620 patches are revisions of a root PR (NIP-34),
// not separate pull requests: count only root events, or
// the header counts inflate with every revision (which
// also default to `Open` in `status_of`).
if pr.kind != Kind::GitPullRequest {
return None;
}
let status = store.status_of(pr);
counts.0 += 1;
match status {
RepoStatus::Open => counts.1 += 1,
RepoStatus::Closed => counts.2 += 1,
RepoStatus::Draft => counts.3 += 1,
RepoStatus::Applied => counts.4 += 1,
}
filter.matches(status).then_some(ix)
})
.collect();
self.counts = counts;
self.cache_key = Some((version, filter));
}
let count = self.visible_prs.len();
+2 -7
View File
@@ -97,9 +97,7 @@ pub struct RepoListView {
/// Number of rows [`Self::item_sizes`] was built for (the filtered repo count).
repo_len: usize,
/// Indices into the store's `announcements` matching [`Self::filter`],
/// in display order; rebuilt when the store changes, the filter is
/// switched, or the search text changes. The virtual list renders this
/// slice.
/// in display order; the virtual list renders this slice.
visible: Vec<usize>,
/// Search box filtering repositories by name.
search: Entity<InputState>,
@@ -153,10 +151,7 @@ impl RepoListView {
}
/// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the current
/// store contents, [`Self::filter`] and the search query. Called when
/// the view is created, when the store changes, when the filter is
/// switched, and on every search keystroke, so the list is ready before
/// the next render.
/// store contents, [`Self::filter`] and the search query.
fn rebuild_rows(&mut self, cx: &mut Context<Self>) {
let filter = self.filter;
let query = self.search.read(cx).value();
+2 -3
View File
@@ -417,9 +417,8 @@ impl SidebarPanel {
)
}
/// Sign-in placeholder shown while logged out: the banner artwork fills the
/// panel behind a scrim that ends in a solid black band, keeping the CTA
/// buttons readable on a clean dark surface in both themes.
/// Sign-in placeholder shown while logged out: banner artwork behind a
/// scrim so the CTA buttons stay readable in both themes.
fn render_sign_in(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
v_flex()
.size_full()
+2 -14
View File
@@ -7,17 +7,13 @@ use gpui_component::{Theme, ThemeRegistry, theme};
use gpui_platform::application;
fn main() {
// Initialize logging
tracing_subscriber::fmt::init();
application()
.with_assets(Assets)
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()))
.run(move |cx| {
// Initialize components
gpui_component::init(cx);
// Initialize theme
theme::init(cx);
// Register the built-in "Signed" theme (light + dark variants)
@@ -49,10 +45,9 @@ fn main() {
tracing::warn!("Signed Dark theme is missing from the registry");
}
// Sync the theme with the system appearance
Theme::sync_system_appearance(None, cx);
// Initialize backend and stores (connects relays, restores session)
// Connects relays and restores the session.
std::fs::create_dir_all(paths::nostr_dir()).ok();
signed_state::init(paths::nostr_dir(), cx);
@@ -60,15 +55,9 @@ fn main() {
std::fs::create_dir_all(paths::repos_dir()).ok();
signed_state::GitStore::set_global(paths::repos_dir().clone(), cx);
// Set app identity
cx.set_app_identity("su.reya.signed", "Signed");
// Set up the window options
let bounds = Bounds::centered(None, size(px(1120.0), px(750.0)), cx);
// The dock's tab bar acts as the window title bar: the app owns
// title-bar dragging (via `start_window_move` on the tab bar), so
// AppKit must not treat the top strip as a native drag region.
let opts = WindowOptions {
window_background: WindowBackgroundAppearance::Opaque,
window_decorations: Some(WindowDecorations::Client),
@@ -78,7 +67,7 @@ fn main() {
app_id: Some("Signed".to_owned()),
titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static("Signed")),
// AppKit's traffic-light buttons are 14 pt tall; offset them so their vertical center matches the tab bar.
// Center the 14pt traffic-light buttons on the tab bar.
traffic_light_position: Some(point(
px(9.0),
px(TAB_BAR_HEIGHT / px(2.) - 14. / 2.),
@@ -94,7 +83,6 @@ fn main() {
})
.detach();
// Bring the app to the foreground
cx.activate(true);
});
}
+12 -2
View File
@@ -2,8 +2,18 @@
## Local repository scan
- [ ] Allow the user to configure which directories are scanned for local git repositories (currently fixed to the Desktop and Documents folders).
- [ ] Make the scanned directories configurable (currently fixed to Desktop and Documents).
## Create repository dialog
- [ ] Persist the user's preferred local repository folder (the one picked in the create-repository dialog, defaulting to Desktop) and use it as the default next time the dialog opens.
- [ ] Remember the folder picked in the create-repository dialog and default to it next time (currently defaults to Desktop).
## 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.