Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98453329a1 | ||
|
|
a5ac986375 |
@@ -1,3 +1,5 @@
|
||||
/target/
|
||||
/.tools/
|
||||
/.worktrees
|
||||
/sdk/node_modules/
|
||||
/sdk/dist/
|
||||
|
||||
Generated
+669
-2
@@ -37,6 +37,21 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aligned-vec"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
|
||||
dependencies = [
|
||||
"equator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "android-activity"
|
||||
version = "0.6.1"
|
||||
@@ -118,6 +133,18 @@ dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -178,6 +205,148 @@ dependencies = [
|
||||
"objc2 0.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boa_ast"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9221b0a090572b4a118b46c5874c0781fe4bc9546b85f0a615b8bfb609ca4ed7"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"boa_interner",
|
||||
"boa_macros",
|
||||
"boa_string",
|
||||
"indexmap",
|
||||
"num-bigint",
|
||||
"rustc-hash 2.1.3",
|
||||
"strum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boa_engine"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d6aaf7fdc4299df68adb67e1a5440a51d34630a1f5271b9698fd74bc97b60d9"
|
||||
dependencies = [
|
||||
"aligned-vec",
|
||||
"arrayvec",
|
||||
"async-channel",
|
||||
"bitflags 2.13.1",
|
||||
"boa_ast",
|
||||
"boa_gc",
|
||||
"boa_interner",
|
||||
"boa_macros",
|
||||
"boa_parser",
|
||||
"boa_string",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"cow-utils",
|
||||
"dashmap",
|
||||
"dynify",
|
||||
"fast-float2",
|
||||
"futures-concurrency",
|
||||
"futures-lite",
|
||||
"hashbrown 0.17.1",
|
||||
"icu_normalizer",
|
||||
"indexmap",
|
||||
"intrusive-collections",
|
||||
"itertools",
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"num_enum",
|
||||
"oneshot",
|
||||
"pastey",
|
||||
"portable-atomic",
|
||||
"rand",
|
||||
"regress",
|
||||
"rustc-hash 2.1.3",
|
||||
"ryu-js",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"small_btree",
|
||||
"static_assertions",
|
||||
"tag_ptr",
|
||||
"tap",
|
||||
"thin-vec",
|
||||
"thiserror 2.0.20",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boa_gc"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af82119558ab1002d1978c8459b53a3ebb506177d29a3aca37c07c1777b1d966"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"boa_macros",
|
||||
"boa_string",
|
||||
"hashbrown 0.17.1",
|
||||
"thin-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boa_interner"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "317c631e791f5cb7d0017e43c0a758b0eaffd40c34c4c360e2a6be7752b78cd1"
|
||||
dependencies = [
|
||||
"boa_gc",
|
||||
"boa_macros",
|
||||
"hashbrown 0.17.1",
|
||||
"indexmap",
|
||||
"once_cell",
|
||||
"phf",
|
||||
"rustc-hash 2.1.3",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boa_macros"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b882fd440dc0f4b7441367cf89567bb077920356750c9a76a16c002d8ec2fa5c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cow-utils",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
"synstructure 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boa_parser"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "641e81b3c8cbb7a2140547fb3ea534e9edf210b4e8c920b5fa864a919e11fbcf"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"boa_ast",
|
||||
"boa_interner",
|
||||
"boa_macros",
|
||||
"fast-float2",
|
||||
"icu_properties",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"regress",
|
||||
"rustc-hash 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boa_string"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30bf86c2bcff5bf2547acbfc516c5d3108ca527cb131511d749bf6fbef762c0e"
|
||||
dependencies = [
|
||||
"fast-float2",
|
||||
"itoa",
|
||||
"pastey",
|
||||
"rustc-hash 2.1.3",
|
||||
"ryu-js",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -306,6 +475,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -413,6 +593,21 @@ dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cow-utils"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.1"
|
||||
@@ -440,6 +635,26 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
@@ -497,6 +712,26 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dynify"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81acb15628a3e22358bf73de5e7e62360b8a777dbcb5fc9ac7dfa9ae73723747"
|
||||
dependencies = [
|
||||
"dynify-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dynify-macros"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ec431cd708430d5029356535259c5d645d60edd3d39c54e5eea9782d46caa7d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.33.3"
|
||||
@@ -618,6 +853,12 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||
|
||||
[[package]]
|
||||
name = "emath"
|
||||
version = "0.33.3"
|
||||
@@ -651,6 +892,26 @@ version = "0.33.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862"
|
||||
|
||||
[[package]]
|
||||
name = "equator"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
|
||||
dependencies = [
|
||||
"equator-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equator-macro"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -673,6 +934,38 @@ version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
||||
dependencies = [
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener-strategy"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fast-float2"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "fax"
|
||||
version = "0.2.7"
|
||||
@@ -705,6 +998,12 @@ version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
@@ -787,12 +1086,44 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-concurrency"
|
||||
version = "7.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"pin-project",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
||||
|
||||
[[package]]
|
||||
name = "futures-lite"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
@@ -842,6 +1173,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -984,6 +1316,12 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -1007,6 +1345,17 @@ name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
@@ -1058,6 +1407,8 @@ dependencies = [
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"utf16_iter",
|
||||
"write16",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -1160,6 +1511,27 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "intrusive-collections"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4275b20e6057cd7733fd8df8a5a31701e4fe44497dad0f3fa0e1c4fb971506be"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
@@ -1505,6 +1877,32 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -1537,6 +1935,15 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc"
|
||||
version = "0.2.7"
|
||||
@@ -1823,6 +2230,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "oneshot"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c"
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.55"
|
||||
@@ -1851,6 +2264,12 @@ dependencies = [
|
||||
"ttf-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -1880,12 +2299,60 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
@@ -2002,6 +2469,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
@@ -2080,6 +2553,23 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -2113,6 +2603,15 @@ dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regress"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32eef8b209c3c1c15dbad02c1f30f9539f00dc7253e0cbcdaae442a50a09d7c1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "renderdoc-sys"
|
||||
version = "1.1.0"
|
||||
@@ -2178,6 +2677,12 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "ryu-js"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -2235,6 +2740,19 @@ dependencies = [
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
@@ -2299,6 +2817,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -2312,6 +2836,7 @@ dependencies = [
|
||||
"ab_glyph",
|
||||
"anyhow",
|
||||
"base64",
|
||||
"boa_engine",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fontdb",
|
||||
@@ -2319,8 +2844,10 @@ dependencies = [
|
||||
"png",
|
||||
"portable-pty",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shell-words",
|
||||
"toml",
|
||||
"ts-rs",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
@@ -2333,6 +2860,15 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "small_btree"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ba60d2df92ba73864714808ca68c059734853e6ab722b40e1cf543ebb3a057a"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.0"
|
||||
@@ -2432,6 +2968,27 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
@@ -2465,6 +3022,44 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tag_ptr"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0e973b34477b7823833469eb0f5a3a60370fef7a453e02d751b59180d0a5a05"
|
||||
|
||||
[[package]]
|
||||
name = "tap"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thin-vec"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79def32ffcd477db1ff26f76dab9e3a91f0bd42a85ca96577089b24623056f9d"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -2519,6 +3114,38 @@ dependencies = [
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"libc",
|
||||
"num-conv",
|
||||
"num_threads",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
@@ -2621,6 +3248,28 @@ version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs"
|
||||
version = "12.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
|
||||
dependencies = [
|
||||
"thiserror 2.0.20",
|
||||
"ts-rs-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs-macros"
|
||||
version = "12.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"termcolor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ttf-parser"
|
||||
version = "0.25.1"
|
||||
@@ -2669,6 +3318,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf16_iter"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -3419,6 +4074,12 @@ version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "write16"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
@@ -3508,7 +4169,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
"synstructure 0.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3549,7 +4210,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
"synstructure 0.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3591,6 +4252,12 @@ version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.3"
|
||||
|
||||
@@ -9,6 +9,7 @@ license = "MIT"
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
ab_glyph = "0.2"
|
||||
boa_engine = { version = "0.22", default-features = false }
|
||||
fontdb = "0.23"
|
||||
eframe = { version = "0.33", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11"] }
|
||||
egui = "0.33"
|
||||
@@ -16,8 +17,10 @@ libghostty-vt = { version = "=0.2.1", features = ["png"] }
|
||||
png = "0.18"
|
||||
portable-pty = "0.9"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
shell-words = "1"
|
||||
toml = "0.9"
|
||||
ts-rs = "12"
|
||||
wgpu = { version = "27", default-features = false, features = ["std", "vulkan", "gles", "metal", "wgsl"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -53,6 +53,8 @@ allow_shared_memory = true
|
||||
|
||||
[multiplexer]
|
||||
max_panes = 32
|
||||
# Floating panes follow the centered cascade and configured size until manually
|
||||
# moved or resized. Automatic panes also follow window and configuration changes.
|
||||
floating_width = 0.65
|
||||
floating_height = 0.65
|
||||
# Fractions of the workspace, capped at these many points on large displays.
|
||||
@@ -78,3 +80,10 @@ floating_max_height = 640
|
||||
"Ctrl+Shift+R" = "reload_config"
|
||||
"Ctrl+Shift+C" = "copy"
|
||||
"Ctrl+Shift+V" = "paste"
|
||||
|
||||
# TypeScript plugins compile to JavaScript bundles and run in embedded Boa.
|
||||
# See sdk/README.md. Relative paths resolve beside this configuration file.
|
||||
# Restart Slerm after changing a plugin bundle or these entries.
|
||||
# [[plugins]]
|
||||
# id = "workspace"
|
||||
# path = "plugins/workspace.js"
|
||||
|
||||
@@ -177,6 +177,9 @@
|
||||
./Cargo.lock
|
||||
./config.example.toml
|
||||
./src
|
||||
# Embedded runtime and the bindings checked by Rust tests.
|
||||
./sdk/runtime.js
|
||||
./sdk/src/generated.ts
|
||||
];
|
||||
};
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
# Slerm TypeScript plugin SDK
|
||||
|
||||
Slerm runs JavaScript bundles in **embedded Boa**, with a separate worker thread
|
||||
and context for each plugin. End users do not need Node.js or Bun. Plugin authors
|
||||
use TypeScript and a bundler during development.
|
||||
|
||||
## Try the example
|
||||
|
||||
From the Slerm repository root:
|
||||
|
||||
```sh
|
||||
npm ci --prefix sdk
|
||||
npm run example --prefix sdk
|
||||
```
|
||||
|
||||
Add this to your Slerm configuration, replacing the absolute path:
|
||||
|
||||
```toml
|
||||
[[plugins]]
|
||||
id = "workspace"
|
||||
path = "/absolute/path/to/slerm/sdk/dist/workspace.js"
|
||||
```
|
||||
|
||||
Start Slerm. The example opens **Workspace tools**, with controls to create a
|
||||
floating pane, run a shell command, and inspect terminal text. Reopen the panel
|
||||
from **Plugins → workspace → Show workspace tools**.
|
||||
|
||||
Bundle paths can also be relative to the configuration file's directory. Plugins
|
||||
load at startup; restart Slerm after changing a bundle or the plugin configuration.
|
||||
|
||||
## Write a plugin
|
||||
|
||||
Build the SDK with `npm run build --prefix sdk`. In a separate plugin project,
|
||||
install the local package with `npm install /absolute/path/to/slerm/sdk` and add
|
||||
TypeScript and esbuild as development dependencies. The package has not been
|
||||
published to a registry.
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "@slerm/plugin-sdk";
|
||||
|
||||
definePlugin({
|
||||
async activate(slerm) {
|
||||
await slerm.registerCommand({
|
||||
command: { id: "scratch", title: "New scratch pane" },
|
||||
});
|
||||
},
|
||||
async onEvent(event, slerm) {
|
||||
if (event.event === "command" && event.command === "scratch") {
|
||||
await slerm.execute({ action: "new_floating" });
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Type-check your entry point, then bundle it:
|
||||
|
||||
```sh
|
||||
npx tsc --noEmit --strict --target es2022 --module nodenext plugin.ts
|
||||
npx esbuild plugin.ts --bundle --format=iife --platform=neutral --target=es2022 --outfile=plugin.js
|
||||
```
|
||||
|
||||
Point a `[[plugins]]` entry at `plugin.js`. Each bundle calls `definePlugin` once.
|
||||
All API calls return promises that resolve after Slerm applies the operation.
|
||||
Events invoke `onEvent(event, slerm)`; callbacks can be async.
|
||||
|
||||
## Generated API
|
||||
|
||||
The Rust definitions in [`src/plugin_api.rs`](../src/plugin_api.rs) are the source
|
||||
of truth. A Rust method list declares parameters and results; `ts-rs` generates
|
||||
`src/generated.ts`, and TypeScript derives the client method signatures from those
|
||||
types. Actions come from Slerm's existing Rust action enum. State, UI elements,
|
||||
and events also use the same Rust types for serialization and type generation.
|
||||
The generic runtime bridge does not duplicate a method list or handwritten types.
|
||||
|
||||
Regenerate after changing the Rust API:
|
||||
|
||||
```sh
|
||||
nix develop -c cargo run -- --generate-plugin-sdk
|
||||
npm test --prefix sdk
|
||||
nix develop -c cargo test
|
||||
nix develop -c cargo test bundled_typescript_example_runs_in_boa -- --ignored
|
||||
```
|
||||
|
||||
Without Nix, run Cargo in an environment containing the project's Rust and Zig
|
||||
build dependencies. Commit `sdk/src/generated.ts` with API changes. A Rust test
|
||||
fails if the checked-in bindings differ from the schema. Bump `API_VERSION` for
|
||||
incompatible changes; Slerm rejects bundles built for another API version.
|
||||
|
||||
| Method | Purpose |
|
||||
| --- | --- |
|
||||
| `getState()` | Read tabs, panes, titles, focus, floating state, and zoom |
|
||||
| `execute({ action })` | Execute a built-in pane, tab, clipboard, or settings action |
|
||||
| `focusPane({ pane })`, `focusTab({ tab })` | Focus by ID from `getState()` |
|
||||
| `readTerminal({ pane })` | Read a plain-text screen and scrollback snapshot, up to 128 KiB |
|
||||
| `writeTerminal({ pane, text })` | Send raw PTY input; include `\r` to submit a shell command |
|
||||
| `registerCommand({ command })`, `unregisterCommand({ command })` | Manage entries in the Plugins menu |
|
||||
| `setPanel({ panel })`, `removePanel({ panel })` | Create, replace, or remove a native panel |
|
||||
| `reportError({ message })` | Display an error in Slerm |
|
||||
|
||||
Command and panel IDs are scoped to the plugin. Control IDs must be unique within
|
||||
a panel. Pane and tab IDs come from Slerm and should be treated as opaque numbers.
|
||||
|
||||
Events are discriminated by `event.event`:
|
||||
|
||||
- `command`: `event.command` is the selected command ID.
|
||||
- `state`: `event.state` is a snapshot when workspace metadata changes. Terminal
|
||||
output changes alone do not produce this event; use `readTerminal` for snapshots.
|
||||
- `ui`: `event.data` contains the panel/control IDs and either a `click`, or a
|
||||
`change` with the new input `value`.
|
||||
|
||||
## Native UI
|
||||
|
||||
`setPanel` renders an egui floating window. Its `elements` accept `text`, `button`,
|
||||
`input`, and `separator` variants. Inputs retain edits until replaced by another
|
||||
`setPanel` call. UI focus receives typing without forwarding it to the terminal.
|
||||
See [`examples/workspace.ts`](examples/workspace.ts) for a complete example.
|
||||
|
||||
## Runtime boundaries
|
||||
|
||||
The runtime supports ECMAScript and promises. This initial host does not expose
|
||||
Node.js APIs, a DOM, filesystem/network APIs, or timers. Bundle imports ahead of
|
||||
time and use libraries compatible with this environment. Report diagnostics with
|
||||
`reportError`; `console` is not installed.
|
||||
|
||||
Plugins are trusted local extensions: terminal writes can run shell commands.
|
||||
Boa contexts run in the same OS process as Slerm; worker threads and execution
|
||||
limits are not an OS security sandbox. Slerm bounds loops, recursion, promise jobs,
|
||||
request waits, bundle/message sizes, and UI counts. An uncaught callback error
|
||||
stops that plugin and removes its commands and panels. Host-operation errors
|
||||
reject the API promise and can be handled by the plugin.
|
||||
|
||||
Implementation references: [Boa](https://boajs.dev/docs/intro) and
|
||||
[ts-rs](https://docs.rs/ts-rs/latest/ts_rs/).
|
||||
@@ -0,0 +1,40 @@
|
||||
import { definePlugin, type Slerm } from "../src/index.js";
|
||||
|
||||
let command = "pwd";
|
||||
const show = (slerm: Slerm) => slerm.setPanel({ panel: {
|
||||
id: "workspace", title: "Workspace tools", elements: [
|
||||
{ type: "text", text: "Create a scratch terminal or run a command in the active pane." },
|
||||
{ type: "button", id: "scratch", label: "New floating pane" },
|
||||
{ type: "separator" },
|
||||
{ type: "input", id: "command", label: "Shell command", value: command },
|
||||
{ type: "button", id: "run", label: "Run in active pane" },
|
||||
{ type: "button", id: "read", label: "Show terminal text" },
|
||||
{ type: "button", id: "close", label: "Close tools" },
|
||||
],
|
||||
} });
|
||||
|
||||
definePlugin({
|
||||
async activate(slerm) {
|
||||
await slerm.registerCommand({ command: { id: "show-tools", title: "Show workspace tools" } });
|
||||
await show(slerm);
|
||||
},
|
||||
async onEvent(event, slerm) {
|
||||
if (event.event === "command" && event.command === "show-tools") await show(slerm);
|
||||
if (event.event !== "ui" || event.data.panel !== "workspace") return;
|
||||
const control = event.data;
|
||||
if (control.kind === "change" && control.control === "command") command = control.value;
|
||||
if (control.kind !== "click") return;
|
||||
if (control.control === "scratch") await slerm.execute({ action: "new_floating" });
|
||||
if (control.control === "close") await slerm.removePanel({ panel: "workspace" });
|
||||
if (control.control === "run" || control.control === "read") {
|
||||
const state = await slerm.getState();
|
||||
const pane = state.tabs.find(tab => tab.id === state.activeTab)?.activePane;
|
||||
if (pane === undefined) return;
|
||||
if (control.control === "run") await slerm.writeTerminal({ pane, text: command + "\r" });
|
||||
else await slerm.setPanel({ panel: {
|
||||
id: "output", title: "Terminal snapshot",
|
||||
elements: [{ type: "text", text: await slerm.readTerminal({ pane }) }],
|
||||
} });
|
||||
}
|
||||
},
|
||||
});
|
||||
Generated
+515
@@ -0,0 +1,515 @@
|
||||
{
|
||||
"name": "@slerm/plugin-sdk",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@slerm/plugin-sdk",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.7",
|
||||
"@esbuild/android-arm": "0.27.7",
|
||||
"@esbuild/android-arm64": "0.27.7",
|
||||
"@esbuild/android-x64": "0.27.7",
|
||||
"@esbuild/darwin-arm64": "0.27.7",
|
||||
"@esbuild/darwin-x64": "0.27.7",
|
||||
"@esbuild/freebsd-arm64": "0.27.7",
|
||||
"@esbuild/freebsd-x64": "0.27.7",
|
||||
"@esbuild/linux-arm": "0.27.7",
|
||||
"@esbuild/linux-arm64": "0.27.7",
|
||||
"@esbuild/linux-ia32": "0.27.7",
|
||||
"@esbuild/linux-loong64": "0.27.7",
|
||||
"@esbuild/linux-mips64el": "0.27.7",
|
||||
"@esbuild/linux-ppc64": "0.27.7",
|
||||
"@esbuild/linux-riscv64": "0.27.7",
|
||||
"@esbuild/linux-s390x": "0.27.7",
|
||||
"@esbuild/linux-x64": "0.27.7",
|
||||
"@esbuild/netbsd-arm64": "0.27.7",
|
||||
"@esbuild/netbsd-x64": "0.27.7",
|
||||
"@esbuild/openbsd-arm64": "0.27.7",
|
||||
"@esbuild/openbsd-x64": "0.27.7",
|
||||
"@esbuild/openharmony-arm64": "0.27.7",
|
||||
"@esbuild/sunos-x64": "0.27.7",
|
||||
"@esbuild/win32-arm64": "0.27.7",
|
||||
"@esbuild/win32-ia32": "0.27.7",
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@slerm/plugin-sdk",
|
||||
"version": "0.1.0",
|
||||
"description": "Generated TypeScript API for Slerm's embedded Boa plugins",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"generate": "cargo run --manifest-path ../Cargo.toml -- --generate-plugin-sdk",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "npm run build && tsc -p tsconfig.examples.json",
|
||||
"example": "npm run check && esbuild examples/workspace.ts --bundle --format=iife --platform=neutral --target=es2022 --outfile=dist/workspace.js",
|
||||
"test": "npm run example && node --test test/*.test.mjs",
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Generic Boa bridge. API methods and their types come from the Rust schema.
|
||||
(() => {
|
||||
const call = __slerm_call;
|
||||
const fail = __slerm_error;
|
||||
let plugin;
|
||||
const api = new Proxy(Object.create(null), {
|
||||
get: (_, method) => method === "then" ? undefined : async (params = {}) => {
|
||||
const reply = JSON.parse(call(JSON.stringify({ ...params, method })));
|
||||
if (reply.error !== undefined) throw new Error(reply.error);
|
||||
return reply.result;
|
||||
},
|
||||
});
|
||||
const invoke = (callback, ...args) => {
|
||||
if (callback) Promise.resolve().then(() => callback(...args)).catch(error => fail(String(error)));
|
||||
};
|
||||
Object.defineProperty(globalThis, "__slerm", { value: Object.freeze({
|
||||
activate(definition, version) {
|
||||
if (version !== __slerm_version) throw new Error(`Unsupported Slerm API version ${version}`);
|
||||
if (plugin) throw new Error("A bundle must define exactly one plugin");
|
||||
plugin = definition;
|
||||
invoke(plugin.activate, api);
|
||||
},
|
||||
}) });
|
||||
Object.defineProperty(globalThis, "__slerm_dispatch", { value: event => {
|
||||
if (!plugin) throw new Error("Bundle did not call definePlugin");
|
||||
invoke(plugin.onEvent, event, api);
|
||||
} });
|
||||
})();
|
||||
@@ -0,0 +1,30 @@
|
||||
// Generated by cargo run -- --generate-plugin-sdk. Do not edit.
|
||||
|
||||
export const apiVersion = 1 as const;
|
||||
|
||||
export type Action = "split_right" | "split_down" | "new_floating" | "new_pane" | "toggle_floating" | "toggle_floats" | "next_pane" | "previous_pane" | "focus_left" | "focus_down" | "focus_up" | "focus_right" | "close_pane" | "zoom_pane" | "new_tab" | "next_tab" | "previous_tab" | "reload_config" | "copy" | "paste" | "open_scrollback";
|
||||
|
||||
export type Operation = { "method": "getState", } | { "method": "execute", action: Action, } | { "method": "focusPane", pane: number, } | { "method": "focusTab", tab: number, } | { "method": "readTerminal", pane: number, } | { "method": "writeTerminal", pane: number, text: string, } | { "method": "registerCommand", command: PluginCommand, } | { "method": "unregisterCommand", command: string, } | { "method": "setPanel", panel: Panel, } | { "method": "removePanel", panel: string, } | { "method": "reportError", message: string, };
|
||||
|
||||
export type Results = { getState: State, execute: null, focusPane: null, focusTab: null, readTerminal: string, writeTerminal: null, registerCommand: null, unregisterCommand: null, setPanel: null, removePanel: null, reportError: null, };
|
||||
|
||||
export type State = { activeTab: number | null, tabs: Array<TabState>, };
|
||||
|
||||
export type TabState = { id: number, activePane: number, floatsVisible: boolean, zoomed: boolean, panes: Array<PaneState>, };
|
||||
|
||||
export type PaneState = { id: number, title: string, floating: boolean, exited: boolean, };
|
||||
|
||||
export type PluginCommand = { id: string, title: string, };
|
||||
|
||||
export type Panel = { id: string, title: string, elements: Array<Element>, };
|
||||
|
||||
export type Element = { "type": "text", text: string, } | { "type": "button", id: string, label: string, } | { "type": "input", id: string, label: string, value: string, } | { "type": "separator" };
|
||||
|
||||
export type UIEvent = { "kind": "click", panel: string, control: string, } | { "kind": "change", panel: string, control: string, value: string, };
|
||||
|
||||
export type Event = { "event": "command", command: string, } | { "event": "state", state: State, } | { "event": "ui", data: UIEvent, };
|
||||
|
||||
type Params<M extends Operation["method"]> = Omit<Extract<Operation, { method: M }>, "method">;
|
||||
export type Slerm = {
|
||||
[M in Operation["method"]]: (...args: keyof Params<M> extends never ? [] : [params: Params<M>]) => Promise<Results[M] extends null ? void : Results[M]>;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { apiVersion } from "./generated.js";
|
||||
import type { Event, Slerm } from "./generated.js";
|
||||
export * from "./generated.js";
|
||||
|
||||
export interface Plugin {
|
||||
activate?(slerm: Slerm): void | Promise<void>;
|
||||
onEvent?(event: Event, slerm: Slerm): void | Promise<void>;
|
||||
}
|
||||
|
||||
declare const __slerm: { activate(plugin: Plugin, version: number): void };
|
||||
|
||||
/** Register one plugin in its embedded Boa runtime. Bundle this entry point. */
|
||||
export function definePlugin(plugin: Plugin): void {
|
||||
__slerm.activate(plugin, apiVersion);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { runInNewContext, runInContext } from "node:vm";
|
||||
import test from "node:test";
|
||||
|
||||
const bridge = readFileSync(new URL("../runtime.js", import.meta.url), "utf8");
|
||||
const example = readFileSync(new URL("../dist/workspace.js", import.meta.url), "utf8");
|
||||
const settle = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
function start(reply = () => ({ result: null })) {
|
||||
const operations = [];
|
||||
const errors = [];
|
||||
const context = {
|
||||
__slerm_version: 1,
|
||||
__slerm_call: text => {
|
||||
const operation = JSON.parse(text);
|
||||
operations.push(operation);
|
||||
return JSON.stringify(reply(operation));
|
||||
},
|
||||
__slerm_error: error => errors.push(error),
|
||||
};
|
||||
runInNewContext(bridge, context);
|
||||
return { context, operations, errors };
|
||||
}
|
||||
|
||||
test("bundled TypeScript example uses generated methods and handles UI and command events", async () => {
|
||||
const host = start(operation => ({ result: operation.method === "getState"
|
||||
? { activeTab: 1, tabs: [{ id: 1, activePane: 2 }] }
|
||||
: operation.method === "readTerminal" ? "hello from terminal" : null }));
|
||||
runInContext(example, host.context);
|
||||
await settle();
|
||||
assert.deepEqual(host.operations.map(op => op.method), ["registerCommand", "setPanel"]);
|
||||
assert.equal(host.operations[1].panel.elements[3].type, "input");
|
||||
const ui = data => host.context.__slerm_dispatch({ event: "ui", data: { panel: "workspace", ...data } });
|
||||
ui({ kind: "change", control: "command", value: "echo hello" });
|
||||
await settle();
|
||||
ui({ kind: "click", control: "run" });
|
||||
await settle();
|
||||
assert.deepEqual(host.operations.at(-1), { method: "writeTerminal", pane: 2, text: "echo hello\r" });
|
||||
ui({ kind: "click", control: "scratch" });
|
||||
await settle();
|
||||
assert.deepEqual(host.operations.at(-1), { method: "execute", action: "new_floating" });
|
||||
ui({ kind: "click", control: "read" });
|
||||
await settle();
|
||||
assert.equal(host.operations.at(-1).panel.elements[0].text, "hello from terminal");
|
||||
ui({ kind: "click", control: "close" });
|
||||
await settle();
|
||||
assert.equal(host.operations.at(-1).method, "removePanel");
|
||||
host.context.__slerm_dispatch({ event: "command", command: "show-tools" });
|
||||
await settle();
|
||||
assert.equal(host.operations.at(-1).method, "setPanel");
|
||||
assert.deepEqual(host.errors, []);
|
||||
});
|
||||
|
||||
test("API mismatch and duplicate registration fail before activation", () => {
|
||||
const { context } = start();
|
||||
assert.throws(() => context.__slerm.activate({}, 2), /Unsupported Slerm API/);
|
||||
context.__slerm.activate({}, 1);
|
||||
assert.throws(() => context.__slerm.activate({}, 1), /exactly one plugin/);
|
||||
});
|
||||
|
||||
test("host errors reject generated calls and surface callback failures", async () => {
|
||||
const host = start(() => ({ error: "unknown pane" }));
|
||||
runInContext(example, host.context);
|
||||
await settle();
|
||||
assert.deepEqual(host.errors, ["Error: unknown pane"]);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Slerm, State } from "../src/index.js";
|
||||
|
||||
declare const api: Slerm;
|
||||
const state: Promise<State> = api.getState();
|
||||
const text: Promise<string> = api.readTerminal({ pane: 1 });
|
||||
void state; void text;
|
||||
// @ts-expect-error Generated methods reject unknown actions.
|
||||
api.execute({ action: "missing" });
|
||||
// @ts-expect-error Pane IDs are numbers in the JSON API.
|
||||
api.focusPane({ pane: "1" });
|
||||
// @ts-expect-error A terminal write requires text.
|
||||
api.writeTerminal({ pane: 1 });
|
||||
// @ts-expect-error Parameterless methods take no parameters.
|
||||
api.getState({});
|
||||
// @ts-expect-error Panel elements are generated from the Rust enum.
|
||||
api.setPanel({ panel: { id: "test", title: "Test", elements: [{ type: "html", html: "" }] } });
|
||||
// @ts-expect-error Return types come from the Rust method list.
|
||||
const invalid: Promise<number> = api.readTerminal({ pane: 1 });
|
||||
void invalid;
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": { "noEmit": true, "rootDir": "." },
|
||||
"include": ["examples/**/*.ts", "test/types.ts"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"types": [],
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
+551
-27
@@ -3,6 +3,8 @@ use crate::{
|
||||
input,
|
||||
layout::{Axis, Direction, Layout, PaneId, Tab},
|
||||
pane::{self, Pane},
|
||||
plugin_api::{Event, Operation, PaneState, State, TabState},
|
||||
plugins::{self, Host},
|
||||
theme::{self, Theme},
|
||||
};
|
||||
use anyhow::{Result, ensure};
|
||||
@@ -22,6 +24,7 @@ pub struct App {
|
||||
next_id: u64,
|
||||
error: Option<String>,
|
||||
suppressed: HashSet<egui::Key>,
|
||||
plugins: Host,
|
||||
}
|
||||
|
||||
/// Width kept free at the left of the tab bar for the macOS traffic lights,
|
||||
@@ -69,6 +72,11 @@ impl App {
|
||||
cc.egui_ctx.set_visuals(theme.visuals());
|
||||
cc.egui_ctx.set_fonts(fonts(&config)?);
|
||||
let pane = Pane::new(&config, &theme, &cc.egui_ctx, None)?;
|
||||
let config_dir = config_path
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.unwrap_or(std::path::Path::new("."));
|
||||
let (plugins, errors) = Host::start(&config.plugins, config_dir, &cc.egui_ctx);
|
||||
Ok(Self {
|
||||
config,
|
||||
theme,
|
||||
@@ -77,8 +85,9 @@ impl App {
|
||||
tabs: vec![Tab::new(1, 1)],
|
||||
current: 0,
|
||||
next_id: 2,
|
||||
error: None,
|
||||
error: (!errors.is_empty()).then(|| errors.join("\n")),
|
||||
suppressed: HashSet::new(),
|
||||
plugins,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -292,6 +301,175 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_state(&self) -> State {
|
||||
State {
|
||||
active_tab: self.tabs.get(self.current).map(|tab| tab.id),
|
||||
tabs: self
|
||||
.tabs
|
||||
.iter()
|
||||
.map(|tab| TabState {
|
||||
id: tab.id,
|
||||
active_pane: tab.active,
|
||||
floats_visible: tab.floats_visible,
|
||||
zoomed: tab.zoomed,
|
||||
panes: tab
|
||||
.ids()
|
||||
.iter()
|
||||
.map(|id| PaneState {
|
||||
id: *id,
|
||||
floating: tab.floats.contains(id),
|
||||
title: self.panes[id].terminal.title().unwrap_or("").into(),
|
||||
exited: self.panes[id].dead,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_request(
|
||||
&mut self,
|
||||
index: usize,
|
||||
operation: Operation,
|
||||
ctx: &egui::Context,
|
||||
) -> Result<serde_json::Value> {
|
||||
use serde_json::json;
|
||||
match operation {
|
||||
Operation::GetState {} => return Ok(serde_json::to_value(self.plugin_state())?),
|
||||
Operation::Execute { action } => {
|
||||
ensure!(!self.tabs.is_empty(), "no active tab");
|
||||
self.action(action, ctx)?;
|
||||
}
|
||||
Operation::FocusPane { pane } => {
|
||||
let index = self
|
||||
.tabs
|
||||
.iter()
|
||||
.position(|tab| tab.ids().contains(&pane))
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown pane {pane}"))?;
|
||||
self.current = index;
|
||||
let tab = &mut self.tabs[index];
|
||||
tab.active = pane;
|
||||
tab.floats_visible = tab.floats.contains(&pane);
|
||||
}
|
||||
Operation::FocusTab { tab } => {
|
||||
self.current = self
|
||||
.tabs
|
||||
.iter()
|
||||
.position(|candidate| candidate.id == tab)
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown tab {tab}"))?;
|
||||
}
|
||||
Operation::ReadTerminal { pane } => {
|
||||
use libghostty_vt::fmt::{Formatter, FormatterOptions};
|
||||
let pane = self
|
||||
.panes
|
||||
.get(&pane)
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown pane {pane}"))?;
|
||||
let bytes = Formatter::new(
|
||||
&pane.terminal,
|
||||
FormatterOptions::new().with_trim(true).with_unwrap(true),
|
||||
)?
|
||||
.format_alloc(None)?;
|
||||
ensure!(
|
||||
bytes.len() <= 128 * 1024,
|
||||
"terminal snapshot exceeds 128 KiB"
|
||||
);
|
||||
return Ok(json!(String::from_utf8_lossy(&bytes)));
|
||||
}
|
||||
Operation::WriteTerminal { pane, text } => {
|
||||
self.panes
|
||||
.get_mut(&pane)
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown pane {pane}"))?
|
||||
.send(text.into_bytes())?;
|
||||
}
|
||||
Operation::RegisterCommand { command } => {
|
||||
ensure!(plugins::valid_id(&command.id), "invalid command id");
|
||||
ensure!(
|
||||
!command.title.trim().is_empty() && command.title.len() <= 200,
|
||||
"invalid command title"
|
||||
);
|
||||
let commands = &mut self.plugins.runtimes[index].commands;
|
||||
ensure!(
|
||||
commands.contains_key(&command.id) || commands.len() < 64,
|
||||
"command limit reached"
|
||||
);
|
||||
commands.insert(command.id.clone(), command);
|
||||
}
|
||||
Operation::UnregisterCommand { command } => {
|
||||
self.plugins.runtimes[index].commands.remove(&command);
|
||||
}
|
||||
Operation::SetPanel { panel } => {
|
||||
panel.validate()?;
|
||||
let panels = &mut self.plugins.runtimes[index].panels;
|
||||
ensure!(
|
||||
panels.contains_key(&panel.id) || panels.len() < 8,
|
||||
"panel limit reached"
|
||||
);
|
||||
panels.insert(panel.id.clone(), panel);
|
||||
}
|
||||
Operation::RemovePanel { panel } => {
|
||||
self.plugins.runtimes[index].panels.remove(&panel);
|
||||
}
|
||||
Operation::ReportError { message } => {
|
||||
self.error = Some(format!(
|
||||
"Plugin {}: {message}",
|
||||
self.plugins.runtimes[index].id
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
fn poll_plugins(&mut self, ctx: &egui::Context) {
|
||||
use serde_json::json;
|
||||
if self.plugins.runtimes.is_empty() {
|
||||
return;
|
||||
}
|
||||
for index in 0..self.plugins.runtimes.len() {
|
||||
// Bound work per frame even when a plugin continuously sends requests.
|
||||
for count in 0..32 {
|
||||
let Some(request) = self.plugins.runtimes[index].poll() else {
|
||||
break;
|
||||
};
|
||||
let response = match request {
|
||||
Ok(request) => {
|
||||
let response = match self.plugin_request(index, request.operation, ctx) {
|
||||
Ok(result) => json!({"result": result}),
|
||||
Err(error) => json!({"error": error.to_string()}),
|
||||
};
|
||||
request
|
||||
.reply
|
||||
.try_send(response)
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
||||
}
|
||||
Err(error) => Err(anyhow::anyhow!(error)),
|
||||
};
|
||||
if let Err(e) = response {
|
||||
let plugin = &mut self.plugins.runtimes[index];
|
||||
self.error = Some(format!("Plugin {}: {e}", plugin.id));
|
||||
plugin.stop();
|
||||
break;
|
||||
}
|
||||
if count == 31 {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
let state = self.plugin_state();
|
||||
let serialized = serde_json::to_value(&state).unwrap();
|
||||
for plugin in &mut self.plugins.runtimes {
|
||||
if plugin.is_running() && plugin.last_state.as_ref() != Some(&serialized) {
|
||||
if let Err(e) = plugin.send(Event::State {
|
||||
state: state.clone(),
|
||||
}) {
|
||||
self.error = Some(format!("Plugin {}: {e}", plugin.id));
|
||||
plugin.stop();
|
||||
} else {
|
||||
plugin.last_state = Some(serialized.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn keyboard(&mut self, ctx: &egui::Context) -> Result<()> {
|
||||
let events = ctx.input(|i| i.events.clone());
|
||||
let modifiers = ctx.input(|i| i.modifiers);
|
||||
@@ -388,15 +566,24 @@ impl eframe::App for App {
|
||||
for pane in self.panes.values_mut() {
|
||||
pane.drain(ctx);
|
||||
}
|
||||
self.poll_plugins(ctx);
|
||||
if self.tabs.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Native plugin text fields must receive typing without forwarding it to the PTY.
|
||||
let editing_ui = ctx.memory(|m| m.focused().is_some_and(|id| id != egui::Id::new("keys")));
|
||||
if !editing_ui {
|
||||
grab_keys(ctx);
|
||||
if let Err(e) = self.keyboard(ctx) {
|
||||
self.error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
if self.tabs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut action = None;
|
||||
let mut picked = None;
|
||||
let mut plugin_command = None;
|
||||
egui::TopBottomPanel::top("tabs").show(ctx, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if cfg!(target_os = "macos") {
|
||||
@@ -440,6 +627,20 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
});
|
||||
if self.plugins.runtimes.iter().any(|p| !p.commands.is_empty()) {
|
||||
ui.menu_button("Plugins", |ui| {
|
||||
for (index, plugin) in self.plugins.runtimes.iter().enumerate() {
|
||||
ui.menu_button(&plugin.id, |ui| {
|
||||
for command in plugin.commands.values() {
|
||||
if ui.button(&command.title).clicked() {
|
||||
plugin_command = Some((index, command.id.clone()));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.menu_button("Settings", |ui| {
|
||||
if ui.button("Reload config").clicked() {
|
||||
action = Some(Action::ReloadConfig);
|
||||
@@ -526,21 +727,31 @@ impl eframe::App for App {
|
||||
tab.active = id;
|
||||
}
|
||||
});
|
||||
if let Some(result) = result
|
||||
&& result.response.contains_pointer()
|
||||
&& ctx.input(|i| i.pointer.any_pressed())
|
||||
if let Some(result) = result {
|
||||
remember_float_interaction(ctx, window_id, &result.response);
|
||||
if result.response.contains_pointer() && ctx.input(|i| i.pointer.any_pressed())
|
||||
{
|
||||
tab.active = id;
|
||||
}
|
||||
}
|
||||
if active {
|
||||
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, window_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
let active = tab.active;
|
||||
let active_visible = tab.is_visible(active);
|
||||
for error in self.plugins.show_panels(ctx) {
|
||||
self.error = Some(error);
|
||||
}
|
||||
if let Some((index, command)) = plugin_command
|
||||
&& let Err(e) = self.plugins.invoke(index, &command)
|
||||
{
|
||||
self.error = Some(e.to_string());
|
||||
}
|
||||
for (id, pane) in &mut self.panes {
|
||||
if let Err(e) =
|
||||
pane.focus(*id == active && tab.is_visible(*id) && ctx.input(|i| i.focused))
|
||||
if let Err(e) = pane
|
||||
.focus(*id == active && active_visible && !editing_ui && ctx.input(|i| i.focused))
|
||||
{
|
||||
pane.error = Some(e.to_string());
|
||||
}
|
||||
@@ -675,7 +886,9 @@ fn float_placement(
|
||||
// The fractions are of the workspace, so on a large display they hand a scratch
|
||||
// terminal far more room than it needs; the maximums keep it in hand.
|
||||
let size = (workspace.size() * vec2(config.floating_width, config.floating_height))
|
||||
.min(vec2(config.floating_max_width, config.floating_max_height));
|
||||
.min(vec2(config.floating_max_width, config.floating_max_height))
|
||||
.max(vec2(160.0, 100.0))
|
||||
.min(workspace.size());
|
||||
(workspace.center() - size / 2.0 + float_offset(index), size)
|
||||
}
|
||||
|
||||
@@ -685,8 +898,43 @@ fn float_offset(index: usize) -> egui::Vec2 {
|
||||
vec2(step + stack, step)
|
||||
}
|
||||
|
||||
/// Reapply the cascade when a pane changes slots or the workspace resizes.
|
||||
/// Otherwise leave egui's remembered position alone so panes can still be dragged.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct FloatPlacement {
|
||||
configured_size: Option<egui::Vec2>,
|
||||
manual: bool,
|
||||
}
|
||||
|
||||
/// Opt out of automatic placement independently of the input that moved the pane.
|
||||
/// Future movement commands should call this before changing its geometry.
|
||||
fn mark_float_manual(ctx: &egui::Context, window_id: egui::Id) {
|
||||
ctx.data_mut(|data| {
|
||||
data.get_temp_mut_or_default::<FloatPlacement>(window_id.with("placement"))
|
||||
.manual = true;
|
||||
});
|
||||
}
|
||||
|
||||
fn remember_float_interaction(ctx: &egui::Context, window_id: egui::Id, response: &egui::Response) {
|
||||
let placement = ctx.data_mut(|data| {
|
||||
data.get_temp::<FloatPlacement>(window_id.with("placement"))
|
||||
.unwrap_or_default()
|
||||
});
|
||||
// Edge resizing has its own response, so the area's dragged flag only covers
|
||||
// movement. Compare sizes during a drag to also preserve a manual resize.
|
||||
let resized = placement.configured_size.is_some_and(|size| {
|
||||
(response.rect.size() - size).abs().max_elem() > 1.0
|
||||
&& ctx.input(|i| i.pointer.any_down() && i.pointer.is_decidedly_dragging())
|
||||
});
|
||||
// egui can report a drag as soon as the pointer is pressed, before it moves.
|
||||
let moved = response.dragged()
|
||||
&& response
|
||||
.total_drag_delta()
|
||||
.is_some_and(|delta| delta != egui::Vec2::ZERO);
|
||||
if moved || resized {
|
||||
mark_float_manual(ctx, window_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Follow configured geometry until the pane is explicitly moved or resized.
|
||||
fn position_float<'a>(
|
||||
ctx: &egui::Context,
|
||||
window: egui::Window<'a>,
|
||||
@@ -696,24 +944,26 @@ fn position_float<'a>(
|
||||
config: &config::Multiplexer,
|
||||
) -> egui::Window<'a> {
|
||||
let (pos, size) = float_placement(workspace, index, config);
|
||||
let window = window.default_pos(pos).default_size(size);
|
||||
let changed = ctx.data_mut(|data| {
|
||||
let key = window_id.with("cascade placement");
|
||||
let placement = (index, workspace);
|
||||
let previous = data.get_temp::<(usize, Rect)>(key);
|
||||
data.insert_temp(key, placement);
|
||||
previous.is_some_and(|previous| previous != placement)
|
||||
let mut window = window
|
||||
.default_pos(pos)
|
||||
.default_size(size)
|
||||
.min_size(vec2(160.0, 100.0).min(workspace.size()));
|
||||
let (manual, size_changed) = ctx.data_mut(|data| {
|
||||
let placement = data.get_temp_mut_or_default::<FloatPlacement>(window_id.with("placement"));
|
||||
let size_changed = placement.configured_size != Some(size);
|
||||
placement.configured_size = Some(size);
|
||||
(placement.manual, size_changed)
|
||||
});
|
||||
if changed {
|
||||
// Center using the remembered size, including user resizing.
|
||||
let size = egui::AreaState::load(ctx, window_id)
|
||||
.and_then(|state| state.size)
|
||||
.unwrap_or(size);
|
||||
window.current_pos(workspace.center() - size / 2.0 + float_offset(index))
|
||||
} else {
|
||||
window
|
||||
if !manual {
|
||||
window = window.current_pos(pos);
|
||||
if size_changed {
|
||||
// default_size alone does not update egui's remembered size. Clamp
|
||||
// for this frame, then restore normal resize limits on the next one.
|
||||
window = window.fixed_size(size).resizable(true);
|
||||
}
|
||||
}
|
||||
window
|
||||
}
|
||||
|
||||
fn pane_title(id: PaneId, pane: &Pane) -> String {
|
||||
let title = pane.terminal.title().unwrap_or("");
|
||||
@@ -728,6 +978,117 @@ fn pane_title(id: PaneId, pane: &Pane) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pump_plugins(app: &mut App, ctx: &egui::Context, done: impl Fn(&App) -> bool) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);
|
||||
loop {
|
||||
for pane in app.panes.values_mut() {
|
||||
pane.drain(ctx);
|
||||
}
|
||||
app.poll_plugins(ctx);
|
||||
assert!(app.error.is_none(), "plugin error: {:?}", app.error);
|
||||
if done(app) {
|
||||
break;
|
||||
}
|
||||
assert!(std::time::Instant::now() < deadline, "plugin timed out");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_plugin_controls_panes_and_native_ui() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = test_app(&ctx);
|
||||
let path = std::env::temp_dir().join(format!("slerm-plugin-{}.js", std::process::id()));
|
||||
std::fs::write(&path, r#"
|
||||
__slerm.activate({
|
||||
async activate(api) {
|
||||
let rejected = false;
|
||||
try { await api.focusPane({pane:999}); } catch (_) { rejected = true; }
|
||||
if (!rejected) throw Error('missing validation');
|
||||
await api.execute({action:'new_floating'});
|
||||
const state = await api.getState();
|
||||
if (state.tabs[0].panes.length !== 2) throw Error('wrong state');
|
||||
await api.writeTerminal({pane:state.tabs[0].activePane, text:'echo SDK_INPUT\r'});
|
||||
if (typeof await api.readTerminal({pane:1}) !== 'string') throw Error('wrong terminal result');
|
||||
await api.setPanel({panel:{id:'test', title:'Tools', elements:[
|
||||
{type:'button', id:'done', label:'Done'},
|
||||
{type:'input', id:'name', label:'Name', value:'Slerm'}
|
||||
]}});
|
||||
await api.registerCommand({command:{id:'ready',title:'Ready'}});
|
||||
},
|
||||
async onEvent(event, api) {
|
||||
if (event.event === 'command') await api.execute({action:'new_tab'});
|
||||
if (event.event === 'ui') {
|
||||
await api.removePanel({panel:event.data.panel});
|
||||
await api.registerCommand({command:{id:'passed',title:'Passed'}});
|
||||
}
|
||||
}
|
||||
}, 1);
|
||||
"#).unwrap();
|
||||
let (host, errors) = Host::start(
|
||||
&[config::Plugin {
|
||||
id: "test".into(),
|
||||
path: path.clone(),
|
||||
}],
|
||||
std::path::Path::new("."),
|
||||
&ctx,
|
||||
);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
app.plugins = host;
|
||||
pump_plugins(&mut app, &ctx, |app| {
|
||||
app.plugins.runtimes[0].commands.contains_key("ready")
|
||||
});
|
||||
assert_eq!(app.tabs[0].floats.len(), 1);
|
||||
assert_eq!(app.plugins.runtimes[0].panels.len(), 1);
|
||||
let _ = ctx.run(egui::RawInput::default(), |ctx| {
|
||||
assert!(app.plugins.show_panels(ctx).is_empty());
|
||||
});
|
||||
app.plugins.invoke(0, "ready").unwrap();
|
||||
pump_plugins(&mut app, &ctx, |app| app.tabs.len() == 2);
|
||||
app.plugins.runtimes[0]
|
||||
.send(Event::Ui {
|
||||
data: crate::plugin_api::UIEvent::Click {
|
||||
panel: "test".into(),
|
||||
control: "done".into(),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
pump_plugins(&mut app, &ctx, |app| {
|
||||
app.plugins.runtimes[0].commands.contains_key("passed")
|
||||
});
|
||||
assert!(app.plugins.runtimes[0].panels.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "build the example first: npm run example --prefix sdk"]
|
||||
fn bundled_typescript_example_runs_in_boa() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = test_app(&ctx);
|
||||
let (host, errors) = Host::start(
|
||||
&[config::Plugin {
|
||||
id: "workspace".into(),
|
||||
path: "sdk/dist/workspace.js".into(),
|
||||
}],
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")),
|
||||
&ctx,
|
||||
);
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
app.plugins = host;
|
||||
pump_plugins(&mut app, &ctx, |app| {
|
||||
app.plugins.runtimes[0].panels.contains_key("workspace")
|
||||
});
|
||||
app.plugins.runtimes[0]
|
||||
.send(Event::Ui {
|
||||
data: crate::plugin_api::UIEvent::Click {
|
||||
panel: "workspace".into(),
|
||||
control: "scratch".into(),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
pump_plugins(&mut app, &ctx, |app| app.tabs[0].floats.len() == 1);
|
||||
}
|
||||
|
||||
fn test_app(ctx: &egui::Context) -> App {
|
||||
let config = Config {
|
||||
shell: vec!["/bin/sh".into()],
|
||||
@@ -745,6 +1106,7 @@ mod tests {
|
||||
next_id: 2,
|
||||
error: None,
|
||||
suppressed: HashSet::new(),
|
||||
plugins: Host::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,6 +1176,7 @@ mod tests {
|
||||
.title_bar(false)
|
||||
.frame(egui::Frame::NONE);
|
||||
if moved == Some(id) {
|
||||
mark_float_manual(ctx, window_id);
|
||||
window = window.current_pos(egui::pos2(100.0, 100.0));
|
||||
}
|
||||
let response =
|
||||
@@ -825,6 +1188,7 @@ mod tests {
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
remember_float_interaction(ctx, window_id, &response.response);
|
||||
positions.insert(id, response.response.rect.min);
|
||||
}
|
||||
},
|
||||
@@ -833,12 +1197,18 @@ mod tests {
|
||||
positions
|
||||
};
|
||||
render(&tab, Some(2), workspace);
|
||||
render(&tab, Some(5), workspace);
|
||||
assert_eq!(render(&tab, None, workspace)[&2], egui::pos2(100.0, 100.0));
|
||||
// Resizing recenters even a dragged pane, using its existing size.
|
||||
// Workspace changes preserve manual panes while recentering the rest.
|
||||
let resized = Rect::from_min_size(egui::Pos2::ZERO, vec2(1800.0, 1400.0));
|
||||
let positions = render(&tab, None, resized);
|
||||
for (index, &id) in tab.floats.iter().enumerate() {
|
||||
assert_eq!(positions[&id], float_placement(resized, index, &config).0);
|
||||
let expected = if id == 2 || id == 5 {
|
||||
egui::pos2(100.0, 100.0)
|
||||
} else {
|
||||
float_placement(resized, index, &config).0
|
||||
};
|
||||
assert_eq!(positions[&id], expected);
|
||||
}
|
||||
render(&tab, None, workspace);
|
||||
render(&tab, Some(2), workspace);
|
||||
@@ -851,7 +1221,7 @@ mod tests {
|
||||
let positions = render(&tab, None, workspace);
|
||||
tab.active = tab.floats[0];
|
||||
for index in 0..tab.floats.len() {
|
||||
let expected = if tab.active == 2 {
|
||||
let expected = if tab.active == 2 || tab.active == 5 {
|
||||
egui::pos2(100.0, 100.0)
|
||||
} else {
|
||||
float_placement(workspace, index, &config).0
|
||||
@@ -863,6 +1233,160 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_float(
|
||||
ctx: &egui::Context,
|
||||
workspace: Rect,
|
||||
config: &config::Multiplexer,
|
||||
events: Vec<egui::Event>,
|
||||
) -> Rect {
|
||||
let window_id = egui::Id::new("test float");
|
||||
let mut rect = Rect::NOTHING;
|
||||
let _ = ctx.run(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(workspace),
|
||||
events,
|
||||
..Default::default()
|
||||
},
|
||||
|ctx| {
|
||||
let window = egui::Window::new("float")
|
||||
.id(window_id)
|
||||
.title_bar(false)
|
||||
.constrain_to(workspace)
|
||||
.min_size(vec2(160.0, 100.0))
|
||||
.frame(egui::Frame::NONE);
|
||||
let response = position_float(ctx, window, window_id, 0, workspace, config)
|
||||
.show(ctx, |ui| {
|
||||
let rect = ui.available_rect_before_wrap();
|
||||
ui.allocate_rect(rect, egui::Sense::hover());
|
||||
let header = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0));
|
||||
ui.interact(header, ui.id().with("header"), egui::Sense::click());
|
||||
let content = Rect::from_min_max(header.left_bottom(), rect.max);
|
||||
ui.interact(
|
||||
content,
|
||||
ui.id().with("content"),
|
||||
egui::Sense::click_and_drag(),
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
remember_float_interaction(ctx, window_id, &response.response);
|
||||
rect = response.response.rect;
|
||||
},
|
||||
);
|
||||
rect
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_floats_follow_workspace_and_config_changes() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut config = config::Multiplexer::default();
|
||||
for (width, height, fraction, maximum) in [
|
||||
(1000.0, 800.0, 0.65, 960.0),
|
||||
(800.0, 600.0, 0.65, 960.0),
|
||||
(1800.0, 1400.0, 0.65, 960.0),
|
||||
(1800.0, 1400.0, 0.4, 960.0),
|
||||
(1800.0, 1400.0, 0.4, 500.0),
|
||||
(200.0, 150.0, 0.4, 500.0),
|
||||
(100.0, 80.0, 0.4, 500.0),
|
||||
(1000.0, 800.0, 0.65, 960.0),
|
||||
] {
|
||||
config.floating_width = fraction;
|
||||
config.floating_height = fraction;
|
||||
config.floating_max_width = maximum;
|
||||
config.floating_max_height = maximum;
|
||||
let workspace = Rect::from_min_size(egui::pos2(0.0, 30.0), vec2(width, height));
|
||||
let (pos, size) = float_placement(workspace, 0, &config);
|
||||
for _ in 0..3 {
|
||||
render_float(&ctx, workspace, &config, vec![]);
|
||||
}
|
||||
let rect = render_float(&ctx, workspace, &config, vec![]);
|
||||
assert!((rect.min - pos).abs().max_elem() < 0.1, "{rect:?}");
|
||||
assert!((rect.size() - size).abs().max_elem() < 0.1, "{rect:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_float_geometry_survives_workspace_config_and_visibility_changes() {
|
||||
for gesture in ["move", "resize", "select", "click"] {
|
||||
let ctx = egui::Context::default();
|
||||
let window_id = egui::Id::new("test float");
|
||||
let mut config = config::Multiplexer::default();
|
||||
let workspace = Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 800.0));
|
||||
for _ in 0..3 {
|
||||
render_float(&ctx, workspace, &config, vec![]);
|
||||
}
|
||||
let before = render_float(&ctx, workspace, &config, vec![]);
|
||||
let start = match gesture {
|
||||
"resize" => before.right_bottom() + vec2(2.0, 2.0),
|
||||
"select" => before.center(),
|
||||
_ => before.center_top() + vec2(0.0, 12.0),
|
||||
};
|
||||
let end = if gesture == "click" {
|
||||
start
|
||||
} else {
|
||||
start + vec2(50.0, 40.0)
|
||||
};
|
||||
let button = |pos, pressed| egui::Event::PointerButton {
|
||||
pos,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed,
|
||||
modifiers: egui::Modifiers::default(),
|
||||
};
|
||||
render_float(
|
||||
&ctx,
|
||||
workspace,
|
||||
&config,
|
||||
vec![egui::Event::PointerMoved(start)],
|
||||
);
|
||||
render_float(&ctx, workspace, &config, vec![button(start, true)]);
|
||||
if gesture != "click" {
|
||||
render_float(
|
||||
&ctx,
|
||||
workspace,
|
||||
&config,
|
||||
vec![egui::Event::PointerMoved(end)],
|
||||
);
|
||||
}
|
||||
render_float(&ctx, workspace, &config, vec![button(end, false)]);
|
||||
let after = render_float(&ctx, workspace, &config, vec![]);
|
||||
let manual = matches!(gesture, "move" | "resize");
|
||||
assert_eq!(
|
||||
ctx.data_mut(|data| data
|
||||
.get_temp::<FloatPlacement>(window_id.with("placement"))
|
||||
.unwrap()
|
||||
.manual),
|
||||
manual,
|
||||
"{gesture}"
|
||||
);
|
||||
match gesture {
|
||||
"move" => {
|
||||
assert_eq!(after.min, before.min + vec2(50.0, 40.0));
|
||||
assert_eq!(after.size(), before.size());
|
||||
}
|
||||
"resize" => assert_ne!(after.size(), before.size()),
|
||||
_ => assert_eq!(after, before),
|
||||
}
|
||||
// Hidden and zoomed floats both skip rendering their window.
|
||||
let _ = ctx.run(egui::RawInput::default(), |_| {});
|
||||
config.floating_width = 0.4;
|
||||
config.floating_height = 0.4;
|
||||
let workspace = Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 1000.0));
|
||||
for _ in 0..3 {
|
||||
render_float(&ctx, workspace, &config, vec![]);
|
||||
}
|
||||
let restored = render_float(&ctx, workspace, &config, vec![]);
|
||||
let (pos, size) = float_placement(workspace, 0, &config);
|
||||
assert_eq!(
|
||||
restored,
|
||||
if manual {
|
||||
after
|
||||
} else {
|
||||
Rect::from_min_size(pos, size)
|
||||
},
|
||||
"{gesture}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floats_open_centered_then_cascade_and_stay_within_the_maximums() {
|
||||
let config = config::Multiplexer::default();
|
||||
|
||||
+23
-1
@@ -34,6 +34,15 @@ pub struct Config {
|
||||
pub kitty: Kitty,
|
||||
pub multiplexer: Multiplexer,
|
||||
pub keys: BTreeMap<String, Action>,
|
||||
pub plugins: Vec<Plugin>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Plugin {
|
||||
pub id: String,
|
||||
/// JavaScript bundle path, relative to the config file.
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -55,7 +64,7 @@ pub struct Multiplexer {
|
||||
pub floating_max_height: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, ts_rs::TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Action {
|
||||
SplitRight,
|
||||
@@ -127,6 +136,7 @@ impl Default for Config {
|
||||
colors: BTreeMap::new(),
|
||||
kitty: Kitty::default(),
|
||||
multiplexer: Multiplexer::default(),
|
||||
plugins: Vec::new(),
|
||||
keys: [
|
||||
("Alt+F", Action::ToggleFloats),
|
||||
("Alt+M", Action::NewPane),
|
||||
@@ -174,6 +184,18 @@ impl Config {
|
||||
|
||||
pub fn parse(text: &str) -> Result<Self> {
|
||||
let config: Self = toml::from_str(text)?;
|
||||
let mut plugin_ids = std::collections::HashSet::new();
|
||||
for plugin in &config.plugins {
|
||||
ensure!(
|
||||
crate::plugins::valid_id(&plugin.id) && plugin_ids.insert(&plugin.id),
|
||||
"plugin ids must be unique and contain only letters, digits, '.', '-' or '_'"
|
||||
);
|
||||
ensure!(
|
||||
!plugin.path.as_os_str().is_empty(),
|
||||
"plugin {} needs a bundle path",
|
||||
plugin.id
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
config.font_path.is_none() || config.font_family.is_none(),
|
||||
"set either font_family or font_path, not both"
|
||||
|
||||
+10
-1
@@ -4,6 +4,8 @@ mod fonts;
|
||||
mod input;
|
||||
mod layout;
|
||||
mod pane;
|
||||
mod plugin_api;
|
||||
mod plugins;
|
||||
mod pty;
|
||||
mod render;
|
||||
#[cfg(test)]
|
||||
@@ -29,13 +31,20 @@ fn main() -> Result<()> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
"--generate-plugin-sdk" => {
|
||||
let target =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("sdk/src/generated.ts");
|
||||
std::fs::write(&target, plugin_api::typescript())?;
|
||||
println!("Generated {}", target.display());
|
||||
return Ok(());
|
||||
}
|
||||
"--version" => {
|
||||
println!("slerm {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"slerm [--config PATH] [--check-config | --init-config | --list-themes]\n\nDefault config: {}",
|
||||
"slerm [--config PATH] [--check-config | --init-config | --list-themes | --generate-plugin-sdk]\n\nDefault config: {}",
|
||||
path.display()
|
||||
);
|
||||
return Ok(());
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! The source of truth for the plugin API and its generated TypeScript bindings.
|
||||
use crate::config::Action;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
pub const API_VERSION: u32 = 1;
|
||||
|
||||
// Declare each method, its parameters and its result once. The enum drives Rust
|
||||
// dispatch; the generated result map drives the TypeScript client's signatures.
|
||||
macro_rules! plugin_api {
|
||||
($($variant:ident => $method:ident { $($(#[$attr:meta])* $arg:ident: $ty:ty),* } -> $result:ty;)+) => {
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(tag = "method", rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub enum Operation {
|
||||
$($variant { $($(#[$attr])* $arg: $ty),* },)+
|
||||
}
|
||||
#[derive(TS)]
|
||||
#[allow(dead_code, non_snake_case)]
|
||||
pub struct Results { $(pub $method: $result,)+ }
|
||||
};
|
||||
}
|
||||
plugin_api! {
|
||||
GetState => getState {} -> State;
|
||||
Execute => execute { action: Action } -> ();
|
||||
FocusPane => focusPane { #[ts(type = "number")] pane: u64 } -> ();
|
||||
FocusTab => focusTab { #[ts(type = "number")] tab: u64 } -> ();
|
||||
ReadTerminal => readTerminal { #[ts(type = "number")] pane: u64 } -> String;
|
||||
WriteTerminal => writeTerminal { #[ts(type = "number")] pane: u64, text: String } -> ();
|
||||
RegisterCommand => registerCommand { command: PluginCommand } -> ();
|
||||
UnregisterCommand => unregisterCommand { command: String } -> ();
|
||||
SetPanel => setPanel { panel: Panel } -> ();
|
||||
RemovePanel => removePanel { panel: String } -> ();
|
||||
ReportError => reportError { message: String } -> ();
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct State {
|
||||
#[ts(type = "number | null")]
|
||||
pub active_tab: Option<u64>,
|
||||
pub tabs: Vec<TabState>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TabState {
|
||||
#[ts(type = "number")]
|
||||
pub id: u64,
|
||||
#[ts(type = "number")]
|
||||
pub active_pane: u64,
|
||||
pub floats_visible: bool,
|
||||
pub zoomed: bool,
|
||||
pub panes: Vec<PaneState>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
pub struct PaneState {
|
||||
#[ts(type = "number")]
|
||||
pub id: u64,
|
||||
pub title: String,
|
||||
pub floating: bool,
|
||||
pub exited: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, TS)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginCommand {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, TS)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Panel {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub elements: Vec<Element>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, TS)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub enum Element {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
Button {
|
||||
id: String,
|
||||
label: String,
|
||||
},
|
||||
Input {
|
||||
id: String,
|
||||
label: String,
|
||||
value: String,
|
||||
},
|
||||
Separator,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum UIEvent {
|
||||
Click {
|
||||
panel: String,
|
||||
control: String,
|
||||
},
|
||||
Change {
|
||||
panel: String,
|
||||
control: String,
|
||||
value: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(tag = "event", rename_all = "camelCase")]
|
||||
pub enum Event {
|
||||
Command { command: String },
|
||||
State { state: State },
|
||||
Ui { data: UIEvent },
|
||||
}
|
||||
|
||||
pub fn typescript() -> String {
|
||||
let mut output =
|
||||
String::from("// Generated by cargo run -- --generate-plugin-sdk. Do not edit.\n\n");
|
||||
output.push_str(&format!(
|
||||
"export const apiVersion = {API_VERSION} as const;\n\n"
|
||||
));
|
||||
let config = ts_rs::Config::default();
|
||||
for declaration in [
|
||||
Action::decl(&config),
|
||||
Operation::decl(&config),
|
||||
Results::decl(&config),
|
||||
State::decl(&config),
|
||||
TabState::decl(&config),
|
||||
PaneState::decl(&config),
|
||||
PluginCommand::decl(&config),
|
||||
Panel::decl(&config),
|
||||
Element::decl(&config),
|
||||
UIEvent::decl(&config),
|
||||
Event::decl(&config),
|
||||
] {
|
||||
output.push_str("export ");
|
||||
output.push_str(&declaration);
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
output.push_str(r#"type Params<M extends Operation["method"]> = Omit<Extract<Operation, { method: M }>, "method">;
|
||||
export type Slerm = {
|
||||
[M in Operation["method"]]: (...args: keyof Params<M> extends never ? [] : [params: Params<M>]) => Promise<Results[M] extends null ? void : Results[M]>;
|
||||
};
|
||||
"#);
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn generated_sdk_matches_rust_api() {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("sdk/src/generated.ts");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(path).unwrap(),
|
||||
super::typescript(),
|
||||
"Regenerate bindings with cargo run -- --generate-plugin-sdk"
|
||||
);
|
||||
}
|
||||
}
|
||||
+457
@@ -0,0 +1,457 @@
|
||||
//! Embedded Boa runtimes, one worker thread per plugin. UI mutations stay on the UI thread.
|
||||
use crate::{
|
||||
config::Plugin,
|
||||
plugin_api::{API_VERSION, Element, Event, Operation, Panel, PluginCommand, UIEvent},
|
||||
};
|
||||
use anyhow::{Context as _, Result, ensure};
|
||||
use boa_engine::{
|
||||
Context, JsNativeError, JsResult, JsValue, NativeFunction, Source,
|
||||
job::{Job, JobExecutor},
|
||||
js_string,
|
||||
property::Attribute,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{BTreeMap, VecDeque},
|
||||
path::Path,
|
||||
rc::Rc,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc::{self, Receiver, SyncSender, TryRecvError},
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const MAX_MESSAGE: usize = 1024 * 1024;
|
||||
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 80
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c))
|
||||
}
|
||||
|
||||
pub struct Pending {
|
||||
pub operation: Operation,
|
||||
pub reply: SyncSender<Value>,
|
||||
}
|
||||
|
||||
struct Bridge {
|
||||
requests: SyncSender<Result<Pending, String>>,
|
||||
repaint: egui::Context,
|
||||
stopped: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
fn host_call(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let text = args
|
||||
.first()
|
||||
.unwrap_or(&JsValue::undefined())
|
||||
.to_string(ctx)?
|
||||
.to_std_string_escaped();
|
||||
if text.len() > MAX_MESSAGE {
|
||||
return Err(JsNativeError::range()
|
||||
.with_message("plugin request exceeds 1 MiB")
|
||||
.into());
|
||||
}
|
||||
let operation = serde_json::from_str(&text)
|
||||
.map_err(|e| JsNativeError::typ().with_message(e.to_string()))?;
|
||||
let bridge = ctx.get_data::<Bridge>().unwrap();
|
||||
let (reply, response) = mpsc::sync_channel(1);
|
||||
bridge
|
||||
.requests
|
||||
.try_send(Ok(Pending { operation, reply }))
|
||||
.map_err(|_| JsNativeError::error().with_message("plugin host is unavailable"))?;
|
||||
bridge.repaint.request_repaint();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
if bridge.stopped.load(Ordering::Relaxed) || Instant::now() >= deadline {
|
||||
return Err(JsNativeError::error()
|
||||
.with_message("plugin stopped or host request timed out")
|
||||
.into());
|
||||
}
|
||||
match response.recv_timeout(Duration::from_millis(50)) {
|
||||
Ok(value) => return Ok(js_string!(serde_json::to_string(&value).unwrap()).into()),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(_) => {
|
||||
return Err(JsNativeError::error()
|
||||
.with_message("plugin host disconnected")
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn host_error(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let text = args
|
||||
.first()
|
||||
.unwrap_or(&JsValue::undefined())
|
||||
.to_string(ctx)?
|
||||
.to_std_string_escaped();
|
||||
let bridge = ctx.get_data::<Bridge>().unwrap();
|
||||
let _ = bridge.requests.try_send(Err(text));
|
||||
bridge.repaint.request_repaint();
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
// This runtime exposes promises, but no timers or native async jobs. Bound each
|
||||
// promise checkpoint as well as JS loops, so runaway callbacks can be stopped.
|
||||
struct Jobs {
|
||||
queue: RefCell<VecDeque<Job>>,
|
||||
stopped: Arc<AtomicBool>,
|
||||
}
|
||||
impl JobExecutor for Jobs {
|
||||
fn enqueue_job(self: Rc<Self>, job: Job, _: &mut Context) {
|
||||
self.queue.borrow_mut().push_back(job);
|
||||
}
|
||||
fn run_jobs(self: Rc<Self>, ctx: &mut Context) -> JsResult<()> {
|
||||
for _ in 0..10_000 {
|
||||
if self.stopped.load(Ordering::Relaxed) {
|
||||
return Err(JsNativeError::error().with_message("plugin stopped").into());
|
||||
}
|
||||
let Some(job) = self.queue.borrow_mut().pop_front() else {
|
||||
return Ok(());
|
||||
};
|
||||
match job {
|
||||
Job::PromiseJob(job) => {
|
||||
job.call(ctx)?;
|
||||
}
|
||||
Job::GenericJob(job) => {
|
||||
job.call(ctx)?;
|
||||
}
|
||||
Job::FinalizationRegistryCleanupJob(_) => {}
|
||||
_ => {
|
||||
return Err(JsNativeError::error()
|
||||
.with_message("unsupported plugin job")
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(JsNativeError::error()
|
||||
.with_message("plugin promise job limit exceeded")
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
fn run(source: String, events: Receiver<Event>, bridge: Bridge) -> Result<()> {
|
||||
let jobs = Rc::new(Jobs {
|
||||
queue: RefCell::new(VecDeque::new()),
|
||||
stopped: bridge.stopped.clone(),
|
||||
});
|
||||
let mut ctx = Context::builder()
|
||||
.job_executor(jobs)
|
||||
.build()
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
ctx.runtime_limits_mut().set_loop_iteration_limit(1_000_000);
|
||||
ctx.runtime_limits_mut().set_recursion_limit(128);
|
||||
ctx.insert_data(bridge);
|
||||
let execute = |ctx: &mut Context, source: &str| -> Result<()> {
|
||||
ctx.eval(Source::from_bytes(source))
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
ctx.run_jobs().map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
Ok(())
|
||||
};
|
||||
ctx.register_global_builtin_callable(
|
||||
js_string!("__slerm_call"),
|
||||
1,
|
||||
NativeFunction::from_fn_ptr(host_call),
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
ctx.register_global_builtin_callable(
|
||||
js_string!("__slerm_error"),
|
||||
1,
|
||||
NativeFunction::from_fn_ptr(host_error),
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
ctx.register_global_property(
|
||||
js_string!("__slerm_version"),
|
||||
API_VERSION,
|
||||
Attribute::READONLY,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
execute(&mut ctx, include_str!("../sdk/runtime.js"))?;
|
||||
execute(&mut ctx, &source)?;
|
||||
while let Ok(event) = events.recv() {
|
||||
if ctx
|
||||
.get_data::<Bridge>()
|
||||
.unwrap()
|
||||
.stopped
|
||||
.load(Ordering::Relaxed)
|
||||
{
|
||||
break;
|
||||
}
|
||||
execute(
|
||||
&mut ctx,
|
||||
&format!("__slerm_dispatch({});", serde_json::to_string(&event)?),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct Runtime {
|
||||
pub id: String,
|
||||
pub commands: BTreeMap<String, PluginCommand>,
|
||||
pub panels: BTreeMap<String, Panel>,
|
||||
pub last_state: Option<Value>,
|
||||
outgoing: Option<SyncSender<Event>>,
|
||||
incoming: Option<Receiver<Result<Pending, String>>>,
|
||||
stopped: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
pub fn spawn(config: &Plugin, config_dir: &Path, ctx: &egui::Context) -> Result<Self> {
|
||||
let path = config_dir.join(&config.path);
|
||||
let source = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("Cannot read plugin {} at {}", config.id, path.display()))?;
|
||||
ensure!(
|
||||
source.len() <= 4 * MAX_MESSAGE,
|
||||
"plugin bundle exceeds 4 MiB"
|
||||
);
|
||||
let (outgoing, events) = mpsc::sync_channel(64);
|
||||
let (requests, incoming) = mpsc::sync_channel(64);
|
||||
let stopped = Arc::new(AtomicBool::new(false));
|
||||
let bridge = Bridge {
|
||||
requests: requests.clone(),
|
||||
repaint: ctx.clone(),
|
||||
stopped: stopped.clone(),
|
||||
};
|
||||
let repaint = ctx.clone();
|
||||
thread::Builder::new()
|
||||
.name(format!("plugin-{}", config.id))
|
||||
.spawn(move || {
|
||||
if let Err(error) = run(source, events, bridge) {
|
||||
let _ = requests.try_send(Err(error.to_string()));
|
||||
repaint.request_repaint();
|
||||
}
|
||||
})?;
|
||||
Ok(Self {
|
||||
id: config.id.clone(),
|
||||
commands: BTreeMap::new(),
|
||||
panels: BTreeMap::new(),
|
||||
last_state: None,
|
||||
outgoing: Some(outgoing),
|
||||
incoming: Some(incoming),
|
||||
stopped,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn poll(&mut self) -> Option<Result<Pending, String>> {
|
||||
match self.incoming.as_ref()?.try_recv() {
|
||||
Ok(request) => Some(request),
|
||||
Err(TryRecvError::Empty) => None,
|
||||
Err(TryRecvError::Disconnected) => Some(Err("plugin runtime stopped".into())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send(&self, event: Event) -> Result<()> {
|
||||
let outgoing = self
|
||||
.outgoing
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("plugin is stopped"))?;
|
||||
ensure!(
|
||||
serde_json::to_vec(&event)?.len() < MAX_MESSAGE,
|
||||
"plugin event exceeds 1 MiB"
|
||||
);
|
||||
outgoing
|
||||
.try_send(event)
|
||||
.context("plugin event queue is full or disconnected")
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
!self.stopped.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
self.stopped.store(true, Ordering::Relaxed);
|
||||
self.commands.clear();
|
||||
self.panels.clear();
|
||||
self.outgoing.take();
|
||||
self.incoming.take();
|
||||
}
|
||||
}
|
||||
impl Drop for Runtime {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
impl Panel {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
ensure!(valid_id(&self.id), "invalid panel id");
|
||||
ensure!(
|
||||
!self.title.trim().is_empty() && self.title.len() <= 200,
|
||||
"invalid panel title"
|
||||
);
|
||||
ensure!(
|
||||
self.elements.len() <= 128,
|
||||
"a panel supports at most 128 elements"
|
||||
);
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for element in &self.elements {
|
||||
let id = match element {
|
||||
Element::Button { id, .. } | Element::Input { id, .. } => Some(id),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(id) = id {
|
||||
ensure!(
|
||||
valid_id(id) && ids.insert(id),
|
||||
"invalid or duplicate control id"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Host {
|
||||
pub runtimes: Vec<Runtime>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
pub fn start(
|
||||
configs: &[Plugin],
|
||||
config_dir: &Path,
|
||||
ctx: &egui::Context,
|
||||
) -> (Self, Vec<String>) {
|
||||
let mut host = Self::default();
|
||||
let mut errors = Vec::new();
|
||||
for config in configs {
|
||||
match Runtime::spawn(config, config_dir, ctx) {
|
||||
Ok(process) => host.runtimes.push(process),
|
||||
Err(e) => errors.push(e.to_string()),
|
||||
}
|
||||
}
|
||||
(host, errors)
|
||||
}
|
||||
|
||||
pub fn invoke(&self, index: usize, command: &str) -> Result<()> {
|
||||
let plugin = &self.runtimes[index];
|
||||
ensure!(
|
||||
plugin.commands.contains_key(command),
|
||||
"unknown plugin command"
|
||||
);
|
||||
plugin.send(Event::Command {
|
||||
command: command.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn show_panels(&mut self, ctx: &egui::Context) -> Vec<String> {
|
||||
let mut errors = Vec::new();
|
||||
for plugin in &mut self.runtimes {
|
||||
let mut events = Vec::new();
|
||||
for panel in plugin.panels.values_mut() {
|
||||
egui::Window::new(&panel.title)
|
||||
.id(egui::Id::new(("plugin panel", &plugin.id, &panel.id)))
|
||||
.order(egui::Order::Foreground)
|
||||
.default_width(280.0)
|
||||
.show(ctx, |ui| {
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(500.0)
|
||||
.show(ui, |ui| {
|
||||
for element in &mut panel.elements {
|
||||
match element {
|
||||
Element::Text { text } => {
|
||||
ui.label(text.as_str());
|
||||
}
|
||||
Element::Separator => {
|
||||
ui.separator();
|
||||
}
|
||||
Element::Button { id, label } => {
|
||||
if ui
|
||||
.push_id(id.as_str(), |ui| {
|
||||
ui.button(label.as_str())
|
||||
})
|
||||
.inner
|
||||
.clicked()
|
||||
{
|
||||
events.push(Event::Ui {
|
||||
data: UIEvent::Click {
|
||||
panel: panel.id.clone(),
|
||||
control: id.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Element::Input { id, label, value } => {
|
||||
ui.label(label.as_str());
|
||||
if ui
|
||||
.add(
|
||||
egui::TextEdit::singleline(value)
|
||||
.id_source(id.as_str()),
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
events.push(Event::Ui {
|
||||
data: UIEvent::Change {
|
||||
panel: panel.id.clone(),
|
||||
control: id.clone(),
|
||||
value: value.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
for event in events {
|
||||
if let Err(e) = plugin.send(event) {
|
||||
errors.push(format!("{}: {e}", plugin.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_configuration_and_ui() {
|
||||
assert!(crate::config::Config::parse("[[plugins]]\nid='x'\npath=''\n").is_err());
|
||||
assert!(
|
||||
crate::config::Config::parse(
|
||||
"[[plugins]]\nid='x'\npath='a.js'\n[[plugins]]\nid='x'\npath='b.js'\n"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(serde_json::from_str::<Operation>(r#"{"method":"unknown"}"#).is_err());
|
||||
let panel: Panel =
|
||||
serde_json::from_value(json!({"id":"panel", "title":"Panel", "elements":[
|
||||
{"type":"button", "id":"same", "label":"One"},
|
||||
{"type":"input", "id":"same", "label":"Two", "value":""}
|
||||
]}))
|
||||
.unwrap();
|
||||
assert!(panel.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boa_reports_script_errors_and_limits_runaway_loops_and_promises() {
|
||||
for source in [
|
||||
"throw Error('broken plugin')",
|
||||
"while (true) {}",
|
||||
"function again() { Promise.resolve().then(again); } again();",
|
||||
"__slerm.activate({}, 999)",
|
||||
"process.exit(0)",
|
||||
] {
|
||||
let (_, events) = mpsc::sync_channel(1);
|
||||
let (requests, _) = mpsc::sync_channel(1);
|
||||
let bridge = Bridge {
|
||||
requests,
|
||||
repaint: egui::Context::default(),
|
||||
stopped: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
assert!(
|
||||
run(source.into(), events, bridge).is_err(),
|
||||
"source should fail: {source}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user