Compare commits

Author SHA1 Message Date
Gregor Lohaus 98453329a1 ts-plugin-runtime 2026-09-24 15:21:12 +02:00
Gregor Lohaus a5ac986375 plugin sdk
squash
2026-09-17 14:27:57 +02:00
21 changed files with 2792 additions and 36 deletions
+2
View File
@@ -1,3 +1,5 @@
/target/ /target/
/.tools/ /.tools/
/.worktrees /.worktrees
/sdk/node_modules/
/sdk/dist/
Generated
+669 -2
View File
@@ -37,6 +37,21 @@ dependencies = [
"zerocopy", "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]] [[package]]
name = "android-activity" name = "android-activity"
version = "0.6.1" version = "0.6.1"
@@ -118,6 +133,18 @@ dependencies = [
"libloading", "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]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -178,6 +205,148 @@ dependencies = [
"objc2 0.5.2", "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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.3" version = "3.20.3"
@@ -306,6 +475,17 @@ dependencies = [
"libc", "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]] [[package]]
name = "clipboard-win" name = "clipboard-win"
version = "5.4.1" version = "5.4.1"
@@ -413,6 +593,21 @@ dependencies = [
"libm", "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]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.1" version = "1.5.1"
@@ -440,6 +635,26 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" 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]] [[package]]
name = "dispatch" name = "dispatch"
version = "0.2.0" version = "0.2.0"
@@ -497,6 +712,26 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" 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]] [[package]]
name = "ecolor" name = "ecolor"
version = "0.33.3" version = "0.33.3"
@@ -618,6 +853,12 @@ dependencies = [
"winit", "winit",
] ]
[[package]]
name = "either"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]] [[package]]
name = "emath" name = "emath"
version = "0.33.3" version = "0.33.3"
@@ -651,6 +892,26 @@ version = "0.33.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862" 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]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -673,6 +934,38 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" 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]] [[package]]
name = "fax" name = "fax"
version = "0.2.7" version = "0.2.7"
@@ -705,6 +998,12 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.1.10" version = "1.1.10"
@@ -787,12 +1086,44 @@ dependencies = [
"percent-encoding", "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]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.34" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" 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]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.34" version = "0.3.34"
@@ -842,6 +1173,7 @@ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core",
] ]
[[package]] [[package]]
@@ -984,6 +1316,12 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -1007,6 +1345,17 @@ name = "hashbrown"
version = "0.17.1" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" 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]] [[package]]
name = "hermit-abi" name = "hermit-abi"
@@ -1058,6 +1407,8 @@ dependencies = [
"icu_properties", "icu_properties",
"icu_provider", "icu_provider",
"smallvec", "smallvec",
"utf16_iter",
"write16",
"zerovec", "zerovec",
] ]
@@ -1160,6 +1511,27 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "jni" name = "jni"
version = "0.22.4" version = "0.22.4"
@@ -1505,6 +1877,32 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" 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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -1537,6 +1935,15 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "objc" name = "objc"
version = "0.2.7" version = "0.2.7"
@@ -1823,6 +2230,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oneshot"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c"
[[package]] [[package]]
name = "orbclient" name = "orbclient"
version = "0.3.55" version = "0.3.55"
@@ -1851,6 +2264,12 @@ dependencies = [
"ttf-parser", "ttf-parser",
] ]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -1880,12 +2299,60 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 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]] [[package]]
name = "pin-project" name = "pin-project"
version = "1.1.13" version = "1.1.13"
@@ -2002,6 +2469,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.5.0" version = "3.5.0"
@@ -2080,6 +2553,23 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 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]] [[package]]
name = "raw-window-handle" name = "raw-window-handle"
version = "0.6.2" version = "0.6.2"
@@ -2113,6 +2603,15 @@ dependencies = [
"bitflags 2.13.1", "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]] [[package]]
name = "renderdoc-sys" name = "renderdoc-sys"
version = "1.1.0" version = "1.1.0"
@@ -2178,6 +2677,12 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu-js"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714"
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@@ -2235,6 +2740,19 @@ dependencies = [
"syn 3.0.5", "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]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.1.1" version = "1.1.1"
@@ -2299,6 +2817,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -2312,6 +2836,7 @@ dependencies = [
"ab_glyph", "ab_glyph",
"anyhow", "anyhow",
"base64", "base64",
"boa_engine",
"eframe", "eframe",
"egui", "egui",
"fontdb", "fontdb",
@@ -2319,8 +2844,10 @@ dependencies = [
"png", "png",
"portable-pty", "portable-pty",
"serde", "serde",
"serde_json",
"shell-words", "shell-words",
"toml", "toml",
"ts-rs",
"wgpu", "wgpu",
] ]
@@ -2333,6 +2860,15 @@ dependencies = [
"version_check", "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]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.16.0" version = "1.16.0"
@@ -2432,6 +2968,27 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 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]] [[package]]
name = "syn" name = "syn"
version = "2.0.119" version = "2.0.119"
@@ -2465,6 +3022,44 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@@ -2519,6 +3114,38 @@ dependencies = [
"zune-jpeg", "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]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.4" version = "0.8.4"
@@ -2621,6 +3248,28 @@ version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" 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]] [[package]]
name = "ttf-parser" name = "ttf-parser"
version = "0.25.1" version = "0.25.1"
@@ -2669,6 +3318,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "utf16_iter"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -3419,6 +4074,12 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "write16"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.4" version = "0.6.4"
@@ -3508,7 +4169,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
"synstructure", "synstructure 0.13.2",
] ]
[[package]] [[package]]
@@ -3549,7 +4210,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
"synstructure", "synstructure 0.13.2",
] ]
[[package]] [[package]]
@@ -3591,6 +4252,12 @@ version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]] [[package]]
name = "zune-core" name = "zune-core"
version = "0.5.3" version = "0.5.3"
+3
View File
@@ -9,6 +9,7 @@ license = "MIT"
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
ab_glyph = "0.2" ab_glyph = "0.2"
boa_engine = { version = "0.22", default-features = false }
fontdb = "0.23" fontdb = "0.23"
eframe = { version = "0.33", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11"] } eframe = { version = "0.33", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11"] }
egui = "0.33" egui = "0.33"
@@ -16,8 +17,10 @@ libghostty-vt = { version = "=0.2.1", features = ["png"] }
png = "0.18" png = "0.18"
portable-pty = "0.9" portable-pty = "0.9"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1"
shell-words = "1" shell-words = "1"
toml = "0.9" toml = "0.9"
ts-rs = "12"
wgpu = { version = "27", default-features = false, features = ["std", "vulkan", "gles", "metal", "wgsl"] } wgpu = { version = "27", default-features = false, features = ["std", "vulkan", "gles", "metal", "wgsl"] }
[dev-dependencies] [dev-dependencies]
+9
View File
@@ -53,6 +53,8 @@ allow_shared_memory = true
[multiplexer] [multiplexer]
max_panes = 32 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_width = 0.65
floating_height = 0.65 floating_height = 0.65
# Fractions of the workspace, capped at these many points on large displays. # 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+R" = "reload_config"
"Ctrl+Shift+C" = "copy" "Ctrl+Shift+C" = "copy"
"Ctrl+Shift+V" = "paste" "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"
+3
View File
@@ -177,6 +177,9 @@
./Cargo.lock ./Cargo.lock
./config.example.toml ./config.example.toml
./src ./src
# Embedded runtime and the bindings checked by Rust tests.
./sdk/runtime.js
./sdk/src/generated.ts
]; ];
}; };
cargoLock.lockFile = ./Cargo.lock; cargoLock.lockFile = ./Cargo.lock;
+133
View File
@@ -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/).
+40
View File
@@ -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 }) }],
} });
}
},
});
+515
View File
@@ -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"
}
}
}
}
+29
View File
@@ -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"
}
}
+28
View File
@@ -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);
} });
})();
+30
View File
@@ -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]>;
};
+15
View File
@@ -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);
}
+67
View File
@@ -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"]);
});
+19
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"compilerOptions": { "noEmit": true, "rootDir": "." },
"include": ["examples/**/*.ts", "test/types.ts"]
}
+16
View File
@@ -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
View File
@@ -3,6 +3,8 @@ use crate::{
input, input,
layout::{Axis, Direction, Layout, PaneId, Tab}, layout::{Axis, Direction, Layout, PaneId, Tab},
pane::{self, Pane}, pane::{self, Pane},
plugin_api::{Event, Operation, PaneState, State, TabState},
plugins::{self, Host},
theme::{self, Theme}, theme::{self, Theme},
}; };
use anyhow::{Result, ensure}; use anyhow::{Result, ensure};
@@ -22,6 +24,7 @@ pub struct App {
next_id: u64, next_id: u64,
error: Option<String>, error: Option<String>,
suppressed: HashSet<egui::Key>, suppressed: HashSet<egui::Key>,
plugins: Host,
} }
/// Width kept free at the left of the tab bar for the macOS traffic lights, /// 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_visuals(theme.visuals());
cc.egui_ctx.set_fonts(fonts(&config)?); cc.egui_ctx.set_fonts(fonts(&config)?);
let pane = Pane::new(&config, &theme, &cc.egui_ctx, None)?; 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 { Ok(Self {
config, config,
theme, theme,
@@ -77,8 +85,9 @@ impl App {
tabs: vec![Tab::new(1, 1)], tabs: vec![Tab::new(1, 1)],
current: 0, current: 0,
next_id: 2, next_id: 2,
error: None, error: (!errors.is_empty()).then(|| errors.join("\n")),
suppressed: HashSet::new(), 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<()> { fn keyboard(&mut self, ctx: &egui::Context) -> Result<()> {
let events = ctx.input(|i| i.events.clone()); let events = ctx.input(|i| i.events.clone());
let modifiers = ctx.input(|i| i.modifiers); let modifiers = ctx.input(|i| i.modifiers);
@@ -388,15 +566,24 @@ impl eframe::App for App {
for pane in self.panes.values_mut() { for pane in self.panes.values_mut() {
pane.drain(ctx); 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); grab_keys(ctx);
if let Err(e) = self.keyboard(ctx) { if let Err(e) = self.keyboard(ctx) {
self.error = Some(e.to_string()); self.error = Some(e.to_string());
} }
}
if self.tabs.is_empty() { if self.tabs.is_empty() {
return; return;
} }
let mut action = None; let mut action = None;
let mut picked = None; let mut picked = None;
let mut plugin_command = None;
egui::TopBottomPanel::top("tabs").show(ctx, |ui| { egui::TopBottomPanel::top("tabs").show(ctx, |ui| {
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
if cfg!(target_os = "macos") { 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| { ui.menu_button("Settings", |ui| {
if ui.button("Reload config").clicked() { if ui.button("Reload config").clicked() {
action = Some(Action::ReloadConfig); action = Some(Action::ReloadConfig);
@@ -526,21 +727,31 @@ impl eframe::App for App {
tab.active = id; tab.active = id;
} }
}); });
if let Some(result) = result if let Some(result) = result {
&& result.response.contains_pointer() remember_float_interaction(ctx, window_id, &result.response);
&& ctx.input(|i| i.pointer.any_pressed()) if result.response.contains_pointer() && ctx.input(|i| i.pointer.any_pressed())
{ {
tab.active = id; tab.active = id;
} }
}
if active { if active {
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, window_id)); ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, window_id));
} }
} }
} }
let active = tab.active; 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 { for (id, pane) in &mut self.panes {
if let Err(e) = if let Err(e) = pane
pane.focus(*id == active && tab.is_visible(*id) && ctx.input(|i| i.focused)) .focus(*id == active && active_visible && !editing_ui && ctx.input(|i| i.focused))
{ {
pane.error = Some(e.to_string()); 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 // 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. // terminal far more room than it needs; the maximums keep it in hand.
let size = (workspace.size() * vec2(config.floating_width, config.floating_height)) 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) (workspace.center() - size / 2.0 + float_offset(index), size)
} }
@@ -685,8 +898,43 @@ fn float_offset(index: usize) -> egui::Vec2 {
vec2(step + stack, step) vec2(step + stack, step)
} }
/// Reapply the cascade when a pane changes slots or the workspace resizes. #[derive(Clone, Copy, Default)]
/// Otherwise leave egui's remembered position alone so panes can still be dragged. 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>( fn position_float<'a>(
ctx: &egui::Context, ctx: &egui::Context,
window: egui::Window<'a>, window: egui::Window<'a>,
@@ -696,24 +944,26 @@ fn position_float<'a>(
config: &config::Multiplexer, config: &config::Multiplexer,
) -> egui::Window<'a> { ) -> egui::Window<'a> {
let (pos, size) = float_placement(workspace, index, config); let (pos, size) = float_placement(workspace, index, config);
let window = window.default_pos(pos).default_size(size); let mut window = window
let changed = ctx.data_mut(|data| { .default_pos(pos)
let key = window_id.with("cascade placement"); .default_size(size)
let placement = (index, workspace); .min_size(vec2(160.0, 100.0).min(workspace.size()));
let previous = data.get_temp::<(usize, Rect)>(key); let (manual, size_changed) = ctx.data_mut(|data| {
data.insert_temp(key, placement); let placement = data.get_temp_mut_or_default::<FloatPlacement>(window_id.with("placement"));
previous.is_some_and(|previous| previous != placement) let size_changed = placement.configured_size != Some(size);
placement.configured_size = Some(size);
(placement.manual, size_changed)
}); });
if changed { if !manual {
// Center using the remembered size, including user resizing. window = window.current_pos(pos);
let size = egui::AreaState::load(ctx, window_id) if size_changed {
.and_then(|state| state.size) // default_size alone does not update egui's remembered size. Clamp
.unwrap_or(size); // for this frame, then restore normal resize limits on the next one.
window.current_pos(workspace.center() - size / 2.0 + float_offset(index)) window = window.fixed_size(size).resizable(true);
} else {
window
} }
} }
window
}
fn pane_title(id: PaneId, pane: &Pane) -> String { fn pane_title(id: PaneId, pane: &Pane) -> String {
let title = pane.terminal.title().unwrap_or(""); let title = pane.terminal.title().unwrap_or("");
@@ -728,6 +978,117 @@ fn pane_title(id: PaneId, pane: &Pane) -> String {
mod tests { mod tests {
use super::*; 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 { fn test_app(ctx: &egui::Context) -> App {
let config = Config { let config = Config {
shell: vec!["/bin/sh".into()], shell: vec!["/bin/sh".into()],
@@ -745,6 +1106,7 @@ mod tests {
next_id: 2, next_id: 2,
error: None, error: None,
suppressed: HashSet::new(), suppressed: HashSet::new(),
plugins: Host::default(),
} }
} }
@@ -814,6 +1176,7 @@ mod tests {
.title_bar(false) .title_bar(false)
.frame(egui::Frame::NONE); .frame(egui::Frame::NONE);
if moved == Some(id) { if moved == Some(id) {
mark_float_manual(ctx, window_id);
window = window.current_pos(egui::pos2(100.0, 100.0)); window = window.current_pos(egui::pos2(100.0, 100.0));
} }
let response = let response =
@@ -825,6 +1188,7 @@ mod tests {
); );
}) })
.unwrap(); .unwrap();
remember_float_interaction(ctx, window_id, &response.response);
positions.insert(id, response.response.rect.min); positions.insert(id, response.response.rect.min);
} }
}, },
@@ -833,12 +1197,18 @@ mod tests {
positions positions
}; };
render(&tab, Some(2), workspace); render(&tab, Some(2), workspace);
render(&tab, Some(5), workspace);
assert_eq!(render(&tab, None, workspace)[&2], egui::pos2(100.0, 100.0)); 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 resized = Rect::from_min_size(egui::Pos2::ZERO, vec2(1800.0, 1400.0));
let positions = render(&tab, None, resized); let positions = render(&tab, None, resized);
for (index, &id) in tab.floats.iter().enumerate() { 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, None, workspace);
render(&tab, Some(2), workspace); render(&tab, Some(2), workspace);
@@ -851,7 +1221,7 @@ mod tests {
let positions = render(&tab, None, workspace); let positions = render(&tab, None, workspace);
tab.active = tab.floats[0]; tab.active = tab.floats[0];
for index in 0..tab.floats.len() { 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) egui::pos2(100.0, 100.0)
} else { } else {
float_placement(workspace, index, &config).0 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] #[test]
fn floats_open_centered_then_cascade_and_stay_within_the_maximums() { fn floats_open_centered_then_cascade_and_stay_within_the_maximums() {
let config = config::Multiplexer::default(); let config = config::Multiplexer::default();
+23 -1
View File
@@ -34,6 +34,15 @@ pub struct Config {
pub kitty: Kitty, pub kitty: Kitty,
pub multiplexer: Multiplexer, pub multiplexer: Multiplexer,
pub keys: BTreeMap<String, Action>, 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)] #[derive(Clone, Debug, Deserialize)]
@@ -55,7 +64,7 @@ pub struct Multiplexer {
pub floating_max_height: f32, 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")] #[serde(rename_all = "snake_case")]
pub enum Action { pub enum Action {
SplitRight, SplitRight,
@@ -127,6 +136,7 @@ impl Default for Config {
colors: BTreeMap::new(), colors: BTreeMap::new(),
kitty: Kitty::default(), kitty: Kitty::default(),
multiplexer: Multiplexer::default(), multiplexer: Multiplexer::default(),
plugins: Vec::new(),
keys: [ keys: [
("Alt+F", Action::ToggleFloats), ("Alt+F", Action::ToggleFloats),
("Alt+M", Action::NewPane), ("Alt+M", Action::NewPane),
@@ -174,6 +184,18 @@ impl Config {
pub fn parse(text: &str) -> Result<Self> { pub fn parse(text: &str) -> Result<Self> {
let config: Self = toml::from_str(text)?; 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!( ensure!(
config.font_path.is_none() || config.font_family.is_none(), config.font_path.is_none() || config.font_family.is_none(),
"set either font_family or font_path, not both" "set either font_family or font_path, not both"
+10 -1
View File
@@ -4,6 +4,8 @@ mod fonts;
mod input; mod input;
mod layout; mod layout;
mod pane; mod pane;
mod plugin_api;
mod plugins;
mod pty; mod pty;
mod render; mod render;
#[cfg(test)] #[cfg(test)]
@@ -29,13 +31,20 @@ fn main() -> Result<()> {
} }
return Ok(()); 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" => { "--version" => {
println!("slerm {}", env!("CARGO_PKG_VERSION")); println!("slerm {}", env!("CARGO_PKG_VERSION"));
return Ok(()); return Ok(());
} }
"--help" | "-h" => { "--help" | "-h" => {
println!( 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() path.display()
); );
return Ok(()); return Ok(());
+163
View File
@@ -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
View File
@@ -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}"
);
}
}
}