diff --git a/.github/actions/install-flatc/action.yml b/.github/actions/install-flatc/action.yml new file mode 100644 index 0000000..6555bff --- /dev/null +++ b/.github/actions/install-flatc/action.yml @@ -0,0 +1,46 @@ +name: Install flatc +description: Download and install a flatc binary for CI + +inputs: + version: + description: FlatBuffers release version to install + required: true + sha256: + description: SHA-256 of the expected flatc archive + required: true + +runs: + using: composite + steps: + - name: Download flatc + shell: bash + run: | + set -euo pipefail + + mkdir -p flatbuffers-bin + asset="" + candidates=( + "Linux.flatc.binary.g++-13.zip" + "Linux.flatc.binary.clang++-18.zip" + ) + + for candidate in "${candidates[@]}"; do + if curl --retry 5 --retry-delay 2 --retry-connrefused --connect-timeout 10 --max-time 120 -fsSL -o flatbuffers-bin/flatc.zip "https://github.com/google/flatbuffers/releases/download/v${{ inputs.version }}/${candidate}"; then + actual_sha256="$(sha256sum flatbuffers-bin/flatc.zip | awk '{print $1}')" + if [[ "${actual_sha256}" == "${{ inputs.sha256 }}" ]]; then + asset="${candidate}" + break + fi + rm -f flatbuffers-bin/flatc.zip + fi + done + + if [[ -z "${asset}" ]]; then + echo "::error::failed to download flatc v${{ inputs.version }}; attempted assets: ${candidates[*]}" + exit 1 + fi + + unzip -q flatbuffers-bin/flatc.zip -d flatbuffers-bin + chmod +x flatbuffers-bin/flatc + sudo install flatbuffers-bin/flatc /usr/local/bin/flatc + rm -rf flatbuffers-bin diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49b057c..4217a8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,15 +4,19 @@ on: push: pull_request: +env: + FLATBUFFERS_VERSION: 25.12.19 + FLATBUFFERS_SHA256: 9f87066dc5dfa7fe02090b55bab5f3e55df03e32c9b0cdf229004ade7d091039 + jobs: fmt-lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Install flatbuffers compiler - run: | - sudo apt-get update - sudo apt-get install -y flatbuffers-compiler + - uses: ./.github/actions/install-flatc + with: + version: ${{ env.FLATBUFFERS_VERSION }} + sha256: ${{ env.FLATBUFFERS_SHA256 }} - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy @@ -26,10 +30,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Install flatbuffers compiler - run: | - sudo apt-get update - sudo apt-get install -y flatbuffers-compiler + - uses: ./.github/actions/install-flatc + with: + version: ${{ env.FLATBUFFERS_VERSION }} + sha256: ${{ env.FLATBUFFERS_SHA256 }} - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: Run unit and integration tests @@ -39,11 +43,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Install flatbuffers compiler - run: | - sudo apt-get update - sudo apt-get install -y flatbuffers-compiler + - uses: ./.github/actions/install-flatc + with: + version: ${{ env.FLATBUFFERS_VERSION }} + sha256: ${{ env.FLATBUFFERS_SHA256 }} - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: Run ignored PTY smoke test - run: cargo test -p mux-test-support pty_round_trips_input -- --ignored + run: cargo test -p embers-test-support pty_round_trips_input -- --ignored diff --git a/Cargo.lock b/Cargo.lock index a54cac6..9000285 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,20 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +25,37 @@ dependencies = [ "memchr", ] +[[package]] +name = "alacritty_terminal" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46319972e74179d707445f64aaa2893bbf6a111de3a9af29b7eb382f8b39e282" +dependencies = [ + "base64", + "bitflags 2.11.0", + "home", + "libc", + "log", + "miow", + "parking_lot", + "piper", + "polling", + "regex-automata", + "rustix", + "rustix-openpty", + "serde", + "signal-hook", + "unicode-width", + "vte", + "windows-sys 0.59.0", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "1.0.0" @@ -47,7 +92,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,7 +103,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -67,6 +112,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "assert_cmd" version = "2.2.0" @@ -93,12 +144,39 @@ dependencies = [ "syn", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -110,6 +188,9 @@ name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] [[package]] name = "bstr" @@ -122,12 +203,24 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cfg-if" version = "1.0.4" @@ -140,6 +233,33 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" version = "4.6.0" @@ -186,18 +306,229 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "criterion" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c047a62b0cc3e145fa84415a3191f628e980b194c2755aa12300a4e6cbd928" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools", + "num-traits", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b1bcc0dc7dfae599d84ad0b1a55f80cde8af3725da8313b528da95ef783e338" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "downcast-rs" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embers-cli" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "clap", + "embers-client", + "embers-core", + "embers-protocol", + "embers-server", + "embers-test-support", + "libc", + "predicates", + "tempfile", + "tokio", + "tracing", + "unicode-width", +] + +[[package]] +name = "embers-client" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64", + "criterion", + "directories", + "embers-core", + "embers-protocol", + "embers-test-support", + "rhai", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "embers-core" +version = "0.1.0" +dependencies = [ + "thiserror 2.0.18", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "embers-protocol" +version = "0.1.0" +dependencies = [ + "embers-core", + "flatbuffers", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "embers-server" +version = "0.1.0" +dependencies = [ + "alacritty_terminal", + "embers-core", + "embers-protocol", + "portable-pty", + "proptest", + "tempfile", + "tokio", + "tracing", +] + +[[package]] +name = "embers-test-support" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "embers-core", + "embers-protocol", + "embers-server", + "portable-pty", + "tempfile", + "tokio", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -211,7 +542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -233,11 +564,11 @@ dependencies = [ [[package]] name = "flatbuffers" -version = "24.12.23" +version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f1baf0dbf96932ec9a3038d57900329c015b0bfb7b63d904f3bc27e2b02a096" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.11.0", "rustc_version", ] @@ -250,12 +581,47 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -264,11 +630,22 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -290,6 +667,21 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -314,12 +706,31 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -338,12 +749,30 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -373,72 +802,16 @@ checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", - "windows-sys", -] - -[[package]] -name = "mux-cli" -version = "0.1.0" -dependencies = [ - "clap", - "mux-core", - "mux-protocol", - "mux-test-support", - "predicates", - "tokio", + "windows-sys 0.61.2", ] [[package]] -name = "mux-client" -version = "0.1.0" -dependencies = [ - "async-trait", - "mux-core", - "mux-protocol", - "tokio", -] - -[[package]] -name = "mux-core" -version = "0.1.0" -dependencies = [ - "thiserror 2.0.18", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "mux-protocol" -version = "0.1.0" -dependencies = [ - "flatbuffers", - "mux-core", - "thiserror 2.0.18", - "tokio", -] - -[[package]] -name = "mux-server" -version = "0.1.0" -dependencies = [ - "mux-core", - "mux-protocol", - "tempfile", - "tokio", - "tracing", -] - -[[package]] -name = "mux-test-support" -version = "0.1.0" +name = "miow" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "assert_cmd", - "mux-core", - "mux-protocol", - "mux-server", - "portable-pty", - "tempfile", - "tokio", + "windows-sys 0.61.2", ] [[package]] @@ -465,7 +838,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -482,6 +855,9 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -489,12 +865,106 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "portable-pty" version = "0.9.0" @@ -516,6 +986,15 @@ dependencies = [ "winreg", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "predicates" version = "3.1.4" @@ -565,6 +1044,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.11.0", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.45" @@ -574,12 +1078,96 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.12.3" @@ -609,6 +1197,34 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rhai" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f9ef5dabe4c0b43d8f1187dc6beb67b53fe607fff7e30c5eb7f71b814b8c2c1" +dependencies = [ + "ahash", + "bitflags 2.11.0", + "num-traits", + "once_cell", + "rhai_codegen", + "smallvec", + "smartstring", + "thin-vec", + "web-time", +] + +[[package]] +name = "rhai_codegen" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4322a2a4e8cf30771dd9f27f7f37ca9ac8fe812dddd811096a98483080dabe6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -628,9 +1244,53 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-openpty" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393" +dependencies = [ + "errno", + "libc", + "rustix", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.27" @@ -644,6 +1304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -715,6 +1376,16 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -731,6 +1402,17 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + [[package]] name = "socket2" version = "0.6.3" @@ -738,9 +1420,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -765,10 +1453,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -777,6 +1465,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "thin-vec" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144f754d318415ac792f9d69fc87abbbfc043ce2ef041c60f16ad828f638717d" + [[package]] name = "thiserror" version = "1.0.69" @@ -826,6 +1520,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tokio" version = "1.50.0" @@ -839,7 +1552,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -914,12 +1627,30 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -938,6 +1669,26 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "bitflags 2.11.0", + "cursor-icon", + "log", + "memchr", + "serde", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -947,6 +1698,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -971,6 +1732,51 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -1005,6 +1811,26 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1021,6 +1847,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -1033,6 +1868,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1042,6 +1886,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winreg" version = "0.10.1" @@ -1139,6 +2047,26 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 273d3dd..e3c1662 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [workspace] members = [ - "crates/mux-core", - "crates/mux-protocol", - "crates/mux-server", - "crates/mux-client", - "crates/mux-cli", - "crates/mux-test-support", + "crates/embers-core", + "crates/embers-protocol", + "crates/embers-server", + "crates/embers-client", + "crates/embers-cli", + "crates/embers-test-support", ] resolver = "2" @@ -18,12 +18,20 @@ version = "0.1.0" [workspace.dependencies] async-trait = "0.1" assert_cmd = "2" +base64 = "0.22" clap = { version = "4.5", features = ["derive"] } -flatbuffers = "24.3.25" +criterion = "0.7" +directories = "6" +flatbuffers = "=25.12.19" +libc = "0.2" portable-pty = "0.9" predicates = "3" +proptest = "1" +rhai = "1" tempfile = "3" thiserror = "2" -tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "sync", "time"] } +tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +unicode-segmentation = "1.12" +unicode-width = "0.2" diff --git a/crates/embers-cli/Cargo.toml b/crates/embers-cli/Cargo.toml new file mode 100644 index 0000000..d165813 --- /dev/null +++ b/crates/embers-cli/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "embers-cli" +autobins = false +edition.workspace = true +license.workspace = true +rust-version.workspace = true +version.workspace = true + +# `embers` is the main interactive entry point; `embers-cli` is the lightweight +# command-focused binary kept for explicit CLI/tooling workflows. +[[bin]] +name = "embers" +path = "src/main.rs" + +[[bin]] +name = "embers-cli" +path = "src/bin/embers-cli.rs" + +[dependencies] +clap.workspace = true +embers-client = { path = "../embers-client" } +embers-core = { path = "../embers-core" } +embers-protocol = { path = "../embers-protocol" } +embers-server = { path = "../embers-server" } +libc.workspace = true +tokio.workspace = true +tracing.workspace = true +unicode-width.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +embers-test-support = { path = "../embers-test-support" } +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/embers-cli/src/bin/embers-cli.rs b/crates/embers-cli/src/bin/embers-cli.rs new file mode 100644 index 0000000..4e7dec3 --- /dev/null +++ b/crates/embers-cli/src/bin/embers-cli.rs @@ -0,0 +1,25 @@ +use clap::Parser; +use embers_cli::{Cli, run}; +use embers_core::init_tracing; + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + init_tracing(&cli.log_filter()); + + if let Err(error) = run(cli).await { + eprintln!("{}", format_error_chain(&error)); + std::process::exit(1); + } +} + +fn format_error_chain(error: &dyn std::error::Error) -> String { + let mut rendered = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + rendered.push_str("\ncaused by: "); + rendered.push_str(&cause.to_string()); + source = cause.source(); + } + rendered +} diff --git a/crates/embers-cli/src/interactive.rs b/crates/embers-cli/src/interactive.rs new file mode 100644 index 0000000..67253f9 --- /dev/null +++ b/crates/embers-cli/src/interactive.rs @@ -0,0 +1,984 @@ +use std::io::{self, Write}; +use std::os::fd::AsRawFd; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +use embers_client::{ + ConfigManager, ConfiguredClient, KeyEvent, MouseButton, MouseEvent, MouseEventKind, + MouseModifiers, MuxClient, RenderGrid, SocketTransport, +}; +use embers_core::{CursorShape, MuxError, Result, SessionId, Size}; +use embers_protocol::{BufferRequest, ClientMessage, ServerResponse, SessionRequest}; +use tokio::sync::mpsc; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +const DEFAULT_SESSION_NAME: &str = "main"; +const KEY_SEQUENCE_TIMEOUT: Duration = Duration::from_millis(15); +const KEY_SEQUENCE_CONTINUATION_TIMEOUT: Duration = Duration::from_millis(2); +const EVENT_POLL_INTERVAL: Duration = Duration::from_millis(20); +const BRACKETED_PASTE_END: &[u8] = b"\x1b[201~"; +const TERMINAL_ENTER_BASE_SEQUENCE: &str = + "\x1b[?1049h\x1b[?1004h\x1b[?2004h\x1b[?25l\x1b[2J\x1b[H"; +const TERMINAL_ENABLE_MOUSE_SEQUENCE: &str = "\x1b[?1002h\x1b[?1006h"; +const TERMINAL_DISABLE_MOUSE_SEQUENCE: &str = "\x1b[?1006l\x1b[?1002l"; + +pub async fn run( + socket_path: PathBuf, + target: Option, + config_path: Option, +) -> Result<()> { + let mut client = MuxClient::connect(&socket_path).await?; + client.subscribe(None).await?; + let requested_target = target; + let mut session_id = ensure_session_ready(&mut client, requested_target.as_deref()).await?; + let config = ConfigManager::from_process(config_path) + .map_err(|error| MuxError::invalid_input(error.to_string()))?; + let mut configured = ConfiguredClient::new(client, config); + + let mut terminal = TerminalGuard::enter(mouse_capture_enabled(&configured))?; + let (input_tx, mut input_rx) = mpsc::unbounded_channel(); + let _input_thread = spawn_input_thread(input_tx)?; + + let mut terminal_size = terminal.size()?; + let mut dirty = true; + loop { + if dirty { + terminal.sync_mouse_capture(mouse_capture_enabled(&configured))?; + if !configured + .client() + .state() + .sessions + .contains_key(&session_id) + { + session_id = + ensure_session_ready(configured.client_mut(), requested_target.as_deref()) + .await?; + } + terminal_size = terminal.size()?; + let viewport = content_viewport(terminal_size); + let grid = configured.render_session(session_id, viewport).await?; + terminal.write_bytes(&drain_terminal_output(&mut configured))?; + let status = configured.status_line(session_id, &socket_path); + terminal.render(&grid, terminal_size, Some(&status))?; + dirty = false; + } + + loop { + match input_rx.try_recv() { + Ok(TerminalEvent::Key(KeyEvent::Ctrl('q'))) => return Ok(()), + Ok(TerminalEvent::Key(key)) => { + let viewport = content_viewport(terminal_size); + configured.handle_key(session_id, viewport, key).await?; + terminal.write_bytes(&drain_terminal_output(&mut configured))?; + dirty = true; + } + Ok(TerminalEvent::Paste(bytes)) => { + let viewport = content_viewport(terminal_size); + configured.handle_paste(session_id, viewport, bytes).await?; + terminal.write_bytes(&drain_terminal_output(&mut configured))?; + dirty = true; + } + Ok(TerminalEvent::Focus(focused)) => { + let viewport = content_viewport(terminal_size); + configured + .handle_focus_event(session_id, viewport, focused) + .await?; + terminal.write_bytes(&drain_terminal_output(&mut configured))?; + dirty = true; + } + Ok(TerminalEvent::Mouse(mouse)) => { + let viewport = content_viewport(terminal_size); + configured.handle_mouse(session_id, viewport, mouse).await?; + terminal.write_bytes(&drain_terminal_output(&mut configured))?; + dirty = true; + } + Ok(TerminalEvent::InputClosed) => return Ok(()), + Ok(TerminalEvent::InputError(message)) => { + return Err(MuxError::transport(message)); + } + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => return Ok(()), + } + } + + let next_size = terminal.size()?; + if next_size != terminal_size { + terminal_size = next_size; + dirty = true; + continue; + } + + match tokio::time::timeout(EVENT_POLL_INTERVAL, configured.process_next_event()).await { + Ok(result) => { + result?; + terminal.write_bytes(&drain_terminal_output(&mut configured))?; + dirty = true; + } + Err(_) => { + continue; + } + } + } +} + +async fn ensure_session_ready( + client: &mut MuxClient, + target: Option<&str>, +) -> Result { + client.resync_all_sessions().await?; + let session_id = match select_session_id(client, target)? { + Some(session_id) => session_id, + None => create_session(client, target.unwrap_or(DEFAULT_SESSION_NAME)).await?, + }; + ensure_root_window(client, session_id).await?; + client.resync_session(session_id).await?; + Ok(session_id) +} + +fn select_session_id( + client: &MuxClient, + target: Option<&str>, +) -> Result> { + if client.state().sessions.is_empty() { + return Ok(None); + } + + if let Some(target) = target { + return client + .state() + .sessions + .values() + .find(|session| session.name == target) + .map(|session| Some(session.id)) + .ok_or_else(|| MuxError::not_found(format!("session '{target}' was not found"))); + } + + Ok(client + .state() + .sessions + .values() + .max_by_key(|session| session.id.0) + .map(|session| session.id)) +} + +async fn create_session(client: &mut MuxClient, name: &str) -> Result { + let response = client + .request_message(ClientMessage::Session(SessionRequest::Create { + request_id: client.next_request_id(), + name: name.to_owned(), + })) + .await?; + match response { + ServerResponse::SessionSnapshot(response) => { + let session_id = response.snapshot.session.id; + client.state_mut().apply_session_snapshot(response.snapshot); + Ok(session_id) + } + other => Err(MuxError::protocol(format!( + "expected session snapshot response, got {other:?}" + ))), + } +} + +async fn ensure_root_window( + client: &mut MuxClient, + session_id: SessionId, +) -> Result<()> { + client.resync_session(session_id).await?; + if session_has_root_window(client, session_id)? { + return Ok(()); + } + + let command = default_shell_command(); + let title = default_title(&command, "shell"); + let buffer_id = create_buffer(client, &command, &title).await?; + let response = client + .request_message(ClientMessage::Session(SessionRequest::AddRootTab { + request_id: client.next_request_id(), + session_id, + title, + buffer_id: Some(buffer_id), + child_node_id: None, + })) + .await?; + match response { + ServerResponse::SessionSnapshot(response) => { + client.state_mut().apply_session_snapshot(response.snapshot); + Ok(()) + } + other => Err(MuxError::protocol(format!( + "expected session snapshot response, got {other:?}" + ))), + } +} + +fn session_has_root_window( + client: &MuxClient, + session_id: SessionId, +) -> Result { + let session = client + .state() + .sessions + .get(&session_id) + .ok_or_else(|| MuxError::not_found(format!("session {session_id} is not cached")))?; + let root = client + .state() + .nodes + .get(&session.root_node_id) + .ok_or_else(|| { + MuxError::not_found(format!("node {} is not cached", session.root_node_id)) + })?; + let tabs = root.tabs.as_ref(); + Ok(tabs.is_none_or(|tabs| !tabs.tabs.is_empty())) +} + +async fn create_buffer( + client: &mut MuxClient, + command: &[String], + title: &str, +) -> Result { + let response = client + .request_message(ClientMessage::Buffer(BufferRequest::Create { + request_id: client.next_request_id(), + title: Some(title.to_owned()), + command: command.to_vec(), + cwd: None, + env: Default::default(), + })) + .await?; + match response { + ServerResponse::Buffer(response) => Ok(response.buffer.id), + other => Err(MuxError::protocol(format!( + "expected buffer response, got {other:?}" + ))), + } +} + +fn default_shell_command() -> Vec { + vec![std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned())] +} + +fn default_title(command: &[String], fallback: &str) -> String { + command + .first() + .and_then(|value| { + Path::new(value) + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_owned) + }) + .unwrap_or_else(|| fallback.to_owned()) +} + +fn content_viewport(size: Size) -> Size { + if size.height > 1 { + Size { + width: size.width, + height: size.height - 1, + } + } else { + size + } +} + +fn drain_terminal_output(configured: &mut ConfiguredClient) -> Vec { + configured + .drain_terminal_output() + .into_iter() + .flatten() + .collect() +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum TerminalEvent { + Key(KeyEvent), + Mouse(MouseEvent), + Paste(Vec), + Focus(bool), + InputClosed, + InputError(String), +} + +fn spawn_input_thread( + tx: mpsc::UnboundedSender, +) -> Result> { + thread::Builder::new() + .name("embers-input".to_owned()) + .spawn(move || { + let stdin = io::stdin(); + // Keep the stdin lock alive for the full read loop so the raw fd stays valid. + let stdin_lock = stdin.lock(); + let fd = stdin_lock.as_raw_fd(); + let _stdin_lock = stdin_lock; + loop { + match read_terminal_event(fd) { + Ok(Some(event)) => { + if tx.send(event).is_err() { + break; + } + } + Ok(None) => { + let _ = tx.send(TerminalEvent::InputClosed); + break; + } + Err(error) => { + let _ = tx.send(TerminalEvent::InputError(error.to_string())); + break; + } + } + } + }) + .map_err(|error| MuxError::internal(format!("failed to spawn input thread: {error}"))) +} + +fn read_terminal_event(fd: libc::c_int) -> Result> { + let Some(first) = read_byte(fd)? else { + return Ok(None); + }; + let event = match first { + b'\r' | b'\n' => TerminalEvent::Key(KeyEvent::Enter), + b'\t' => TerminalEvent::Key(KeyEvent::Tab), + 0x7f | 0x08 => TerminalEvent::Key(KeyEvent::Backspace), + 0x1b => read_escape_event(fd)?, + 0x00 | 0x01..=0x1a | 0x1c..=0x1f => TerminalEvent::Key(KeyEvent::Ctrl( + char::from(first | 0x40).to_ascii_lowercase(), + )), + 0x20..=0x7e => TerminalEvent::Key(KeyEvent::Char(char::from(first))), + other => TerminalEvent::Key(decode_utf8_key(fd, other)?), + }; + Ok(Some(event)) +} + +fn read_escape_event(fd: libc::c_int) -> Result { + let Some(next) = read_optional_byte(fd, KEY_SEQUENCE_TIMEOUT)? else { + return Ok(TerminalEvent::Key(KeyEvent::Escape)); + }; + + match next { + b'[' => read_csi_event(fd), + b'O' => read_ss3_event(fd), + byte if byte.is_ascii() => Ok(TerminalEvent::Key(KeyEvent::Alt(char::from(byte)))), + other => { + let mut bytes = vec![0x1b, other]; + while let Some(extra) = read_optional_byte(fd, KEY_SEQUENCE_CONTINUATION_TIMEOUT)? { + bytes.push(extra); + } + Ok(TerminalEvent::Key(KeyEvent::Bytes(bytes))) + } + } +} + +fn read_csi_event(fd: libc::c_int) -> Result { + let bytes = read_control_sequence(fd, b'[')?; + if bytes == b"\x1b[200~" { + return Ok(TerminalEvent::Paste(read_bracketed_paste(fd)?)); + } + Ok(parse_csi_event(&bytes).unwrap_or(TerminalEvent::Key(KeyEvent::Bytes(bytes)))) +} + +fn read_ss3_event(fd: libc::c_int) -> Result { + let mut bytes = vec![0x1b, b'O']; + let Some(final_byte) = read_optional_byte(fd, KEY_SEQUENCE_CONTINUATION_TIMEOUT)? else { + return Ok(TerminalEvent::Key(KeyEvent::Bytes(bytes))); + }; + bytes.push(final_byte); + let key = match final_byte { + b'A' => Some(KeyEvent::Up), + b'B' => Some(KeyEvent::Down), + b'C' => Some(KeyEvent::Right), + b'D' => Some(KeyEvent::Left), + _ => None, + }; + Ok(match key { + Some(key) => TerminalEvent::Key(key), + None => TerminalEvent::Key(KeyEvent::Bytes(bytes)), + }) +} + +fn read_control_sequence(fd: libc::c_int, introducer: u8) -> Result> { + let mut bytes = vec![0x1b, introducer]; + while let Some(next) = read_optional_byte(fd, KEY_SEQUENCE_CONTINUATION_TIMEOUT)? { + bytes.push(next); + if is_csi_final_byte(next) { + break; + } + } + Ok(bytes) +} + +fn is_csi_final_byte(byte: u8) -> bool { + (0x40..=0x7e).contains(&byte) +} + +fn parse_csi_event(bytes: &[u8]) -> Option { + match bytes { + b"\x1b[A" => Some(TerminalEvent::Key(KeyEvent::Up)), + b"\x1b[B" => Some(TerminalEvent::Key(KeyEvent::Down)), + b"\x1b[C" => Some(TerminalEvent::Key(KeyEvent::Right)), + b"\x1b[D" => Some(TerminalEvent::Key(KeyEvent::Left)), + b"\x1b[1~" | b"\x1b[H" => Some(TerminalEvent::Key(KeyEvent::Home)), + b"\x1b[2~" => Some(TerminalEvent::Key(KeyEvent::Insert)), + b"\x1b[3~" => Some(TerminalEvent::Key(KeyEvent::Delete)), + b"\x1b[4~" | b"\x1b[F" => Some(TerminalEvent::Key(KeyEvent::End)), + b"\x1b[5~" => Some(TerminalEvent::Key(KeyEvent::PageUp)), + b"\x1b[6~" => Some(TerminalEvent::Key(KeyEvent::PageDown)), + b"\x1b[I" => Some(TerminalEvent::Focus(true)), + b"\x1b[O" => Some(TerminalEvent::Focus(false)), + _ => parse_sgr_mouse(bytes).map(TerminalEvent::Mouse), + } +} + +fn parse_sgr_mouse(bytes: &[u8]) -> Option { + let text = std::str::from_utf8(bytes).ok()?; + let body = text.strip_prefix("\x1b[<")?; + let (payload, suffix) = body.split_at(body.len().checked_sub(1)?); + if suffix != "M" && suffix != "m" { + return None; + } + + let mut parts = payload.split(';'); + let code = parts.next()?.parse::().ok()?; + let column = parts.next()?.parse::().ok()?.saturating_sub(1); + let row = parts.next()?.parse::().ok()?.saturating_sub(1); + if parts.next().is_some() { + return None; + } + + let modifiers = MouseModifiers { + shift: (code & 0b00100) != 0, + alt: (code & 0b01000) != 0, + ctrl: (code & 0b10000) != 0, + }; + let button_code = code & 0b11; + let kind = if (code & 0b1_000000) != 0 { + match button_code { + 0 => MouseEventKind::WheelUp, + 1 => MouseEventKind::WheelDown, + _ => return None, + } + } else if (code & 0b100000) != 0 { + MouseEventKind::Drag(mouse_button(button_code)?) + } else if suffix == "m" { + MouseEventKind::Release(mouse_button(button_code)) + } else { + MouseEventKind::Press(mouse_button(button_code)?) + }; + + Some(MouseEvent { + row, + column, + modifiers, + kind, + }) +} + +fn mouse_button(code: u16) -> Option { + match code { + 0 => Some(MouseButton::Left), + 1 => Some(MouseButton::Middle), + 2 => Some(MouseButton::Right), + _ => None, + } +} + +fn read_bracketed_paste(fd: libc::c_int) -> Result> { + let mut bytes = Vec::new(); + loop { + let Some(next) = read_byte(fd)? else { + break; + }; + bytes.push(next); + if bytes.ends_with(BRACKETED_PASTE_END) { + let new_len = bytes.len() - BRACKETED_PASTE_END.len(); + bytes.truncate(new_len); + break; + } + } + Ok(bytes) +} + +fn decode_utf8_key(fd: libc::c_int, first: u8) -> Result { + let width = utf8_width(first); + if width <= 1 { + return Ok(KeyEvent::Bytes(vec![first])); + } + + let mut bytes = vec![first]; + for _ in 1..width { + let Some(next) = read_byte(fd)? else { + return Ok(KeyEvent::Bytes(bytes)); + }; + bytes.push(next); + } + + match std::str::from_utf8(&bytes) + .ok() + .and_then(|text| text.chars().next()) + { + Some(ch) => Ok(KeyEvent::Char(ch)), + None => Ok(KeyEvent::Bytes(bytes)), + } +} + +fn utf8_width(first: u8) -> usize { + match first { + 0x00..=0x7f => 1, + 0xc0..=0xdf => 2, + 0xe0..=0xef => 3, + 0xf0..=0xf7 => 4, + _ => 0, + } +} + +fn read_optional_byte(fd: libc::c_int, timeout: Duration) -> Result> { + if poll_fd(fd, timeout)? { + read_byte(fd) + } else { + Ok(None) + } +} + +fn poll_fd(fd: libc::c_int, timeout: Duration) -> Result { + let timeout_ms = timeout.as_millis().min(i32::MAX as u128) as i32; + let mut poll_fd = libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, + }; + loop { + // SAFETY: poll_fd points to a valid pollfd on the stack and we pass a valid count. + let result = unsafe { libc::poll(&mut poll_fd, 1, timeout_ms) }; + if result == 0 { + return Ok(false); + } + if result > 0 { + return Ok((poll_fd.revents & libc::POLLIN) != 0); + } + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(error.into()); + } +} + +fn read_byte(fd: libc::c_int) -> Result> { + let mut byte = 0_u8; + loop { + // SAFETY: we pass a valid fd and a writable pointer to a single-byte buffer. + let result = unsafe { libc::read(fd, (&mut byte as *mut u8).cast(), 1) }; + if result == 0 { + return Ok(None); + } + if result > 0 { + return Ok(Some(byte)); + } + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(error.into()); + } +} + +struct TerminalGuard { + input_fd: libc::c_int, + original_mode: libc::termios, + mouse_capture_enabled: bool, +} + +impl TerminalGuard { + fn enter(mouse_capture_enabled: bool) -> Result { + let input_fd = io::stdin().as_raw_fd(); + let output_fd = io::stdout().as_raw_fd(); + if !is_tty(input_fd) || !is_tty(output_fd) { + return Err(MuxError::invalid_input( + "interactive embers client requires a TTY on stdin/stdout", + )); + } + + let original_mode = terminal_mode(input_fd)?; + let mut raw_mode = original_mode; + raw_mode.c_iflag &= !(libc::BRKINT | libc::ICRNL | libc::INPCK | libc::ISTRIP | libc::IXON); + raw_mode.c_oflag &= !libc::OPOST; + raw_mode.c_cflag |= libc::CS8; + raw_mode.c_lflag &= !(libc::ECHO | libc::ICANON | libc::IEXTEN | libc::ISIG); + raw_mode.c_cc[libc::VMIN] = 1; + raw_mode.c_cc[libc::VTIME] = 0; + set_terminal_mode(input_fd, &raw_mode)?; + + let mut stdout = io::stdout(); + match write!(stdout, "{}", terminal_enter_sequence(mouse_capture_enabled)) + .and_then(|()| stdout.flush()) + { + Ok(()) => {} + Err(error) => { + let _ = set_terminal_mode(input_fd, &original_mode); + return Err(error.into()); + } + } + + Ok(Self { + input_fd, + original_mode, + mouse_capture_enabled, + }) + } + + fn size(&self) -> Result { + terminal_size(io::stdout().as_raw_fd()) + } + + fn write_bytes(&self, bytes: &[u8]) -> Result<()> { + if bytes.is_empty() { + return Ok(()); + } + let mut stdout = io::stdout(); + stdout.write_all(bytes)?; + stdout.flush()?; + Ok(()) + } + + fn sync_mouse_capture(&mut self, enabled: bool) -> Result<()> { + if self.mouse_capture_enabled == enabled { + return Ok(()); + } + let mut stdout = io::stdout(); + write!( + stdout, + "{}", + if enabled { + TERMINAL_ENABLE_MOUSE_SEQUENCE + } else { + TERMINAL_DISABLE_MOUSE_SEQUENCE + } + )?; + stdout.flush()?; + self.mouse_capture_enabled = enabled; + Ok(()) + } + + fn render(&self, grid: &RenderGrid, terminal_size: Size, status: Option<&str>) -> Result<()> { + let mut stdout = io::stdout(); + write!(stdout, "\x1b[H")?; + for line in grid.ansi_lines() { + write!(stdout, "{line}\x1b[K\r\n")?; + } + + if terminal_size.height > grid.height() { + let status = fit_width(status.unwrap_or_default(), terminal_size.width); + write!(stdout, "\x1b[7m{status}\x1b[0m\x1b[K")?; + } + + write!(stdout, "\x1b[J")?; + if let Some(cursor) = grid.cursor() { + write!( + stdout, + "\x1b[{} q\x1b[?25h\x1b[{};{}H", + cursor_shape_code(cursor.shape), + cursor.y.saturating_add(1), + cursor.x.saturating_add(1) + )?; + } else { + write!(stdout, "\x1b[?25l")?; + } + stdout.flush()?; + Ok(()) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = set_terminal_mode(self.input_fd, &self.original_mode); + let mut stdout = io::stdout(); + let _ = write!( + stdout, + "{}", + terminal_exit_sequence(self.mouse_capture_enabled) + ); + let _ = stdout.flush(); + } +} + +fn mouse_capture_enabled(configured: &ConfiguredClient) -> bool { + configured + .config() + .active_script() + .loaded_config() + .mouse + .capture_enabled() +} + +fn terminal_enter_sequence(mouse_capture_enabled: bool) -> String { + let mut sequence = TERMINAL_ENTER_BASE_SEQUENCE.to_owned(); + if mouse_capture_enabled { + sequence.push_str(TERMINAL_ENABLE_MOUSE_SEQUENCE); + } + sequence +} + +fn terminal_exit_sequence(mouse_capture_enabled: bool) -> String { + let mut sequence = String::from("\x1b[0m\x1b[2 q\x1b[?25h\x1b[?2004l"); + if mouse_capture_enabled { + sequence.push_str(TERMINAL_DISABLE_MOUSE_SEQUENCE); + } + sequence.push_str("\x1b[?1004l\x1b[?1049l"); + sequence +} + +fn is_tty(fd: libc::c_int) -> bool { + // SAFETY: isatty only inspects the fd and has no additional invariants. + unsafe { libc::isatty(fd) == 1 } +} + +fn terminal_mode(fd: libc::c_int) -> Result { + // SAFETY: termios is a plain old data struct and zero initialization is valid. + let mut mode = unsafe { std::mem::zeroed::() }; + // SAFETY: tcgetattr writes to the provided termios pointer when fd is valid. + if unsafe { libc::tcgetattr(fd, &mut mode) } == -1 { + return Err(io::Error::last_os_error().into()); + } + Ok(mode) +} + +fn set_terminal_mode(fd: libc::c_int, mode: &libc::termios) -> Result<()> { + // SAFETY: tcsetattr reads the provided termios pointer and applies it to the valid fd. + if unsafe { libc::tcsetattr(fd, libc::TCSAFLUSH, mode) } == -1 { + return Err(io::Error::last_os_error().into()); + } + Ok(()) +} + +fn terminal_size(fd: libc::c_int) -> Result { + // SAFETY: winsize is POD and zero initialization is valid. + let mut winsize = unsafe { std::mem::zeroed::() }; + // SAFETY: ioctl writes to winsize when fd references a terminal. + if unsafe { libc::ioctl(fd, libc::TIOCGWINSZ, &mut winsize) } == -1 { + return Err(io::Error::last_os_error().into()); + } + + let width = if winsize.ws_col == 0 { + 80 + } else { + winsize.ws_col + }; + let height = if winsize.ws_row == 0 { + 24 + } else { + winsize.ws_row + }; + Ok(Size { width, height }) +} + +fn fit_width(text: &str, width: u16) -> String { + if width == 0 { + return String::new(); + } + + let width = usize::from(width); + let mut fitted = String::new(); + let mut used = 0; + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(1); + if used + ch_width > width { + break; + } + fitted.push(ch); + used += ch_width; + } + let current = UnicodeWidthStr::width(fitted.as_str()); + if current < width { + fitted.push_str(&" ".repeat(width - current)); + } + fitted +} + +fn cursor_shape_code(shape: CursorShape) -> u8 { + match shape { + CursorShape::Block => 2, + CursorShape::Underline => 4, + CursorShape::Beam => 6, + } +} + +#[cfg(test)] +mod tests { + use super::{ + TERMINAL_DISABLE_MOUSE_SEQUENCE, TERMINAL_ENABLE_MOUSE_SEQUENCE, TerminalEvent, + read_terminal_event, terminal_enter_sequence, terminal_exit_sequence, + }; + use embers_client::{KeyEvent, MouseButton, MouseEvent, MouseEventKind, MouseModifiers}; + + fn with_pipe(bytes: &[u8], test: impl FnOnce(libc::c_int) -> T) -> T { + let mut fds = [0; 2]; + // SAFETY: pipe writes two valid file descriptors into the provided array. + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0); + let read_fd = fds[0]; + let write_fd = fds[1]; + // SAFETY: write_fd is valid and bytes points to a readable buffer of the requested size. + let written = unsafe { libc::write(write_fd, bytes.as_ptr().cast(), bytes.len()) }; + assert_eq!(written, bytes.len() as isize); + // SAFETY: write_fd/read_fd are valid file descriptors from pipe. + unsafe { + libc::close(write_fd); + } + let result = test(read_fd); + // SAFETY: read_fd is valid until explicitly closed here. + unsafe { + libc::close(read_fd); + } + result + } + + #[test] + fn parses_page_up_and_page_down_keys() { + with_pipe(b"\x1b[1~\x1b[2~\x1b[3~\x1b[4~\x1b[5~\x1b[6~", |fd| { + assert_eq!( + read_terminal_event(fd).unwrap(), + Some(TerminalEvent::Key(KeyEvent::Home)) + ); + assert_eq!( + read_terminal_event(fd).unwrap(), + Some(TerminalEvent::Key(KeyEvent::Insert)) + ); + assert_eq!( + read_terminal_event(fd).unwrap(), + Some(TerminalEvent::Key(KeyEvent::Delete)) + ); + assert_eq!( + read_terminal_event(fd).unwrap(), + Some(TerminalEvent::Key(KeyEvent::End)) + ); + assert_eq!( + read_terminal_event(fd).unwrap(), + Some(TerminalEvent::Key(KeyEvent::PageUp)) + ); + assert_eq!( + read_terminal_event(fd).unwrap(), + Some(TerminalEvent::Key(KeyEvent::PageDown)) + ); + }); + } + + #[test] + fn parses_extended_ctrl_chords() { + with_pipe(&[0x00, 0x1c, 0x1d, 0x1e, 0x1f], |fd| { + assert_eq!( + read_terminal_event(fd).unwrap(), + Some(TerminalEvent::Key(KeyEvent::Ctrl('@'))) + ); + assert_eq!( + read_terminal_event(fd).unwrap(), + Some(TerminalEvent::Key(KeyEvent::Ctrl('\\'))) + ); + assert_eq!( + read_terminal_event(fd).unwrap(), + Some(TerminalEvent::Key(KeyEvent::Ctrl(']'))) + ); + assert_eq!( + read_terminal_event(fd).unwrap(), + Some(TerminalEvent::Key(KeyEvent::Ctrl('^'))) + ); + assert_eq!( + read_terminal_event(fd).unwrap(), + Some(TerminalEvent::Key(KeyEvent::Ctrl('_'))) + ); + }); + } + + #[test] + fn parses_arrow_and_focus_events() { + with_pipe(b"\x1b[A\x1b[I\x1b[O", |fd| { + assert_eq!( + read_terminal_event(fd).unwrap(), + Some(TerminalEvent::Key(KeyEvent::Up)) + ); + assert_eq!( + read_terminal_event(fd).unwrap(), + Some(TerminalEvent::Focus(true)) + ); + assert_eq!( + read_terminal_event(fd).unwrap(), + Some(TerminalEvent::Focus(false)) + ); + }); + } + + #[test] + fn parses_sgr_mouse_events() { + with_pipe( + b"\x1b[<0;12;7M\x1b[<64;3;5M\x1b[<32;10;4M\x1b[<3;10;4m", + |fd| { + assert_eq!( + read_terminal_event(fd).unwrap(), + Some(TerminalEvent::Mouse(MouseEvent { + row: 6, + column: 11, + modifiers: MouseModifiers::default(), + kind: MouseEventKind::Press(MouseButton::Left), + })) + ); + assert_eq!( + read_terminal_event(fd).unwrap(), + Some(TerminalEvent::Mouse(MouseEvent { + row: 4, + column: 2, + modifiers: MouseModifiers::default(), + kind: MouseEventKind::WheelUp, + })) + ); + assert_eq!( + read_terminal_event(fd).unwrap(), + Some(TerminalEvent::Mouse(MouseEvent { + row: 3, + column: 9, + modifiers: MouseModifiers::default(), + kind: MouseEventKind::Drag(MouseButton::Left), + })) + ); + assert_eq!( + read_terminal_event(fd).unwrap(), + Some(TerminalEvent::Mouse(MouseEvent { + row: 3, + column: 9, + modifiers: MouseModifiers::default(), + kind: MouseEventKind::Release(None), + })) + ); + }, + ); + } + + #[test] + fn parses_bracketed_paste_payloads() { + with_pipe(b"\x1b[200~hello\nworld\x1b[201~", |fd| { + assert_eq!( + read_terminal_event(fd).unwrap(), + Some(TerminalEvent::Paste(b"hello\nworld".to_vec())) + ); + }); + } + + #[test] + fn terminal_guard_sequences_toggle_mouse_capture_with_config() { + let with_mouse_enter = terminal_enter_sequence(true); + let without_mouse_enter = terminal_enter_sequence(false); + let with_mouse_exit = terminal_exit_sequence(true); + let without_mouse_exit = terminal_exit_sequence(false); + + assert!(with_mouse_enter.contains("\x1b[?1004h")); + assert!(with_mouse_enter.contains("\x1b[?2004h")); + assert!(with_mouse_enter.contains(TERMINAL_ENABLE_MOUSE_SEQUENCE)); + assert!(with_mouse_exit.contains("\x1b[?1004l")); + assert!(with_mouse_exit.contains("\x1b[?2004l")); + assert!(with_mouse_exit.contains(TERMINAL_DISABLE_MOUSE_SEQUENCE)); + + assert!(without_mouse_enter.contains("\x1b[?1004h")); + assert!(without_mouse_enter.contains("\x1b[?2004h")); + assert!(!without_mouse_enter.contains(TERMINAL_ENABLE_MOUSE_SEQUENCE)); + assert!(without_mouse_exit.contains("\x1b[?1004l")); + assert!(without_mouse_exit.contains("\x1b[?2004l")); + assert!(!without_mouse_exit.contains(TERMINAL_DISABLE_MOUSE_SEQUENCE)); + } +} diff --git a/crates/embers-cli/src/lib.rs b/crates/embers-cli/src/lib.rs new file mode 100644 index 0000000..49fa198 --- /dev/null +++ b/crates/embers-cli/src/lib.rs @@ -0,0 +1,1467 @@ +mod interactive; + +use std::fs::{self, OpenOptions}; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::process::{Command as ProcessCommand, Stdio}; + +use clap::{Parser, Subcommand}; +use embers_core::{ + BufferId, FloatGeometry, FloatingId, MuxError, NodeId, Result, SessionId, SplitDirection, + new_request_id, +}; +use embers_protocol::{ + BufferRequest, BufferResponse, ClientMessage, FloatingRecord, FloatingRequest, + FloatingResponse, NodeRequest, PingRequest, ProtocolClient, ServerResponse, SessionRecord, + SessionRequest, SessionSnapshot, SnapshotResponse, +}; +use embers_server::{SOCKET_ENV_VAR, Server, ServerConfig}; +use tokio::time::{Duration, sleep}; +use tracing::warn; + +#[derive(Debug, Parser)] +#[command(name = "embers", about = "headless terminal multiplexer for embers")] +pub struct Cli { + #[arg(long, global = true)] + pub socket: Option, + #[arg(long, global = true)] + pub config: Option, + #[arg(long, global = true, value_name = "FILTER")] + pub log: Option, + #[arg(short = 'v', long = "verbose", global = true, action = clap::ArgAction::Count)] + pub verbose: u8, + #[command(subcommand)] + pub command: Option, +} + +impl Cli { + pub fn log_filter(&self) -> String { + if let Some(filter) = self.log.as_ref().filter(|value| !value.trim().is_empty()) { + return filter.clone(); + } + match self.verbose { + 0 => {} + 1 => return "debug".to_owned(), + _ => return "trace".to_owned(), + } + if let Some(filter) = std::env::var("EMBERS_LOG") + .ok() + .filter(|value| !value.trim().is_empty()) + { + return filter; + } + if let Some(filter) = std::env::var("RUST_LOG") + .ok() + .filter(|value| !value.trim().is_empty()) + { + return filter; + } + "info".to_owned() + } +} + +#[derive(Debug, Subcommand)] +pub enum Command { + Attach { + #[arg(short = 't', long = "target")] + target: Option, + }, + #[command(name = "__serve", hide = true)] + Serve, + Ping { + #[arg(default_value = "phase0")] + payload: String, + }, + #[command(name = "new-session")] + NewSession { name: String }, + #[command(name = "list-sessions")] + ListSessions, + #[command(name = "has-session")] + HasSession { + #[arg(short = 't', long = "target")] + target: String, + }, + #[command(name = "kill-session")] + KillSession { + #[arg(short = 't', long = "target")] + target: Option, + #[arg(long)] + force: bool, + }, + #[command(name = "new-window")] + NewWindow { + #[arg(short = 't', long = "target")] + target: Option, + #[arg(long)] + title: Option, + #[arg(last = true)] + command: Vec, + }, + #[command(name = "list-windows")] + ListWindows { + #[arg(short = 't', long = "target")] + target: Option, + }, + #[command(name = "select-window")] + SelectWindow { + #[arg(short = 't', long = "target")] + target: String, + }, + #[command(name = "rename-window")] + RenameWindow { + #[arg(short = 't', long = "target")] + target: Option, + title: String, + }, + #[command(name = "kill-window")] + KillWindow { + #[arg(short = 't', long = "target")] + target: Option, + }, + #[command(name = "split-window")] + SplitWindow { + #[arg(short = 't', long = "target")] + target: Option, + #[arg(long, conflicts_with = "vertical")] + horizontal: bool, + #[arg(long)] + vertical: bool, + #[arg(last = true)] + command: Vec, + }, + #[command(name = "list-panes")] + ListPanes { + #[arg(short = 't', long = "target")] + target: Option, + }, + #[command(name = "select-pane")] + SelectPane { + #[arg(short = 't', long = "target")] + target: String, + }, + #[command(name = "resize-pane")] + ResizePane { + #[arg(short = 't', long = "target")] + target: String, + #[arg(long, value_delimiter = ',')] + sizes: Vec, + }, + #[command(name = "send-keys")] + SendKeys { + #[arg(short = 't', long = "target")] + target: Option, + #[arg(long)] + enter: bool, + keys: Vec, + }, + #[command(name = "capture-pane")] + CapturePane { + #[arg(short = 't', long = "target")] + target: Option, + }, + #[command(name = "kill-pane")] + KillPane { + #[arg(short = 't', long = "target")] + target: Option, + }, + #[command(name = "display-popup")] + DisplayPopup { + #[arg(short = 't', long = "target")] + target: Option, + #[arg(long)] + title: Option, + #[arg(long, default_value_t = 14)] + x: u16, + #[arg(long, default_value_t = 4)] + y: u16, + #[arg(long, default_value_t = 60)] + width: u16, + #[arg(long, default_value_t = 12)] + height: u16, + #[arg(last = true)] + command: Vec, + }, + #[command(name = "kill-popup")] + KillPopup { + #[arg(short = 't', long = "target")] + target: Option, + }, +} + +async fn execute(socket: &Path, command: Command) -> Result { + let mut connection = CliConnection::connect(socket).await?; + + match command { + Command::Attach { .. } | Command::Serve => Err(MuxError::internal( + "interactive commands must be dispatched through run()", + )), + Command::Ping { payload } => { + let response = connection + .request(ClientMessage::Ping(PingRequest { + request_id: new_request_id(), + payload, + })) + .await?; + match response { + ServerResponse::Pong(response) => Ok(format!("pong {}", response.payload)), + other => Err(MuxError::protocol(format!( + "unexpected response to ping request: {other:?}" + ))), + } + } + Command::NewSession { name } => { + let response = connection + .request(ClientMessage::Session(SessionRequest::Create { + request_id: new_request_id(), + name, + })) + .await?; + let snapshot = expect_session_snapshot(response, "new-session")?; + Ok(format!( + "{}\t{}", + snapshot.session.id, snapshot.session.name + )) + } + Command::ListSessions => { + let sessions = connection.list_sessions().await?; + Ok(format_sessions(&sessions)) + } + Command::HasSession { target } => { + connection.resolve_session_record(Some(&target)).await?; + Ok(String::new()) + } + Command::KillSession { target, force } => { + let session = connection.resolve_session_record(target.as_deref()).await?; + connection + .request(ClientMessage::Session(SessionRequest::Close { + request_id: new_request_id(), + session_id: session.id, + force, + })) + .await?; + Ok(String::new()) + } + Command::NewWindow { + target, + title, + command, + } => { + let session = connection.resolve_session_record(target.as_deref()).await?; + let command = buffer_command(command); + let window_title = title.unwrap_or_else(|| default_title(&command, "window")); + let buffer = connection + .create_buffer(Some(window_title.clone()), command, None) + .await?; + let response = connection + .request(ClientMessage::Session(SessionRequest::AddRootTab { + request_id: new_request_id(), + session_id: session.id, + title: window_title.clone(), + buffer_id: Some(buffer.buffer.id), + child_node_id: None, + })) + .await; + let response = rollback_created_buffer_on_error( + &mut connection, + buffer.buffer.id, + "new-window", + response, + ) + .await?; + let snapshot = rollback_created_buffer_on_error( + &mut connection, + buffer.buffer.id, + "new-window", + expect_session_snapshot(response, "new-window"), + ) + .await?; + let (index, title) = active_root_window(&snapshot)?; + Ok(format!("{index}\t{title}")) + } + Command::ListWindows { target } => { + let snapshot = connection + .resolve_session_snapshot(target.as_deref()) + .await?; + Ok(format_windows(&snapshot)?) + } + Command::SelectWindow { target } => { + let window = connection.resolve_window(Some(&target)).await?; + connection + .request(ClientMessage::Session(SessionRequest::SelectRootTab { + request_id: new_request_id(), + session_id: window.snapshot.session.id, + index: window.index, + })) + .await?; + Ok(String::new()) + } + Command::RenameWindow { target, title } => { + let window = connection.resolve_window(target.as_deref()).await?; + connection + .request(ClientMessage::Session(SessionRequest::RenameRootTab { + request_id: new_request_id(), + session_id: window.snapshot.session.id, + index: window.index, + title, + })) + .await?; + Ok(String::new()) + } + Command::KillWindow { target } => { + let window = connection.resolve_window(target.as_deref()).await?; + connection + .request(ClientMessage::Session(SessionRequest::CloseRootTab { + request_id: new_request_id(), + session_id: window.snapshot.session.id, + index: window.index, + })) + .await?; + Ok(String::new()) + } + Command::SplitWindow { + target, + horizontal, + vertical: _, + command, + } => { + let pane = connection.resolve_pane(target.as_deref()).await?; + let command = buffer_command(command); + let buffer = connection + .create_buffer(Some(default_title(&command, "pane")), command, None) + .await?; + let direction = if horizontal { + SplitDirection::Horizontal + } else { + SplitDirection::Vertical + }; + let response = connection + .request(ClientMessage::Node(NodeRequest::Split { + request_id: new_request_id(), + leaf_node_id: pane.leaf_id, + direction, + new_buffer_id: buffer.buffer.id, + })) + .await; + let response = rollback_created_buffer_on_error( + &mut connection, + buffer.buffer.id, + "split-window", + response, + ) + .await?; + let snapshot = rollback_created_buffer_on_error( + &mut connection, + buffer.buffer.id, + "split-window", + expect_session_snapshot(response, "split-window"), + ) + .await?; + let focused_leaf = snapshot.session.focused_leaf_id.ok_or_else(|| { + MuxError::protocol("split-window response did not include focused leaf") + })?; + Ok(focused_leaf.to_string()) + } + Command::ListPanes { target } => { + let window = connection.resolve_window(target.as_deref()).await?; + let leaf_ids = visible_leaf_ids(&window.snapshot, window.child_id)?; + Ok(format_panes(&window.snapshot, &leaf_ids)?) + } + Command::SelectPane { target } => { + let pane = connection.resolve_pane(Some(&target)).await?; + connection + .request(ClientMessage::Node(NodeRequest::Focus { + request_id: new_request_id(), + session_id: pane.snapshot.session.id, + node_id: pane.leaf_id, + })) + .await?; + Ok(String::new()) + } + Command::ResizePane { target, sizes } => { + if sizes.is_empty() { + return Err(MuxError::invalid_input( + "resize-pane requires at least one size value", + )); + } + let pane = connection.resolve_pane(Some(&target)).await?; + let leaf = node_record(&pane.snapshot, pane.leaf_id)?; + let parent_id = leaf + .parent_id + .ok_or_else(|| MuxError::invalid_input("pane is not inside a resizable split"))?; + let parent = node_record(&pane.snapshot, parent_id)?; + if parent.kind != embers_protocol::NodeRecordKind::Split { + return Err(MuxError::invalid_input( + "pane parent is not a split and cannot be resized", + )); + } + + connection + .request(ClientMessage::Node(NodeRequest::Resize { + request_id: new_request_id(), + node_id: parent_id, + sizes, + })) + .await?; + Ok(String::new()) + } + Command::SendKeys { + target, + enter, + keys, + } => { + let pane = connection.resolve_pane(target.as_deref()).await?; + if keys.is_empty() && !enter { + return Err(MuxError::invalid_input( + "send-keys requires at least one key or --enter", + )); + } + let mut bytes = keys.join(" ").into_bytes(); + if enter { + bytes.push(b'\r'); + } + connection + .request(ClientMessage::Input(embers_protocol::InputRequest::Send { + request_id: new_request_id(), + buffer_id: pane.buffer_id, + bytes, + })) + .await?; + Ok(String::new()) + } + Command::CapturePane { target } => { + let pane = connection.resolve_pane(target.as_deref()).await?; + let response = connection + .request(ClientMessage::Buffer(BufferRequest::Capture { + request_id: new_request_id(), + buffer_id: pane.buffer_id, + })) + .await?; + let snapshot = expect_capture(response, "capture-pane")?; + Ok(snapshot.lines.join("\n")) + } + Command::KillPane { target } => { + let pane = connection.resolve_pane(target.as_deref()).await?; + connection + .request(ClientMessage::Node(NodeRequest::Close { + request_id: new_request_id(), + node_id: pane.leaf_id, + })) + .await?; + Ok(String::new()) + } + Command::DisplayPopup { + target, + title, + x, + y, + width, + height, + command, + } => { + let session = connection.resolve_session_record(target.as_deref()).await?; + let command = buffer_command(command); + let popup_title = title.unwrap_or_else(|| default_title(&command, "popup")); + let buffer = connection + .create_buffer(Some(popup_title.clone()), command, None) + .await?; + let response = connection + .request(ClientMessage::Floating(FloatingRequest::Create { + request_id: new_request_id(), + session_id: session.id, + root_node_id: None, + buffer_id: Some(buffer.buffer.id), + geometry: FloatGeometry::new(x, y, width, height), + title: Some(popup_title), + focus: true, + close_on_empty: true, + })) + .await; + let response = rollback_created_buffer_on_error( + &mut connection, + buffer.buffer.id, + "display-popup", + response, + ) + .await?; + let popup = rollback_created_buffer_on_error( + &mut connection, + buffer.buffer.id, + "display-popup", + expect_floating(response, "display-popup"), + ) + .await?; + Ok(popup.id.to_string()) + } + Command::KillPopup { target } => { + let popup = connection.resolve_popup(target.as_deref()).await?; + connection + .request(ClientMessage::Floating(FloatingRequest::Close { + request_id: new_request_id(), + floating_id: popup.id, + })) + .await?; + Ok(String::new()) + } + } +} + +pub async fn run(cli: Cli) -> Result<()> { + let socket = resolve_socket_path(cli.socket.as_deref()); + validate_runtime_socket_parent(&socket)?; + + match cli.command { + None => { + ensure_server_process(&socket).await?; + interactive::run(socket, None, cli.config).await + } + Some(Command::Attach { target }) => { + if !server_is_available(&socket).await { + return Err(MuxError::not_found(format!( + "no embers server is listening on {}", + socket.display() + ))); + } + interactive::run(socket, target, cli.config).await + } + Some(Command::Serve) => run_server(socket).await, + Some(command) => { + ensure_server_process(&socket).await?; + let output = execute(&socket, command).await?; + if !output.is_empty() { + println!("{output}"); + } + Ok(()) + } + } +} + +fn resolve_socket_path(explicit: Option<&Path>) -> PathBuf { + explicit + .map(Path::to_path_buf) + .or_else(|| std::env::var_os(SOCKET_ENV_VAR).map(PathBuf::from)) + .unwrap_or_else(default_socket_path) +} + +fn default_socket_path() -> PathBuf { + default_runtime_dir().join("embers.sock") +} + +fn default_runtime_dir() -> PathBuf { + if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR").filter(|value| !value.is_empty()) + { + return PathBuf::from(runtime_dir).join("embers"); + } + #[cfg(unix)] + { + let run_user_dir = PathBuf::from(format!("/run/user/{}", effective_uid())); + if run_user_dir.is_dir() { + return run_user_dir.join("embers"); + } + } + PathBuf::from("/tmp").join(format!("embers-{}", effective_uid())) +} + +#[cfg(unix)] +fn effective_uid() -> u32 { + unsafe { libc::geteuid() } +} + +#[cfg(not(unix))] +fn effective_uid() -> u32 { + 0 +} + +fn pid_path(socket_path: &Path) -> PathBuf { + socket_path.with_extension("pid") +} + +async fn server_is_available(socket_path: &Path) -> bool { + if validate_runtime_socket_parent(socket_path).is_err() { + return false; + } + CliConnection::connect(socket_path).await.is_ok() +} + +async fn ensure_server_process(socket_path: &Path) -> Result<()> { + if server_is_available(socket_path).await { + return Ok(()); + } + + ensure_socket_parent(socket_path)?; + + let current_exe = std::env::current_exe()?; + let mut child = ProcessCommand::new(current_exe) + .arg("__serve") + .arg("--socket") + .arg(socket_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + + let deadline = tokio::time::Instant::now() + Duration::from_secs(3); + let mut exited_status = None; + loop { + if server_is_available(socket_path).await { + return Ok(()); + } + if let Some(status) = child.try_wait()? { + exited_status.get_or_insert(status); + if server_is_available(socket_path).await { + return Ok(()); + } + } + if tokio::time::Instant::now() >= deadline { + if let Some(status) = exited_status { + return Err(MuxError::transport(format!( + "embers server exited before becoming ready with status {status}" + ))); + } + let _ = child.kill(); + let _ = child.wait(); + return Err(MuxError::timeout(format!( + "timed out waiting for embers server at {}", + socket_path.display() + ))); + } + sleep(Duration::from_millis(25)).await; + } +} + +async fn run_server(socket_path: PathBuf) -> Result<()> { + ensure_socket_parent(&socket_path)?; + let secure_parent = socket_path + .parent() + .is_some_and(|parent| parent == default_runtime_dir().as_path()); + let _pid = ServerPidFile::create(&pid_path(&socket_path), secure_parent)?; + let handle = Server::new(ServerConfig::new(socket_path)).start().await?; + wait_for_shutdown_signal().await?; + handle.shutdown().await +} + +fn ensure_socket_parent(socket_path: &Path) -> Result<()> { + let Some(parent) = socket_path.parent() else { + return Ok(()); + }; + if parent == default_runtime_dir().as_path() { + ensure_private_dir(parent) + } else { + fs::create_dir_all(parent)?; + Ok(()) + } +} + +fn ensure_private_dir(path: &Path) -> Result<()> { + fs::create_dir_all(path)?; + #[cfg(unix)] + { + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + validate_private_dir(path)?; + } + Ok(()) +} + +fn validate_runtime_socket_parent(socket_path: &Path) -> Result<()> { + let Some(parent) = socket_path.parent() else { + return Ok(()); + }; + if parent != default_runtime_dir().as_path() || !parent.exists() { + return Ok(()); + } + validate_private_dir(parent) +} + +#[cfg(unix)] +fn validate_private_dir(path: &Path) -> Result<()> { + let metadata = fs::metadata(path)?; + if !metadata.is_dir() { + return Err(MuxError::invalid_input(format!( + "runtime directory {} is not a directory", + path.display() + ))); + } + if metadata.uid() != effective_uid() { + return Err(MuxError::invalid_input(format!( + "runtime directory {} is not owned by uid {}", + path.display(), + effective_uid() + ))); + } + if metadata.permissions().mode() & 0o777 != 0o700 { + return Err(MuxError::invalid_input(format!( + "runtime directory {} must have mode 0700", + path.display() + ))); + } + Ok(()) +} + +#[cfg(not(unix))] +fn validate_private_dir(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +async fn wait_for_shutdown_signal() -> Result<()> { + let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?; + let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; + let mut hangup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?; + tokio::select! { + _ = interrupt.recv() => Ok(()), + _ = terminate.recv() => Ok(()), + _ = hangup.recv() => Ok(()), + } +} + +#[cfg(not(unix))] +async fn wait_for_shutdown_signal() -> Result<()> { + tokio::signal::ctrl_c().await?; + Ok(()) +} + +struct ServerPidFile { + path: PathBuf, +} + +impl ServerPidFile { + fn create(path: &Path, secure_parent: bool) -> Result { + if let Some(parent) = path.parent() { + if secure_parent { + ensure_private_dir(parent)?; + } else { + fs::create_dir_all(parent)?; + } + } + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + return Err(MuxError::conflict(format!( + "refusing to overwrite symlink pid file {}", + path.display() + ))); + } + let pid_text = fs::read_to_string(path).map_err(|error| { + MuxError::conflict(format!( + "refusing to overwrite unreadable pid file {}: {error}", + path.display() + )) + })?; + let pid = pid_text.trim().parse::().map_err(|error| { + MuxError::conflict(format!( + "refusing to overwrite invalid pid file {}: {error}", + path.display() + )) + })?; + if process_is_alive(pid)? { + return Err(MuxError::conflict(format!( + "refusing to overwrite active pid file {} for running process {pid}", + path.display() + ))); + } + fs::remove_file(path)?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + + #[cfg(unix)] + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(path)?; + + #[cfg(not(unix))] + let mut file = OpenOptions::new().write(true).create_new(true).open(path)?; + + file.write_all(std::process::id().to_string().as_bytes())?; + Ok(Self { + path: path.to_path_buf(), + }) + } +} + +#[cfg(unix)] +fn process_is_alive(pid: u32) -> Result { + let result = unsafe { libc::kill(pid as i32, 0) }; + if result == 0 { + return Ok(true); + } + match std::io::Error::last_os_error().raw_os_error() { + Some(libc::ESRCH) => Ok(false), + Some(libc::EPERM) => Ok(true), + Some(code) => Err(MuxError::transport(format!( + "failed to validate process {pid}: os error {code}" + ))), + None => Err(MuxError::transport(format!( + "failed to validate process {pid}: unknown os error" + ))), + } +} + +#[cfg(not(unix))] +fn process_is_alive(_pid: u32) -> Result { + Err(MuxError::conflict( + "pid file validation is unsupported on this platform".to_owned(), + )) +} + +impl Drop for ServerPidFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +#[derive(Debug)] +struct CliConnection { + client: ProtocolClient, +} + +impl CliConnection { + async fn connect(path: impl AsRef) -> Result { + let client = ProtocolClient::connect(path) + .await + .map_err(|error| MuxError::transport(error.to_string()))?; + Ok(Self { client }) + } + + async fn request(&mut self, message: ClientMessage) -> Result { + match self + .client + .request(&message) + .await + .map_err(|error| MuxError::transport(error.to_string()))? + { + ServerResponse::Error(response) => Err(response.error.into()), + response => Ok(response), + } + } + + async fn list_sessions(&mut self) -> Result> { + match self + .request(ClientMessage::Session(SessionRequest::List { + request_id: new_request_id(), + })) + .await? + { + ServerResponse::Sessions(response) => Ok(response.sessions), + other => Err(MuxError::protocol(format!( + "unexpected response to list-sessions: {other:?}" + ))), + } + } + + async fn session_snapshot(&mut self, session_id: SessionId) -> Result { + match self + .request(ClientMessage::Session(SessionRequest::Get { + request_id: new_request_id(), + session_id, + })) + .await? + { + ServerResponse::SessionSnapshot(response) => Ok(response.snapshot), + other => Err(MuxError::protocol(format!( + "unexpected response to session get: {other:?}" + ))), + } + } + + async fn create_buffer( + &mut self, + title: Option, + command: Vec, + cwd: Option, + ) -> Result { + match self + .request(ClientMessage::Buffer(BufferRequest::Create { + request_id: new_request_id(), + title, + command, + cwd, + env: Default::default(), + })) + .await? + { + ServerResponse::Buffer(response) => Ok(response), + other => Err(MuxError::protocol(format!( + "unexpected response to buffer create: {other:?}" + ))), + } + } + + async fn rollback_created_buffer(&mut self, buffer_id: BufferId, operation: &str) { + if let Err(error) = self + .request(ClientMessage::Buffer(BufferRequest::Detach { + request_id: new_request_id(), + buffer_id, + })) + .await + { + warn!( + %buffer_id, + %error, + operation, + "failed to detach created buffer during rollback" + ); + } + if let Err(error) = self + .request(ClientMessage::Buffer(BufferRequest::Kill { + request_id: new_request_id(), + buffer_id, + force: true, + })) + .await + { + warn!( + %buffer_id, + %error, + operation, + "failed to kill created buffer during rollback" + ); + } + } + + async fn resolve_session_record(&mut self, target: Option<&str>) -> Result { + let sessions = self.list_sessions().await?; + match target { + Some(target) => sessions + .into_iter() + .find(|session| session.name == target) + .ok_or_else(|| MuxError::not_found(format!("session '{target}' was not found"))), + None => match sessions.as_slice() { + [session] => Ok(session.clone()), + [] => Err(MuxError::not_found("no sessions exist")), + _ => Err(MuxError::invalid_input( + "session target is required when multiple sessions exist", + )), + }, + } + } + + async fn resolve_session_snapshot(&mut self, target: Option<&str>) -> Result { + let session = self.resolve_session_record(target).await?; + self.session_snapshot(session.id).await + } + + async fn resolve_window(&mut self, target: Option<&str>) -> Result { + let (session_target, selector) = split_scoped_target(target); + let snapshot = self + .resolve_session_snapshot(session_target.as_deref()) + .await?; + let (index, child_id) = { + if let Some((_, root_tabs)) = root_tabs(&snapshot)? { + let index = resolve_window_index(root_tabs, selector.as_deref())?; + let tab = protocol_tab(root_tabs, index).ok_or_else(|| { + MuxError::not_found(format!( + "window index {index} is not present in session {}", + snapshot.session.id + )) + })?; + (index, tab.child_id) + } else { + let title = window_title(&snapshot, snapshot.session.root_node_id)?; + let index = resolve_single_window_index(&title, selector.as_deref())?; + (index, snapshot.session.root_node_id) + } + }; + Ok(ResolvedWindow { + snapshot, + index, + child_id, + }) + } + + async fn resolve_pane(&mut self, target: Option<&str>) -> Result { + match target { + Some(target) => { + let (session_target, selector) = split_scoped_required(target, "pane target")?; + let pane_id = parse_node_id(&selector)?; + let snapshot = if let Some(session_target) = session_target { + self.resolve_session_snapshot(Some(&session_target)).await? + } else { + self.find_session_containing_pane(pane_id).await? + }; + resolved_pane(snapshot, pane_id) + } + None => { + let snapshot = self.resolve_session_snapshot(None).await?; + let pane_id = snapshot + .session + .focused_leaf_id + .ok_or_else(|| MuxError::not_found("session has no focused pane"))?; + resolved_pane(snapshot, pane_id) + } + } + } + + async fn resolve_popup(&mut self, target: Option<&str>) -> Result { + match target { + Some(target) => { + let (session_target, selector) = split_scoped_required(target, "popup target")?; + let popup_id = parse_floating_id(&selector)?; + if let Some(session_target) = session_target { + let snapshot = self.resolve_session_snapshot(Some(&session_target)).await?; + floating_record(&snapshot, popup_id).cloned() + } else { + let sessions = self.list_sessions().await?; + for session in sessions { + let snapshot = self.session_snapshot(session.id).await?; + if let Ok(popup) = floating_record(&snapshot, popup_id) { + return Ok(popup.clone()); + } + } + Err(MuxError::not_found(format!( + "popup {popup_id} was not found" + ))) + } + } + None => { + let snapshot = self.resolve_session_snapshot(None).await?; + let popup_id = snapshot + .session + .focused_floating_id + .ok_or_else(|| MuxError::not_found("session has no focused popup"))?; + floating_record(&snapshot, popup_id).cloned() + } + } + } + + async fn find_session_containing_pane(&mut self, pane_id: NodeId) -> Result { + let sessions = self.list_sessions().await?; + for session in sessions { + let snapshot = self.session_snapshot(session.id).await?; + if node_record(&snapshot, pane_id).is_ok() { + return Ok(snapshot); + } + } + + Err(MuxError::not_found(format!("pane {pane_id} was not found"))) + } +} + +async fn rollback_created_buffer_on_error( + connection: &mut CliConnection, + buffer_id: BufferId, + operation: &str, + result: Result, +) -> Result { + match result { + Ok(value) => Ok(value), + Err(error) => { + connection + .rollback_created_buffer(buffer_id, operation) + .await; + Err(error) + } + } +} + +#[derive(Debug)] +struct ResolvedWindow { + snapshot: SessionSnapshot, + index: u32, + child_id: NodeId, +} + +#[derive(Debug)] +struct ResolvedPane { + snapshot: SessionSnapshot, + leaf_id: NodeId, + buffer_id: BufferId, +} + +fn resolved_pane(snapshot: SessionSnapshot, pane_id: NodeId) -> Result { + let leaf = node_record(&snapshot, pane_id)?; + let buffer_id = leaf + .buffer_view + .as_ref() + .map(|view| view.buffer_id) + .ok_or_else(|| MuxError::invalid_input(format!("node {pane_id} is not a pane leaf")))?; + Ok(ResolvedPane { + snapshot, + leaf_id: pane_id, + buffer_id, + }) +} + +fn expect_session_snapshot(response: ServerResponse, operation: &str) -> Result { + match response { + ServerResponse::SessionSnapshot(response) => Ok(response.snapshot), + other => Err(MuxError::protocol(format!( + "unexpected response to {operation}: {other:?}" + ))), + } +} + +fn expect_floating(response: ServerResponse, operation: &str) -> Result { + match response { + ServerResponse::Floating(FloatingResponse { floating, .. }) => Ok(floating), + other => Err(MuxError::protocol(format!( + "unexpected response to {operation}: {other:?}" + ))), + } +} + +fn expect_capture(response: ServerResponse, operation: &str) -> Result { + match response { + ServerResponse::Snapshot(snapshot) => Ok(snapshot), + other => Err(MuxError::protocol(format!( + "unexpected response to {operation}: {other:?}" + ))), + } +} + +fn format_sessions(sessions: &[SessionRecord]) -> String { + sessions + .iter() + .map(|session| format!("{}\t{}", session.id, session.name)) + .collect::>() + .join("\n") +} + +fn format_windows(snapshot: &SessionSnapshot) -> Result { + if let Some((_, tabs)) = root_tabs(snapshot)? { + Ok(tabs + .tabs + .iter() + .enumerate() + .map(|(index, tab)| { + format!( + "{index}\t{}\t{}", + usize::from(u32::try_from(index).ok() == Some(tabs.active)), + tab.title + ) + }) + .collect::>() + .join("\n")) + } else { + let title = window_title(snapshot, snapshot.session.root_node_id)?; + Ok(format!("0\t1\t{title}")) + } +} + +fn format_panes(snapshot: &SessionSnapshot, pane_ids: &[NodeId]) -> Result { + pane_ids + .iter() + .map(|pane_id| { + let leaf = node_record(snapshot, *pane_id)?; + let buffer_id = leaf + .buffer_view + .as_ref() + .map(|view| view.buffer_id) + .ok_or_else(|| MuxError::invalid_input(format!("node {pane_id} is not a pane")))?; + let buffer = buffer_record(snapshot, buffer_id)?; + Ok(format!( + "{}\t{}\t{}\t{}", + pane_id, + buffer.id, + usize::from(snapshot.session.focused_leaf_id == Some(*pane_id)), + buffer.title + )) + }) + .collect::>>() + .map(|lines| lines.join("\n")) +} + +fn active_root_window(snapshot: &SessionSnapshot) -> Result<(u32, String)> { + if let Some((_, tabs)) = root_tabs(snapshot)? { + let tab = protocol_tab(tabs, tabs.active) + .ok_or_else(|| MuxError::protocol("session root tabs has invalid active index"))?; + Ok((tabs.active, tab.title.clone())) + } else { + Ok((0, window_title(snapshot, snapshot.session.root_node_id)?)) + } +} + +fn root_tabs( + snapshot: &SessionSnapshot, +) -> Result> { + let node = node_record(snapshot, snapshot.session.root_node_id)?; + Ok(node.tabs.as_ref().map(|tabs| (node, tabs))) +} + +fn node_record( + snapshot: &SessionSnapshot, + node_id: NodeId, +) -> Result<&embers_protocol::NodeRecord> { + snapshot + .nodes + .iter() + .find(|node| node.id == node_id) + .ok_or_else(|| MuxError::not_found(format!("node {node_id} is not present in snapshot"))) +} + +fn buffer_record( + snapshot: &SessionSnapshot, + buffer_id: BufferId, +) -> Result<&embers_protocol::BufferRecord> { + snapshot + .buffers + .iter() + .find(|buffer| buffer.id == buffer_id) + .ok_or_else(|| { + MuxError::not_found(format!("buffer {buffer_id} is not present in snapshot")) + }) +} + +fn floating_record(snapshot: &SessionSnapshot, floating_id: FloatingId) -> Result<&FloatingRecord> { + snapshot + .floating + .iter() + .find(|floating| floating.id == floating_id) + .ok_or_else(|| { + MuxError::not_found(format!("popup {floating_id} is not present in snapshot")) + }) +} + +fn visible_leaf_ids(snapshot: &SessionSnapshot, node_id: NodeId) -> Result> { + let node = node_record(snapshot, node_id)?; + match node.kind { + embers_protocol::NodeRecordKind::BufferView => Ok(vec![node.id]), + embers_protocol::NodeRecordKind::Split => { + let split = node + .split + .as_ref() + .ok_or_else(|| MuxError::protocol(format!("split node {node_id} is malformed")))?; + let mut leaves = Vec::new(); + for child_id in &split.child_ids { + leaves.extend(visible_leaf_ids(snapshot, *child_id)?); + } + Ok(leaves) + } + embers_protocol::NodeRecordKind::Tabs => { + let tabs = node + .tabs + .as_ref() + .ok_or_else(|| MuxError::protocol(format!("tabs node {node_id} is malformed")))?; + let active_child = protocol_tab(tabs, tabs.active).ok_or_else(|| { + MuxError::protocol(format!("tabs node {node_id} has invalid active index")) + })?; + visible_leaf_ids(snapshot, active_child.child_id) + } + } +} + +fn resolve_window_index(tabs: &embers_protocol::TabsRecord, selector: Option<&str>) -> Result { + let Some(selector) = selector else { + return Ok(tabs.active); + }; + + if let Ok(index) = selector.parse::() { + let mut candidates = Vec::new(); + if protocol_tab(tabs, index).is_some() { + candidates.push(index); + } + if let Some(one_based) = index.checked_sub(1) + && protocol_tab(tabs, one_based).is_some() + { + candidates.push(one_based); + } + candidates.sort_unstable(); + candidates.dedup(); + return match candidates.as_slice() { + [only] => Ok(*only), + [] => Err(MuxError::not_found(format!( + "window index '{selector}' is out of range" + ))), + _ => Err(MuxError::invalid_input(format!( + "window index '{selector}' is ambiguous between 0-based and 1-based addressing" + ))), + }; + } + + let matches = tabs + .tabs + .iter() + .enumerate() + .filter(|(_, tab)| tab.title == selector) + .map(|(index, _)| u32::try_from(index).expect("tab index fits into protocol width")) + .collect::>(); + + match matches.as_slice() { + [index] => Ok(*index), + [] => Err(MuxError::not_found(format!( + "window '{selector}' was not found" + ))), + _ => Err(MuxError::conflict(format!( + "window title '{selector}' matched multiple root tabs" + ))), + } +} + +fn resolve_single_window_index(title: &str, selector: Option<&str>) -> Result { + let Some(selector) = selector else { + return Ok(0); + }; + + if let Ok(index) = selector.parse::() { + return match index { + 0 | 1 => Ok(0), + _ => Err(MuxError::not_found(format!( + "window index '{selector}' is out of range" + ))), + }; + } + + if selector == title { + Ok(0) + } else { + Err(MuxError::not_found(format!( + "window '{selector}' was not found" + ))) + } +} + +fn window_title(snapshot: &SessionSnapshot, node_id: NodeId) -> Result { + let visible_leaf_ids = visible_leaf_ids(snapshot, node_id)?; + let leaf_id = snapshot + .session + .focused_leaf_id + .filter(|leaf_id| visible_leaf_ids.contains(leaf_id)) + .or_else(|| visible_leaf_ids.first().copied()) + .ok_or_else(|| MuxError::not_found(format!("window {node_id} has no visible panes")))?; + let leaf = node_record(snapshot, leaf_id)?; + let buffer_id = leaf + .buffer_view + .as_ref() + .map(|view| view.buffer_id) + .ok_or_else(|| MuxError::invalid_input(format!("node {leaf_id} is not a pane leaf")))?; + Ok(buffer_record(snapshot, buffer_id)?.title.clone()) +} + +fn protocol_tab( + tabs: &embers_protocol::TabsRecord, + index: u32, +) -> Option<&embers_protocol::TabRecord> { + usize::try_from(index) + .ok() + .and_then(|index| tabs.tabs.get(index)) +} + +fn split_scoped_target(target: Option<&str>) -> (Option, Option) { + match target { + Some(target) => { + if let Some((session, selector)) = target.split_once(':') { + (Some(session.to_owned()), Some(selector.to_owned())) + } else { + (None, Some(target.to_owned())) + } + } + None => (None, None), + } +} + +fn split_scoped_required(target: &str, label: &str) -> Result<(Option, String)> { + let (session, selector) = split_scoped_target(Some(target)); + let selector = + selector.ok_or_else(|| MuxError::invalid_input(format!("{label} is required")))?; + Ok((session, selector)) +} + +fn parse_node_id(raw: &str) -> Result { + raw.parse::() + .map(NodeId) + .map_err(|_| MuxError::invalid_input(format!("pane target '{raw}' is not a valid pane id"))) +} + +fn parse_floating_id(raw: &str) -> Result { + raw.parse::().map(FloatingId).map_err(|_| { + MuxError::invalid_input(format!("popup target '{raw}' is not a valid popup id")) + }) +} + +fn buffer_command(command: Vec) -> Vec { + if command.is_empty() { + vec![std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned())] + } else { + command + } +} + +fn default_title(command: &[String], fallback: &str) -> String { + command + .first() + .and_then(|value| { + Path::new(value) + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_owned) + }) + .unwrap_or_else(|| fallback.to_owned()) +} + +#[cfg(test)] +mod tests { + use clap::Parser; + use embers_core::NodeId; + use embers_protocol::{TabRecord, TabsRecord}; + + use super::{Cli, resolve_window_index, split_scoped_required, split_scoped_target}; + + #[test] + fn parser_accepts_global_socket_after_subcommand() { + let cli = Cli::try_parse_from([ + "embers", + "new-window", + "--socket", + "/tmp/mux.sock", + "--title", + "logs", + "--", + "/bin/sh", + ]) + .expect("cli parses"); + + match cli.command { + Some(super::Command::NewWindow { title, command, .. }) => { + assert_eq!(title.as_deref(), Some("logs")); + assert_eq!(command, vec!["/bin/sh"]); + } + other => panic!("expected new-window command, got {other:?}"), + } + } + + #[test] + fn scoped_targets_split_session_prefix() { + assert_eq!( + split_scoped_required("main:2", "window target").expect("target parses"), + (Some("main".to_owned()), "2".to_owned()) + ); + assert_eq!(split_scoped_target(Some("3")), (None, Some("3".to_owned()))); + } + + #[test] + fn numeric_window_indices_report_ambiguity() { + let tabs = TabsRecord { + active: 0, + tabs: vec![ + TabRecord { + title: "one".to_owned(), + child_id: NodeId(1), + }, + TabRecord { + title: "two".to_owned(), + child_id: NodeId(2), + }, + TabRecord { + title: "three".to_owned(), + child_id: NodeId(3), + }, + ], + }; + + let error = resolve_window_index(&tabs, Some("1")).expect_err("index is ambiguous"); + assert!( + error + .to_string() + .contains("ambiguous between 0-based and 1-based") + ); + assert_eq!( + resolve_window_index(&tabs, Some("0")).expect("zero resolves"), + 0 + ); + } +} diff --git a/crates/mux-cli/src/main.rs b/crates/embers-cli/src/main.rs similarity index 78% rename from crates/mux-cli/src/main.rs rename to crates/embers-cli/src/main.rs index 7f73594..eca5d5f 100644 --- a/crates/mux-cli/src/main.rs +++ b/crates/embers-cli/src/main.rs @@ -1,6 +1,6 @@ use clap::Parser; -use mux_cli::{Cli, run}; -use mux_core::init_tracing; +use embers_cli::{Cli, run}; +use embers_core::init_tracing; #[tokio::main] async fn main() { diff --git a/crates/embers-cli/tests/interactive.rs b/crates/embers-cli/tests/interactive.rs new file mode 100644 index 0000000..3e8249b --- /dev/null +++ b/crates/embers-cli/tests/interactive.rs @@ -0,0 +1,257 @@ +mod support; + +use std::fs; +use std::path::Path; +use std::time::Duration; + +use embers_core::PtySize; +use embers_test_support::{PtyHarness, TestServer, cargo_bin, cargo_bin_path}; +use tempfile::tempdir; + +use support::run_cli; + +const STARTUP_TIMEOUT: Duration = Duration::from_secs(15); +const IO_TIMEOUT: Duration = Duration::from_secs(15); +const FILE_WAIT_POLL: Duration = Duration::from_millis(50); +const FILE_WAIT_ATTEMPTS: usize = 200; +const SCROLLBACK_SETTLE_DELAY: Duration = Duration::from_millis(750); +const QUIET_TIMEOUT: Duration = Duration::from_millis(500); + +fn spawn_embers(args: &[&str]) -> PtyHarness { + let binary = cargo_bin_path("embers"); + let binary_dir = binary.parent().expect("binary dir"); + let path = format!( + "PATH={}:{}", + binary_dir.display(), + std::env::var("PATH").unwrap_or_default() + ); + let mut env_and_args = vec![ + path, + "SHELL=/bin/sh".to_owned(), + binary.to_string_lossy().into_owned(), + ]; + env_and_args.extend(args.iter().map(|arg| (*arg).to_owned())); + let argv = env_and_args.iter().map(String::as_str).collect::>(); + PtyHarness::spawn("/usr/bin/env", &argv, PtySize::new(80, 24)).expect("spawn embers in pty") +} + +async fn shutdown_spawned_server(socket_path: &Path) { + let pid_path = socket_path.with_extension("pid"); + let pid = wait_for_pid(&pid_path) + .await + .trim() + .parse::() + .expect("pid parses"); + assert!(pid > 0, "invalid pid: {pid}"); + + // SAFETY: pid comes from our own pid file and SIGTERM targets that specific process. + let result = unsafe { libc::kill(pid, libc::SIGTERM) }; + assert_eq!(result, 0, "failed to signal spawned server"); + + for _ in 0..FILE_WAIT_ATTEMPTS { + if !socket_path.exists() && !pid_path.exists() { + return; + } + tokio::time::sleep(FILE_WAIT_POLL).await; + } + + panic!( + "timed out waiting for spawned server shutdown (socket: {}, pid file: {})", + socket_path.display(), + pid_path.display() + ); +} + +async fn wait_for_socket(socket_path: &Path) { + for _ in 0..FILE_WAIT_ATTEMPTS { + if socket_path.exists() { + return; + } + tokio::time::sleep(FILE_WAIT_POLL).await; + } + + panic!("timed out waiting for socket {}", socket_path.display()); +} + +async fn wait_for_pid(pid_path: &Path) -> String { + for _ in 0..FILE_WAIT_ATTEMPTS { + if let Ok(pid) = fs::read_to_string(pid_path) { + return pid; + } + tokio::time::sleep(FILE_WAIT_POLL).await; + } + + panic!("timed out waiting for pid file {}", pid_path.display()); +} + +async fn populate_scrollback_or_wait(harness: &mut PtyHarness, lines: usize) { + let long_output = format!( + "printf '{}\\n'; echo DONE\r", + (1..=lines) + .map(|index| format!("line-{index}")) + .collect::>() + .join("\\n") + ); + harness + .write_all(&long_output) + .expect("write scrolling command"); + harness + .read_until_contains("line-1", IO_TIMEOUT) + .unwrap_or_else(|error| panic!("long output started: {error}")); + harness + .wait_for_quiet(QUIET_TIMEOUT, IO_TIMEOUT) + .unwrap_or_else(|error| panic!("long output settled: {error}")); + tokio::time::sleep(SCROLLBACK_SETTLE_DELAY).await; +} + +fn run_pane_command(harness: &mut PtyHarness, command: &str, expected: &str) -> String { + harness + .write_all(&format!("{command}\r")) + .unwrap_or_else(|error| panic!("send pane command `{command}`: {error}")); + + let output = harness + .read_until_contains(expected, IO_TIMEOUT) + .unwrap_or_else(|error| { + panic!("pane command `{command}` did not print `{expected}`: {error}") + }); + + assert!( + output.contains(expected), + "pane command `{command}` did not print `{expected}`:\n{output}" + ); + + output +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn embers_without_subcommand_starts_server_and_client() { + let tempdir = tempdir().expect("tempdir"); + let socket_path = tempdir.path().join("embers.sock"); + let socket_arg = socket_path.to_string_lossy().into_owned(); + let mut harness = spawn_embers(&["--socket", &socket_arg]); + + harness + .read_until_contains("[main]", STARTUP_TIMEOUT) + .expect("client starts and renders"); + + let output = run_pane_command(&mut harness, "embers list-sessions", "1\tmain"); + assert!( + output.contains("1\tmain"), + "expected list-sessions output in pane:\n{output}" + ); + + wait_for_socket(&socket_path).await; + + let output = cargo_bin("embers") + .arg("list-sessions") + .arg("--socket") + .arg(&socket_path) + .output() + .expect("cli command runs"); + assert!( + output.status.success(), + "list-sessions failed after client exit:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("1\tmain")); + + harness.write_all("\x11").expect("quit client"); + harness.wait().expect("client exits"); + + shutdown_spawned_server(&socket_path).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attach_subcommand_connects_to_running_server() { + let server = TestServer::start().await.expect("start server"); + let binary = cargo_bin_path("embers"); + let binary_dir = binary.parent().expect("binary dir"); + let shell_path = format!( + "{}:{}", + binary_dir.display(), + std::env::var("PATH").unwrap_or_default() + ); + + run_cli(&server, ["new-session", "main"]); + run_cli( + &server, + vec![ + "new-window".to_owned(), + "-t".to_owned(), + "main".to_owned(), + "--title".to_owned(), + "shell".to_owned(), + "--".to_owned(), + "/usr/bin/env".to_owned(), + format!("PATH={shell_path}"), + "/bin/sh".to_owned(), + ], + ); + + let socket_arg = server.socket_path().to_string_lossy().into_owned(); + let mut harness = spawn_embers(&["attach", "--socket", &socket_arg]); + harness + .read_until_contains("[main]", STARTUP_TIMEOUT) + .expect("attach client renders"); + + let output = run_pane_command(&mut harness, "embers list-sessions", "1\tmain"); + assert!( + output.contains("1\tmain"), + "expected list-sessions output in attached pane:\n{output}" + ); + + harness.write_all("\x11").expect("quit attached client"); + harness.wait().expect("client exits"); + server.shutdown().await.expect("shutdown server"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn page_up_enters_local_scrollback_and_shows_indicator() { + let tempdir = tempdir().expect("tempdir"); + let socket_path = tempdir.path().join("embers.sock"); + let socket_arg = socket_path.to_string_lossy().into_owned(); + let mut harness = spawn_embers(&["--socket", &socket_arg]); + + harness + .read_until_contains("[main]", STARTUP_TIMEOUT) + .expect("client starts and renders"); + populate_scrollback_or_wait(&mut harness, 40).await; + + harness.write_all("\x1b[5~").expect("page up"); + let output = harness + .read_until_contains("line-1", IO_TIMEOUT) + .expect("page up reveals earlier scrollback"); + assert!(output.contains("line-1")); + + harness.write_all("\x11").expect("quit client"); + harness.wait().expect("client exits"); + shutdown_spawned_server(&socket_path).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn local_selection_yank_emits_osc52_clipboard_sequence() { + let tempdir = tempdir().expect("tempdir"); + let socket_path = tempdir.path().join("embers.sock"); + let socket_arg = socket_path.to_string_lossy().into_owned(); + let mut harness = spawn_embers(&["--socket", &socket_arg]); + + harness + .read_until_contains("[main]", STARTUP_TIMEOUT) + .expect("client starts and renders"); + populate_scrollback_or_wait(&mut harness, 40).await; + + harness.write_all("\x1b[5~").expect("page up"); + harness + .read_until_contains("line-1", IO_TIMEOUT) + .expect("page up reveals earlier scrollback"); + harness.write_all("vly").expect("select and yank"); + let output = harness + .read_until_contains("]52;c;", IO_TIMEOUT) + .expect("osc52 emitted"); + assert!(output.contains("]52;c;")); + + harness.write_all("\x11").expect("quit client"); + harness.wait().expect("client exits"); + shutdown_spawned_server(&socket_path).await; +} diff --git a/crates/embers-cli/tests/panes.rs b/crates/embers-cli/tests/panes.rs new file mode 100644 index 0000000..41b8c29 --- /dev/null +++ b/crates/embers-cli/tests/panes.rs @@ -0,0 +1,128 @@ +mod support; + +use std::time::Duration; + +use embers_test_support::{TestConnection, TestServer}; +use tokio::time::sleep; + +use support::{run_cli, session_snapshot_by_name, stdout}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pane_commands_round_trip_through_cli() { + let server = TestServer::start().await.expect("start server"); + + run_cli(&server, ["new-session", "alpha"]); + run_cli( + &server, + [ + "new-window", + "-t", + "alpha", + "--title", + "work", + "--", + "/bin/sh", + ], + ); + + let split = run_cli(&server, ["split-window", "--", "/bin/sh"]); + let new_pane_id = stdout(&split) + .trim() + .parse::() + .expect("split-window returns pane id"); + + let listed = run_cli(&server, ["list-panes"]); + let lines = stdout(&listed) + .trim() + .lines() + .map(str::to_owned) + .collect::>(); + assert_eq!(lines.len(), 2); + + let pane_ids = lines + .iter() + .map(|line| { + line.split('\t') + .next() + .expect("pane id column") + .parse::() + .expect("pane id parses") + }) + .collect::>(); + assert!(pane_ids.contains(&new_pane_id)); + let other_pane_id = pane_ids + .into_iter() + .find(|pane_id| *pane_id != new_pane_id) + .expect("other pane exists"); + + run_cli(&server, ["select-pane", "-t", &other_pane_id.to_string()]); + let listed = run_cli(&server, ["list-panes"]); + assert!( + stdout(&listed) + .lines() + .any(|line| line.starts_with(&format!("{other_pane_id}\t")) && line.contains("\t1\t")) + ); + + run_cli( + &server, + [ + "resize-pane", + "-t", + &other_pane_id.to_string(), + "--sizes", + "3,1", + ], + ); + + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("connect protocol client"); + let snapshot = session_snapshot_by_name(&mut connection, "alpha").await; + let parent_split = snapshot + .nodes + .iter() + .find(|node| { + node.split.as_ref().is_some_and(|split| { + split + .child_ids + .contains(&embers_core::NodeId(other_pane_id)) + }) + }) + .expect("parent split exists"); + assert_eq!( + parent_split.split.as_ref().expect("split payload").sizes, + vec![3, 1] + ); + + run_cli( + &server, + [ + "send-keys", + "-t", + &other_pane_id.to_string(), + "--enter", + "printf", + "cli-pane\\n", + ], + ); + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + let captured = loop { + let captured = run_cli(&server, ["capture-pane", "-t", &other_pane_id.to_string()]); + if stdout(&captured).contains("cli-pane") { + break captured; + } + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for pane {} to render cli-pane", + other_pane_id + ); + sleep(Duration::from_millis(50)).await; + }; + assert!(stdout(&captured).contains("cli-pane")); + + run_cli(&server, ["kill-pane", "-t", &other_pane_id.to_string()]); + let listed = run_cli(&server, ["list-panes"]); + assert_eq!(stdout(&listed).trim().lines().count(), 1); + + server.shutdown().await.expect("shutdown server"); +} diff --git a/crates/mux-cli/tests/ping.rs b/crates/embers-cli/tests/ping.rs similarity index 83% rename from crates/mux-cli/tests/ping.rs rename to crates/embers-cli/tests/ping.rs index b1ad0bf..a2b2b45 100644 --- a/crates/mux-cli/tests/ping.rs +++ b/crates/embers-cli/tests/ping.rs @@ -1,10 +1,10 @@ -use mux_test_support::{TestServer, cargo_bin}; +use embers_test_support::{TestServer, cargo_bin}; use predicates::prelude::*; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn ping_command_reaches_server() { let server = TestServer::start().await.expect("start server"); - let mut command = cargo_bin("mux-cli"); + let mut command = cargo_bin("embers"); command .arg("ping") .arg("--socket") diff --git a/crates/embers-cli/tests/popups.rs b/crates/embers-cli/tests/popups.rs new file mode 100644 index 0000000..fc960bb --- /dev/null +++ b/crates/embers-cli/tests/popups.rs @@ -0,0 +1,50 @@ +mod support; + +use embers_test_support::{TestConnection, TestServer}; + +use support::{run_cli, session_snapshot_by_name, stdout}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn popup_commands_round_trip_through_cli() { + let server = TestServer::start().await.expect("start server"); + + run_cli(&server, ["new-session", "alpha"]); + let created = run_cli( + &server, + [ + "display-popup", + "-t", + "alpha", + "--title", + "scratch", + "--x", + "2", + "--y", + "1", + "--width", + "20", + "--height", + "6", + "--", + "/bin/sh", + ], + ); + let popup_id = stdout(&created) + .trim() + .parse::() + .expect("display-popup returns popup id"); + + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("connect protocol client"); + let snapshot = session_snapshot_by_name(&mut connection, "alpha").await; + assert_eq!(snapshot.floating.len(), 1); + assert_eq!(u64::from(snapshot.floating[0].id), popup_id); + + run_cli(&server, ["kill-popup", "-t", &popup_id.to_string()]); + + let snapshot = session_snapshot_by_name(&mut connection, "alpha").await; + assert!(snapshot.floating.is_empty()); + + server.shutdown().await.expect("shutdown server"); +} diff --git a/crates/embers-cli/tests/sessions.rs b/crates/embers-cli/tests/sessions.rs new file mode 100644 index 0000000..8568cfb --- /dev/null +++ b/crates/embers-cli/tests/sessions.rs @@ -0,0 +1,132 @@ +mod support; + +use std::fs; +use std::path::Path; +use std::time::Duration; + +use embers_test_support::cargo_bin; +use predicates::prelude::*; + +use embers_test_support::TestServer; +use tempfile::tempdir; + +use support::{cli_command, run_cli, stdout}; + +async fn shutdown_spawned_server(socket_path: &Path) { + let pid_path = socket_path.with_extension("pid"); + let pid = wait_for_pid(&pid_path) + .await + .trim() + .parse::() + .expect("pid parses"); + assert!(pid > 0, "invalid pid: {pid}"); + + // SAFETY: pid comes from our own pid file and SIGTERM targets that specific process. + let result = unsafe { libc::kill(pid, libc::SIGTERM) }; + assert_eq!(result, 0, "failed to signal spawned server"); + + for _ in 0..50 { + if !socket_path.exists() && !pid_path.exists() { + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + panic!( + "timed out waiting for spawned server shutdown (socket: {}, pid file: {})", + socket_path.display(), + pid_path.display() + ); +} + +async fn wait_for_socket(socket_path: &Path) { + for _ in 0..50 { + if socket_path.exists() { + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + panic!("timed out waiting for socket {}", socket_path.display()); +} + +async fn wait_for_pid(pid_path: &Path) -> String { + for _ in 0..50 { + if let Ok(pid) = fs::read_to_string(pid_path) { + return pid; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + panic!("timed out waiting for pid file {}", pid_path.display()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn session_commands_round_trip_through_cli() { + let server = TestServer::start().await.expect("start server"); + + let created = run_cli(&server, ["new-session", "alpha"]); + assert_eq!(stdout(&created).trim(), "1\talpha"); + + let listed = run_cli(&server, ["list-sessions"]); + assert_eq!(stdout(&listed).trim(), "1\talpha"); + + cli_command(&server) + .arg("has-session") + .arg("-t") + .arg("alpha") + .assert() + .success() + .stdout(predicate::str::is_empty()); + + cli_command(&server) + .arg("kill-session") + .arg("-t") + .arg("alpha") + .assert() + .success() + .stdout(predicate::str::is_empty()); + + let listed = run_cli(&server, ["list-sessions"]); + assert!(stdout(&listed).trim().is_empty()); + + server.shutdown().await.expect("shutdown server"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn has_session_reports_missing_names_precisely() { + let server = TestServer::start().await.expect("start server"); + + cli_command(&server) + .arg("has-session") + .arg("-t") + .arg("missing") + .assert() + .failure() + .stderr(predicate::str::contains("session 'missing' was not found")); + + server.shutdown().await.expect("shutdown server"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn list_sessions_bootstraps_server_on_first_run() { + let tempdir = tempdir().expect("tempdir"); + let socket_path = tempdir.path().join("embers.sock"); + + let output = cargo_bin("embers") + .arg("--socket") + .arg(&socket_path) + .arg("list-sessions") + .output() + .expect("cli command runs"); + assert!( + output.status.success(), + "list-sessions failed without a pre-existing server:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty()); + + wait_for_socket(&socket_path).await; + shutdown_spawned_server(&socket_path).await; +} diff --git a/crates/embers-cli/tests/support/mod.rs b/crates/embers-cli/tests/support/mod.rs new file mode 100644 index 0000000..cfa7a31 --- /dev/null +++ b/crates/embers-cli/tests/support/mod.rs @@ -0,0 +1,70 @@ +#![allow(dead_code)] + +use std::ffi::OsStr; +use std::process::Output; + +use embers_protocol::{ClientMessage, ServerResponse, SessionRequest, SessionSnapshot}; +use embers_test_support::{TestConnection, TestServer, cargo_bin}; + +pub fn cli_command(server: &TestServer) -> assert_cmd::Command { + let mut command = cargo_bin("embers"); + command.arg("--socket").arg(server.socket_path()); + command +} + +pub fn run_cli(server: &TestServer, args: I) -> Output +where + I: IntoIterator, + S: AsRef, +{ + let output = cli_command(server) + .args(args) + .output() + .expect("cli command runs"); + assert!( + output.status.success(), + "cli failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output +} + +pub fn stdout(output: &Output) -> String { + String::from_utf8(output.stdout.clone()).expect("stdout is utf-8") +} + +pub async fn session_snapshot_by_name( + connection: &mut TestConnection, + name: &str, +) -> SessionSnapshot { + let response = connection + .request(&ClientMessage::Session(SessionRequest::List { + request_id: embers_core::new_request_id(), + })) + .await + .expect("list sessions succeeds"); + let session_id = match response { + ServerResponse::Sessions(response) => { + response + .sessions + .into_iter() + .find(|session| session.name == name) + .expect("session is present") + .id + } + other => panic!("expected sessions response, got {other:?}"), + }; + + let response = connection + .request(&ClientMessage::Session(SessionRequest::Get { + request_id: embers_core::new_request_id(), + session_id, + })) + .await + .expect("session snapshot succeeds"); + match response { + ServerResponse::SessionSnapshot(response) => response.snapshot, + other => panic!("expected session snapshot, got {other:?}"), + } +} diff --git a/crates/embers-cli/tests/windows.rs b/crates/embers-cli/tests/windows.rs new file mode 100644 index 0000000..755fbd1 --- /dev/null +++ b/crates/embers-cli/tests/windows.rs @@ -0,0 +1,80 @@ +mod support; + +use embers_test_support::{TestConnection, TestServer}; + +use support::{run_cli, session_snapshot_by_name, stdout}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn window_commands_round_trip_through_cli() { + let server = TestServer::start().await.expect("start server"); + + run_cli(&server, ["new-session", "alpha"]); + let first = run_cli( + &server, + [ + "new-window", + "-t", + "alpha", + "--title", + "editor", + "--", + "/bin/sh", + ], + ); + assert_eq!(stdout(&first).trim(), "0\teditor"); + + let second = run_cli( + &server, + [ + "new-window", + "-t", + "alpha", + "--title", + "logs", + "--", + "/bin/sh", + ], + ); + assert_eq!(stdout(&second).trim(), "1\tlogs"); + + let listed = run_cli(&server, ["list-windows", "-t", "alpha"]); + assert_eq!(stdout(&listed).trim(), "0\t0\teditor\n1\t1\tlogs"); + + run_cli(&server, ["select-window", "-t", "alpha:editor"]); + let listed = run_cli(&server, ["list-windows", "-t", "alpha"]); + assert_eq!(stdout(&listed).trim(), "0\t1\teditor\n1\t0\tlogs"); + + run_cli(&server, ["rename-window", "-t", "alpha:editor", "ops"]); + let listed = run_cli(&server, ["list-windows", "-t", "alpha"]); + assert_eq!(stdout(&listed).trim(), "0\t1\tops\n1\t0\tlogs"); + + run_cli(&server, ["kill-window", "-t", "alpha:ops"]); + + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("connect protocol client"); + let snapshot = session_snapshot_by_name(&mut connection, "alpha").await; + let root = snapshot + .nodes + .iter() + .find(|node| node.id == snapshot.session.root_node_id) + .expect("root node exists"); + if let Some(tabs) = root.tabs.as_ref() { + assert_eq!(tabs.tabs.len(), 1); + assert_eq!(tabs.tabs[0].title, "logs"); + } else { + let buffer_id = root + .buffer_view + .as_ref() + .expect("single remaining root window collapses to a buffer view") + .buffer_id; + let buffer = snapshot + .buffers + .iter() + .find(|buffer| buffer.id == buffer_id) + .expect("root buffer exists"); + assert_eq!(buffer.title, "logs"); + } + + server.shutdown().await.expect("shutdown server"); +} diff --git a/crates/embers-client/Cargo.toml b/crates/embers-client/Cargo.toml new file mode 100644 index 0000000..73bc4a8 --- /dev/null +++ b/crates/embers-client/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "embers-client" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +async-trait.workspace = true +base64.workspace = true +directories.workspace = true +embers-core = { path = "../embers-core" } +embers-protocol = { path = "../embers-protocol" } +rhai.workspace = true +thiserror.workspace = true +tokio.workspace = true +tracing.workspace = true +unicode-segmentation.workspace = true +unicode-width.workspace = true + +[dev-dependencies] +criterion.workspace = true +embers-test-support = { path = "../embers-test-support" } +tempfile.workspace = true + +[[bench]] +name = "search_yank" +harness = false diff --git a/crates/embers-client/benches/search_yank.rs b/crates/embers-client/benches/search_yank.rs new file mode 100644 index 0000000..7cc4be9 --- /dev/null +++ b/crates/embers-client/benches/search_yank.rs @@ -0,0 +1,61 @@ +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use embers_client::{ + SelectionKind, SelectionPoint, SelectionState, configured_client::benchmark_search_matches, + configured_client::benchmark_serialize_selection, +}; + +fn synthetic_lines(count: usize, width: usize) -> Vec { + (0..count) + .map(|index| { + format!( + "{index:05} {} needle {index:05} {}", + "abcd".repeat(width / 8), + "wxyz".repeat(width / 8) + ) + }) + .collect() +} + +fn bench_search(c: &mut Criterion) { + let mut group = c.benchmark_group("search"); + for line_count in [1_000_usize, 10_000, 50_000] { + let lines = synthetic_lines(line_count, 96); + group.bench_with_input( + BenchmarkId::from_parameter(line_count), + &lines, + |b, lines| { + b.iter(|| benchmark_search_matches(lines, "needle")); + }, + ); + } + group.finish(); +} + +fn bench_yank(c: &mut Criterion) { + let mut group = c.benchmark_group("yank"); + for line_count in [1_000_usize, 10_000, 50_000] { + let lines = synthetic_lines(line_count, 96); + let selection = SelectionState { + kind: SelectionKind::Character, + anchor: SelectionPoint { + line: 10, + column: 0, + }, + cursor: SelectionPoint { + line: (line_count.saturating_sub(10)) as u64, + column: 48, + }, + }; + group.bench_with_input( + BenchmarkId::from_parameter(line_count), + &lines, + |b, lines| { + b.iter(|| benchmark_serialize_selection(lines, &selection)); + }, + ); + } + group.finish(); +} + +criterion_group!(benches, bench_search, bench_yank); +criterion_main!(benches); diff --git a/crates/embers-client/src/client.rs b/crates/embers-client/src/client.rs new file mode 100644 index 0000000..98945a9 --- /dev/null +++ b/crates/embers-client/src/client.rs @@ -0,0 +1,261 @@ +use std::collections::BTreeSet; +use std::path::Path; + +use embers_core::{BufferId, IdAllocator, MuxError, RequestId, Result, SessionId}; +use embers_protocol::{ + BufferRequest, ClientMessage, ScrollbackSliceResponse, ServerEvent, ServerResponse, + SessionRequest, SnapshotResponse, SubscribeRequest, +}; + +use crate::socket_transport::SocketTransport; +use crate::state::ClientState; +use crate::transport::Transport; + +#[derive(Debug)] +pub struct MuxClient { + transport: T, + request_ids: IdAllocator, + state: ClientState, +} + +impl MuxClient +where + T: Transport, +{ + pub fn new(transport: T) -> Self { + Self { + transport, + request_ids: IdAllocator::new(1), + state: ClientState::default(), + } + } + + pub fn next_request_id(&self) -> RequestId { + self.request_ids.next() + } + + pub fn state(&self) -> &ClientState { + &self.state + } + + pub fn state_mut(&mut self) -> &mut ClientState { + &mut self.state + } + + pub fn transport(&self) -> &T { + &self.transport + } + + pub async fn request_message(&self, message: ClientMessage) -> Result { + let response = self.transport.request(message).await?; + expect_response(response) + } + + pub async fn subscribe(&self, session_id: Option) -> Result { + let response = self + .request_message(ClientMessage::Subscribe(SubscribeRequest { + request_id: self.next_request_id(), + session_id, + })) + .await?; + match response { + ServerResponse::SubscriptionAck(response) => Ok(response.subscription_id), + other => Err(MuxError::protocol(format!( + "expected subscription ack response, got {other:?}" + ))), + } + } + + pub async fn process_next_event(&mut self) -> Result { + let event = self.transport.next_event().await?; + self.state.apply_event(&event); + self.resync_for_event(&event).await?; + Ok(event) + } + + pub async fn resync_session(&mut self, session_id: SessionId) -> Result<()> { + let response = self + .transport + .request(ClientMessage::Session(SessionRequest::Get { + request_id: self.next_request_id(), + session_id, + })) + .await?; + + match expect_response(response)? { + ServerResponse::SessionSnapshot(response) => { + self.state.apply_session_snapshot(response.snapshot); + Ok(()) + } + other => Err(MuxError::protocol(format!( + "expected session snapshot response, got {other:?}" + ))), + } + } + + pub async fn refresh_buffer_snapshot(&mut self, buffer_id: BufferId) -> Result<()> { + let response = self + .transport + .request(ClientMessage::Buffer(BufferRequest::CaptureVisible { + request_id: self.next_request_id(), + buffer_id, + })) + .await?; + + match expect_response(response)? { + ServerResponse::VisibleSnapshot(snapshot) => { + self.state.apply_buffer_snapshot(snapshot); + Ok(()) + } + other => Err(MuxError::protocol(format!( + "expected visible snapshot response, got {other:?}" + ))), + } + } + + pub async fn capture_buffer(&self, buffer_id: BufferId) -> Result { + let response = self + .transport + .request(ClientMessage::Buffer(BufferRequest::Capture { + request_id: self.next_request_id(), + buffer_id, + })) + .await?; + + match expect_response(response)? { + ServerResponse::Snapshot(snapshot) => Ok(snapshot), + other => Err(MuxError::protocol(format!( + "expected snapshot response, got {other:?}" + ))), + } + } + + pub async fn capture_scrollback_slice( + &self, + buffer_id: BufferId, + start_line: u64, + line_count: u32, + ) -> Result { + let response = self + .transport + .request(ClientMessage::Buffer(BufferRequest::ScrollbackSlice { + request_id: self.next_request_id(), + buffer_id, + start_line, + line_count, + })) + .await?; + + match expect_response(response)? { + ServerResponse::ScrollbackSlice(snapshot) => Ok(snapshot), + other => Err(MuxError::protocol(format!( + "expected scrollback slice response, got {other:?}" + ))), + } + } + + pub async fn resync_dirty_sessions(&mut self) -> Result<()> { + let session_ids = self + .state + .dirty_sessions + .iter() + .copied() + .collect::>(); + for session_id in session_ids { + self.resync_session(session_id).await?; + } + Ok(()) + } + + pub async fn resync_all_sessions(&mut self) -> Result<()> { + let response = self + .transport + .request(ClientMessage::Session(SessionRequest::List { + request_id: self.next_request_id(), + })) + .await?; + + let sessions = match expect_response(response)? { + ServerResponse::Sessions(response) => response.sessions, + other => { + return Err(MuxError::protocol(format!( + "expected sessions response, got {other:?}" + ))); + } + }; + + let live_sessions = sessions + .iter() + .map(|session| session.id) + .collect::>(); + let known_sessions = self.state.sessions.keys().copied().collect::>(); + + for session_id in known_sessions { + if !live_sessions.contains(&session_id) { + self.state.remove_session(session_id); + } + } + + for session in sessions { + self.state.dirty_sessions.insert(session.id); + self.resync_session(session.id).await?; + } + + self.resync_detached_buffers().await + } + + async fn resync_for_event(&mut self, event: &ServerEvent) -> Result<()> { + match event { + ServerEvent::SessionCreated(event) => self.resync_session(event.session.id).await, + ServerEvent::NodeChanged(event) => { + self.resync_session(event.session_id).await?; + self.resync_detached_buffers().await + } + ServerEvent::FloatingChanged(event) => { + self.resync_session(event.session_id).await?; + self.resync_detached_buffers().await + } + ServerEvent::SessionClosed(_) => self.resync_detached_buffers().await, + ServerEvent::BufferCreated(_) + | ServerEvent::BufferDetached(_) + | ServerEvent::FocusChanged(_) + | ServerEvent::RenderInvalidated(_) => Ok(()), + } + } + + async fn resync_detached_buffers(&mut self) -> Result<()> { + let response = self + .transport + .request(ClientMessage::Buffer(BufferRequest::List { + request_id: self.next_request_id(), + session_id: None, + attached_only: false, + detached_only: true, + })) + .await?; + + match expect_response(response)? { + ServerResponse::Buffers(response) => { + self.state.apply_detached_buffers(response.buffers); + Ok(()) + } + other => Err(MuxError::protocol(format!( + "expected buffers response, got {other:?}" + ))), + } + } +} + +impl MuxClient { + pub async fn connect(path: impl AsRef) -> Result { + let transport = SocketTransport::connect(path).await?; + Ok(Self::new(transport)) + } +} + +fn expect_response(response: ServerResponse) -> Result { + match response { + ServerResponse::Error(error) => Err(error.error.into()), + other => Ok(other), + } +} diff --git a/crates/embers-client/src/config/discover.rs b/crates/embers-client/src/config/discover.rs new file mode 100644 index 0000000..d549b9c --- /dev/null +++ b/crates/embers-client/src/config/discover.rs @@ -0,0 +1,236 @@ +use std::env; +use std::fmt; +use std::path::{Path, PathBuf}; + +use directories::ProjectDirs; + +use super::error::{ConfigError, ConfigResult}; + +const APPLICATION_NAME: &str = "embers"; +const CONFIG_FILE_NAME: &str = "config.rhai"; + +pub const CONFIG_ENV_VAR: &str = "EMBERS_CONFIG"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConfigOrigin { + Explicit, + Environment, + Standard, + BuiltIn, +} + +impl fmt::Display for ConfigOrigin { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Explicit => "explicit", + Self::Environment => "environment", + Self::Standard => "standard", + Self::BuiltIn => "built-in", + }) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ConfigDiscoveryOptions { + pub explicit_path: Option, + pub env_path: Option, + pub standard_config_path: Option, +} + +impl ConfigDiscoveryOptions { + pub fn from_process(explicit_path: Option) -> Self { + Self { + explicit_path, + env_path: env::var_os(CONFIG_ENV_VAR).map(PathBuf::from), + standard_config_path: default_config_path(), + } + } + + pub fn with_project_config_dir(mut self, project_config_dir: impl Into) -> Self { + self.standard_config_path = Some(config_file_in_dir(project_config_dir.into())); + self + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DiscoveredConfig { + pub origin: ConfigOrigin, + pub path: Option, +} + +pub fn config_file_in_dir(project_config_dir: impl AsRef) -> PathBuf { + project_config_dir.as_ref().join(CONFIG_FILE_NAME) +} + +pub fn default_config_path() -> Option { + ProjectDirs::from("", "", APPLICATION_NAME) + .map(|project_dirs| config_file_in_dir(project_dirs.config_dir())) +} + +pub fn discover_config(options: &ConfigDiscoveryOptions) -> ConfigResult { + if let Some(path) = options.explicit_path.as_deref() { + return resolve_path(path, ConfigOrigin::Explicit, true); + } + + if let Some(path) = options.env_path.as_deref() { + return resolve_path(path, ConfigOrigin::Environment, true); + } + + if let Some(path) = options.standard_config_path.as_deref() { + return resolve_path(path, ConfigOrigin::Standard, false); + } + + Ok(DiscoveredConfig { + origin: ConfigOrigin::BuiltIn, + path: None, + }) +} + +fn resolve_path( + path: &Path, + origin: ConfigOrigin, + required: bool, +) -> ConfigResult { + match path.try_exists().map_err(|source| ConfigError::PathCheck { + origin, + path: path.to_path_buf(), + source, + })? { + true => Ok(DiscoveredConfig { + origin, + path: Some( + path.canonicalize() + .map_err(|source| ConfigError::Canonicalize { + origin, + path: path.to_path_buf(), + source, + })?, + ), + }), + false if required => Err(ConfigError::MissingConfig { + origin, + path: path.to_path_buf(), + }), + false => Ok(DiscoveredConfig { + origin: ConfigOrigin::BuiltIn, + path: None, + }), + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::PathBuf; + + use tempfile::tempdir; + + use super::{ + CONFIG_ENV_VAR, ConfigDiscoveryOptions, ConfigOrigin, config_file_in_dir, discover_config, + }; + use crate::config::ConfigError; + + #[test] + fn config_file_in_dir_appends_config_name() { + let path = config_file_in_dir(PathBuf::from("/tmp/embers")); + assert_eq!(path, PathBuf::from("/tmp/embers/config.rhai")); + } + + #[test] + fn explicit_override_wins_over_env_and_standard() { + let tempdir = tempdir().unwrap(); + let explicit_path = write_config(tempdir.path().join("explicit.rhai"), "explicit"); + let env_path = write_config(tempdir.path().join("env.rhai"), "env"); + let standard_path = write_config(tempdir.path().join("config.rhai"), "standard"); + let options = ConfigDiscoveryOptions { + explicit_path: Some(explicit_path.clone()), + env_path: Some(env_path), + standard_config_path: Some(standard_path), + }; + + let discovered = discover_config(&options).unwrap(); + + assert_eq!(discovered.origin, ConfigOrigin::Explicit); + assert_eq!(discovered.path, Some(explicit_path.canonicalize().unwrap())); + } + + #[test] + fn env_override_wins_over_standard() { + let tempdir = tempdir().unwrap(); + let env_path = write_config(tempdir.path().join("env.rhai"), "env"); + let standard_path = write_config(tempdir.path().join("config.rhai"), "standard"); + let options = ConfigDiscoveryOptions { + explicit_path: None, + env_path: Some(env_path.clone()), + standard_config_path: Some(standard_path), + }; + + let discovered = discover_config(&options).unwrap(); + + assert_eq!(discovered.origin, ConfigOrigin::Environment); + assert_eq!(discovered.path, Some(env_path.canonicalize().unwrap())); + } + + #[test] + fn missing_implicit_path_uses_builtin_config() { + let tempdir = tempdir().unwrap(); + let options = ConfigDiscoveryOptions::default().with_project_config_dir(tempdir.path()); + + let discovered = discover_config(&options).unwrap(); + + assert_eq!(discovered.origin, ConfigOrigin::BuiltIn); + assert_eq!(discovered.path, None); + } + + #[test] + fn missing_explicit_path_is_an_error() { + let tempdir = tempdir().unwrap(); + let missing = tempdir.path().join("missing.rhai"); + let options = ConfigDiscoveryOptions { + explicit_path: Some(missing.clone()), + env_path: None, + standard_config_path: None, + }; + + let error = discover_config(&options).unwrap_err(); + + assert!(matches!( + error, + ConfigError::MissingConfig { + origin: ConfigOrigin::Explicit, + path, + } if path == missing + )); + } + + #[test] + fn missing_env_path_is_an_error() { + let tempdir = tempdir().unwrap(); + let missing = tempdir.path().join("missing-env.rhai"); + let options = ConfigDiscoveryOptions { + explicit_path: None, + env_path: Some(missing.clone()), + standard_config_path: None, + }; + + let error = discover_config(&options).unwrap_err(); + + assert!(matches!( + error, + ConfigError::MissingConfig { + origin: ConfigOrigin::Environment, + path, + } if path == missing + )); + } + + #[test] + fn process_env_var_name_is_embers_config() { + assert_eq!(CONFIG_ENV_VAR, "EMBERS_CONFIG"); + } + + fn write_config(path: PathBuf, contents: &str) -> PathBuf { + fs::write(&path, contents).unwrap(); + path + } +} diff --git a/crates/embers-client/src/config/error.rs b/crates/embers-client/src/config/error.rs new file mode 100644 index 0000000..21ef928 --- /dev/null +++ b/crates/embers-client/src/config/error.rs @@ -0,0 +1,46 @@ +use std::io; +use std::path::PathBuf; + +use thiserror::Error; + +use super::discover::ConfigOrigin; +use crate::scripting::ScriptError; + +pub type ConfigResult = std::result::Result; + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("{origin} config path '{path}' does not exist")] + MissingConfig { origin: ConfigOrigin, path: PathBuf }, + #[error("{origin} config is missing a canonical path")] + MissingPath { origin: ConfigOrigin }, + #[error("failed to inspect {origin} config path '{path}': {source}")] + PathCheck { + origin: ConfigOrigin, + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("failed to canonicalize {origin} config path '{path}': {source}")] + Canonicalize { + origin: ConfigOrigin, + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("failed to read {origin} config file '{path}': {source}")] + Read { + origin: ConfigOrigin, + path: PathBuf, + #[source] + source: io::Error, + }, +} + +#[derive(Debug, Error)] +pub enum ConfigManagerError { + #[error(transparent)] + Config(#[from] ConfigError), + #[error(transparent)] + Script(#[from] ScriptError), +} diff --git a/crates/embers-client/src/config/loader.rs b/crates/embers-client/src/config/loader.rs new file mode 100644 index 0000000..20f581f --- /dev/null +++ b/crates/embers-client/src/config/loader.rs @@ -0,0 +1,198 @@ +use std::collections::hash_map::DefaultHasher; +use std::fs; +use std::hash::{Hash, Hasher}; +use std::path::PathBuf; + +use super::discover::{ConfigDiscoveryOptions, ConfigOrigin, DiscoveredConfig, discover_config}; +use super::error::{ConfigError, ConfigManagerError, ConfigResult}; +use crate::scripting::ScriptEngine; + +pub const BUILTIN_CONFIG_SOURCE: &str = r#"mouse.set_click_focus(true); +mouse.set_click_forward(true); +mouse.set_wheel_scroll(true); +mouse.set_wheel_forward(true); + +bind("normal", "", action.scroll_page_up()); +bind("normal", "", action.scroll_page_down()); +bind("normal", "/", action.enter_search_mode()); +bind("normal", "n", action.search_next()); +bind("normal", "N", action.search_prev()); +bind("normal", "v", action.enter_select_char()); +bind("normal", "V", action.enter_select_line()); +bind("normal", "", action.enter_select_block()); + +bind("select", "", action.select_move_left()); +bind("select", "", action.select_move_right()); +bind("select", "", action.select_move_up()); +bind("select", "", action.select_move_down()); +bind("select", "h", action.select_move_left()); +bind("select", "j", action.select_move_down()); +bind("select", "k", action.select_move_up()); +bind("select", "l", action.select_move_right()); +bind("select", "y", action.yank_selection()); +bind("select", "", action.cancel_selection()); +"#; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LoadedConfigSource { + pub origin: ConfigOrigin, + pub path: Option, + pub source: String, + pub source_hash: u64, +} + +impl LoadedConfigSource { + pub fn display_path(&self) -> String { + self.path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "".to_owned()) + } +} + +pub struct ConfigManager { + discovery: ConfigDiscoveryOptions, + active_source: LoadedConfigSource, + active_script: ScriptEngine, +} + +impl ConfigManager { + pub fn load(discovery: ConfigDiscoveryOptions) -> Result { + let active_source = load_config_source(&discovery)?; + let active_script = match active_source.origin { + ConfigOrigin::BuiltIn => ScriptEngine::load(&active_source)?, + _ => ScriptEngine::load_with_overlay(BUILTIN_CONFIG_SOURCE, &active_source)?, + }; + Ok(Self { + discovery, + active_source, + active_script, + }) + } + + pub fn from_process(explicit_path: Option) -> Result { + Self::load(ConfigDiscoveryOptions::from_process(explicit_path)) + } + + pub fn discovery(&self) -> &ConfigDiscoveryOptions { + &self.discovery + } + + pub fn active_source(&self) -> &LoadedConfigSource { + &self.active_source + } + + pub fn active_script(&self) -> &ScriptEngine { + &self.active_script + } + + pub fn reload(&mut self) -> Result<(), ConfigManagerError> { + let candidate_source = load_config_source(&self.discovery)?; + let candidate_script = match candidate_source.origin { + ConfigOrigin::BuiltIn => ScriptEngine::load(&candidate_source)?, + _ => ScriptEngine::load_with_overlay(BUILTIN_CONFIG_SOURCE, &candidate_source)?, + }; + self.active_source = candidate_source; + self.active_script = candidate_script; + Ok(()) + } +} + +pub fn load_config_source(discovery: &ConfigDiscoveryOptions) -> ConfigResult { + let discovered = discover_config(discovery)?; + load_discovered_source(&discovered) +} + +fn load_discovered_source(discovered: &DiscoveredConfig) -> ConfigResult { + match discovered.origin { + ConfigOrigin::BuiltIn => Ok(LoadedConfigSource { + origin: ConfigOrigin::BuiltIn, + path: None, + source: BUILTIN_CONFIG_SOURCE.to_owned(), + source_hash: source_hash(BUILTIN_CONFIG_SOURCE), + }), + origin => { + let Some(path) = discovered.path.clone() else { + return Err(ConfigError::MissingPath { origin }); + }; + let source = fs::read_to_string(&path).map_err(|source| ConfigError::Read { + origin, + path: path.clone(), + source, + })?; + Ok(LoadedConfigSource { + origin, + path: Some(path), + source_hash: source_hash(&source), + source, + }) + } + } +} + +fn source_hash(source: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + source.hash(&mut hasher); + hasher.finish() +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::{ + BUILTIN_CONFIG_SOURCE, ConfigDiscoveryOptions, LoadedConfigSource, load_config_source, + }; + use crate::config::ConfigOrigin; + + #[test] + fn builtin_source_loads_when_no_file_exists() { + let loaded = load_config_source(&ConfigDiscoveryOptions::default()).unwrap(); + + assert_eq!( + loaded, + LoadedConfigSource { + origin: ConfigOrigin::BuiltIn, + path: None, + source: BUILTIN_CONFIG_SOURCE.to_owned(), + source_hash: super::source_hash(BUILTIN_CONFIG_SOURCE), + } + ); + assert_eq!(loaded.display_path(), ""); + } + + #[test] + fn explicit_file_loads_source_and_hash() { + let tempdir = tempdir().unwrap(); + let path = tempdir.path().join("config.rhai"); + fs::write(&path, "bind(\"normal\", \"q\", ())").unwrap(); + let options = ConfigDiscoveryOptions { + explicit_path: Some(path.clone()), + env_path: None, + standard_config_path: None, + }; + + let loaded = load_config_source(&options).unwrap(); + + assert_eq!(loaded.origin, ConfigOrigin::Explicit); + assert_eq!(loaded.path, Some(path.canonicalize().unwrap())); + assert_eq!(loaded.source, "bind(\"normal\", \"q\", ())"); + assert_eq!(loaded.source_hash, super::source_hash(&loaded.source)); + } + + #[test] + fn missing_paths_fail_without_panicking() { + let error = super::load_discovered_source(&crate::config::DiscoveredConfig { + origin: ConfigOrigin::Explicit, + path: None, + }) + .expect_err("missing path should error"); + + assert!(matches!( + error, + crate::config::ConfigError::MissingPath { .. } + )); + } +} diff --git a/crates/embers-client/src/config/mod.rs b/crates/embers-client/src/config/mod.rs new file mode 100644 index 0000000..25f794d --- /dev/null +++ b/crates/embers-client/src/config/mod.rs @@ -0,0 +1,10 @@ +mod discover; +mod error; +mod loader; + +pub use discover::{ + CONFIG_ENV_VAR, ConfigDiscoveryOptions, ConfigOrigin, DiscoveredConfig, config_file_in_dir, + default_config_path, discover_config, +}; +pub use error::{ConfigError, ConfigManagerError}; +pub use loader::{BUILTIN_CONFIG_SOURCE, ConfigManager, LoadedConfigSource, load_config_source}; diff --git a/crates/embers-client/src/configured_client.rs b/crates/embers-client/src/configured_client.rs new file mode 100644 index 0000000..fcf9861 --- /dev/null +++ b/crates/embers-client/src/configured_client.rs @@ -0,0 +1,2357 @@ +use std::collections::{BTreeMap, VecDeque}; +use std::path::Path; + +use tracing::warn; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +use embers_core::{ + ActivityState, BufferId, FloatGeometry, MuxError, NodeId, Point, Result, SessionId, Size, +}; +use embers_protocol::{ + BufferRecord, BufferRequest, BufferResponse, ClientMessage, FloatingRequest, InputRequest, + NodeRequest, ServerEvent, ServerResponse, +}; + +use crate::RenderGrid; +use crate::client::MuxClient; +use crate::config::ConfigManager; +use crate::controller::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use crate::input::{ + FallbackPolicy, InputResolution, InputState, KeyToken, NORMAL_MODE, SEARCH_MODE, SELECT_MODE, + resolve_key, +}; +use crate::presentation::{LeafFrame, NavigationDirection, PresentationModel}; +use crate::renderer::Renderer; +use crate::scripting::{ + Action, BarSpec, Context, EventInfo, FloatingAnchor, FloatingGeometrySpec, FloatingSize, + NotifyLevel, TabBarContext, TreeSpec, +}; +use crate::state::{SearchMatch, SearchState, SelectionKind, SelectionPoint, SelectionState}; +use crate::transport::Transport; + +const WHEEL_SCROLL_LINES: u64 = 3; +const MAX_EXPANDED_ACTIONS: usize = 256; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct SearchPrompt { + node_id: NodeId, + query: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ResolvedTreeBuffer { + Existing(BufferId), + NewlySpawned(BufferId), +} + +impl ResolvedTreeBuffer { + fn id(self) -> BufferId { + match self { + Self::Existing(buffer_id) | Self::NewlySpawned(buffer_id) => buffer_id, + } + } + + fn created_by_helper(self) -> Option { + match self { + Self::Existing(_) => None, + Self::NewlySpawned(buffer_id) => Some(buffer_id), + } + } +} + +pub struct ConfiguredClient { + client: MuxClient, + config: ConfigManager, + input_state: InputState, + renderer: Renderer, + notifications: Vec, + active_session_id: Option, + viewport: Option, + search_prompt: Option, + terminal_output: VecDeque>, +} + +impl ConfiguredClient +where + T: Transport, +{ + pub fn new(client: MuxClient, config: ConfigManager) -> Self { + Self { + client, + config, + input_state: InputState::default(), + renderer: Renderer, + notifications: Vec::new(), + active_session_id: None, + viewport: None, + search_prompt: None, + terminal_output: VecDeque::new(), + } + } + + pub fn client(&self) -> &MuxClient { + &self.client + } + + pub fn client_mut(&mut self) -> &mut MuxClient { + &mut self.client + } + + pub fn config(&self) -> &ConfigManager { + &self.config + } + + pub fn notifications(&self) -> &[String] { + &self.notifications + } + + pub fn drain_terminal_output(&mut self) -> Vec> { + self.terminal_output.drain(..).collect() + } + + pub fn status_line(&self, session_id: SessionId, socket_path: &Path) -> String { + let session_name = self + .client + .state() + .sessions + .get(&session_id) + .map(|session| session.name.as_str()) + .unwrap_or(""); + if let Some(prompt) = &self.search_prompt { + return format!("[{session_name}] /{}", prompt.query); + } + match self.notifications.last() { + Some(message) => format!("[{session_name}] {message}"), + None => format!("[{session_name}] {} ctrl-q quit", socket_path.display()), + } + } + + pub async fn handle_key( + &mut self, + session_id: SessionId, + viewport: Size, + key: KeyEvent, + ) -> Result<()> { + self.set_active_view(session_id, viewport); + let presentation = self.prepare_presentation(session_id, viewport).await?; + + if self.input_state.current_mode() == SEARCH_MODE { + return self + .handle_search_key(session_id, viewport, &presentation, key) + .await; + } + + match key { + KeyEvent::Bytes(bytes) => { + if self.current_fallback_policy() != FallbackPolicy::Passthrough { + return Ok(()); + } + let buffer_id = self.resolve_buffer_id(None, &presentation)?; + self.send_bytes_to_buffer(buffer_id, session_id, bytes) + .await?; + Ok(()) + } + other => { + let token = key_event_to_token(other)?; + match resolve_key( + &self.config.active_script().loaded_config().bindings, + &self.config.active_script().loaded_config().modes, + &mut self.input_state, + token, + ) { + InputResolution::ExactMatch(binding) => { + if self.should_passthrough_binding_in_alternate_screen( + &presentation, + &binding.target, + ) { + let buffer_id = self.resolve_buffer_id(None, &presentation)?; + return self + .send_bytes_to_buffer( + buffer_id, + session_id, + sequence_to_bytes(&binding.sequence)?, + ) + .await; + } + self.execute_actions( + Some(session_id), + Some(viewport), + binding.target.clone(), + ) + .await + } + InputResolution::PrefixMatch => Ok(()), + InputResolution::Unmatched { + sequence, + fallback_policy, + .. + } => match fallback_policy { + FallbackPolicy::Passthrough => { + let buffer_id = self.resolve_buffer_id(None, &presentation)?; + self.send_bytes_to_buffer( + buffer_id, + session_id, + sequence_to_bytes(&sequence)?, + ) + .await + } + FallbackPolicy::Ignore => Ok(()), + }, + } + } + } + } + + pub async fn handle_paste( + &mut self, + session_id: SessionId, + viewport: Size, + bytes: Vec, + ) -> Result<()> { + self.set_active_view(session_id, viewport); + if self.input_state.current_mode() == SEARCH_MODE { + return self.handle_search_paste(session_id, viewport, bytes).await; + } + if self.current_fallback_policy() != FallbackPolicy::Passthrough { + return Ok(()); + } + + let presentation = self.prepare_presentation(session_id, viewport).await?; + let buffer_id = self.resolve_buffer_id(None, &presentation)?; + let bytes = if self + .client + .state() + .snapshots + .get(&buffer_id) + .is_some_and(|snapshot| snapshot.bracketed_paste) + { + let mut wrapped = b"\x1b[200~".to_vec(); + wrapped.extend(bytes); + wrapped.extend_from_slice(b"\x1b[201~"); + wrapped + } else { + bytes + }; + self.send_bytes_to_buffer(buffer_id, session_id, bytes) + .await + } + + pub async fn handle_mouse( + &mut self, + session_id: SessionId, + viewport: Size, + event: MouseEvent, + ) -> Result<()> { + self.set_active_view(session_id, viewport); + let presentation = self.prepare_presentation(session_id, viewport).await?; + let point = Point { + x: i32::from(event.column), + y: i32::from(event.row), + }; + let Some(target_leaf) = self.mouse_target_leaf(&presentation, point).cloned() else { + return Ok(()); + }; + + let settings = self.config.active_script().loaded_config().mouse; + let target_snapshot = self.client.state().snapshots.get(&target_leaf.buffer_id); + let mouse_reporting = target_snapshot.is_some_and(|snapshot| snapshot.mouse_reporting); + let point_in_content = point.y > target_leaf.rect.origin.y; + + match event.kind { + MouseEventKind::WheelUp | MouseEventKind::WheelDown => { + if mouse_reporting && settings.wheel_forward && point_in_content { + return self + .send_bytes_to_buffer( + target_leaf.buffer_id, + session_id, + encode_mouse_event(&target_leaf, event)?, + ) + .await; + } + if settings.wheel_scroll && !self.view_is_alternate_screen(target_leaf.node_id) { + let delta = match event.kind { + MouseEventKind::WheelUp => -(WHEEL_SCROLL_LINES as i64), + MouseEventKind::WheelDown => WHEEL_SCROLL_LINES as i64, + _ => 0, + }; + return self.scroll_view_by(target_leaf.node_id, delta).await; + } + Ok(()) + } + MouseEventKind::Press(_) | MouseEventKind::Release(_) | MouseEventKind::Drag(_) => { + if settings.click_focus && !target_leaf.focused { + self.focus_node(session_id, target_leaf.node_id).await?; + } + if settings.click_forward && mouse_reporting && point_in_content { + self.send_bytes_to_buffer( + target_leaf.buffer_id, + session_id, + encode_mouse_event(&target_leaf, event)?, + ) + .await?; + } + Ok(()) + } + } + } + + pub async fn handle_focus_event( + &mut self, + session_id: SessionId, + viewport: Size, + focused: bool, + ) -> Result<()> { + self.set_active_view(session_id, viewport); + let presentation = self.prepare_presentation(session_id, viewport).await?; + let buffer_id = self.resolve_buffer_id(None, &presentation)?; + if !self + .client + .state() + .snapshots + .get(&buffer_id) + .is_some_and(|snapshot| snapshot.focus_reporting) + { + return Ok(()); + } + + let bytes = if focused { + b"\x1b[I".to_vec() + } else { + b"\x1b[O".to_vec() + }; + self.send_bytes_to_buffer(buffer_id, session_id, bytes) + .await + } + + pub async fn process_next_event(&mut self) -> Result { + let event = self.client.process_next_event().await?; + if let ServerEvent::RenderInvalidated(event) = &event { + self.client.refresh_buffer_snapshot(event.buffer_id).await?; + } + + let session_id = self.event_session_id(&event); + let mut event_names = vec![event_name(&event).to_owned()]; + if let ServerEvent::RenderInvalidated(render) = &event + && self + .client + .state() + .buffers + .get(&render.buffer_id) + .is_some_and(|buffer| buffer.activity == ActivityState::Bell) + { + event_names.push("buffer_bell".to_owned()); + } + + for event_name in event_names { + let context = self.context_for( + session_id, + self.viewport, + Some(event_info(&event_name, &event)), + ); + match self + .config + .active_script() + .dispatch_event(&event_name, context) + { + Ok(actions) if !actions.is_empty() => { + self.execute_actions(session_id, self.viewport, actions) + .await?; + } + Ok(_) => {} + Err(error) => self.record_notification(error.to_string()), + } + } + Ok(event) + } + + pub async fn render_session( + &mut self, + session_id: SessionId, + viewport: Size, + ) -> Result { + self.set_active_view(session_id, viewport); + let presentation = self.prepare_presentation(session_id, viewport).await?; + let mut custom_bars = BTreeMap::::new(); + let mut recorded_formatter_error = false; + for tabs in &presentation.tab_bars { + let bar_context = + TabBarContext::from_frame(tabs, self.input_state.current_mode(), viewport.width); + let result = self.config.active_script().format_tab_bar(bar_context); + + match result { + Ok(Some(bar)) => { + custom_bars.insert(tabs.node_id, bar); + } + Ok(None) => {} + Err(error) if !recorded_formatter_error => { + recorded_formatter_error = true; + self.record_notification(error.to_string()); + } + Err(_) => {} + } + } + + Ok(self + .renderer + .render_with_tab_bars(self.client.state(), &presentation, &custom_bars)) + } + + pub fn reload_config(&mut self) -> Result<()> { + let current_mode = self.input_state.current_mode().to_owned(); + self.config + .reload() + .map_err(|error| MuxError::invalid_input(error.to_string()))?; + if self + .config + .active_script() + .loaded_config() + .modes + .contains_key(¤t_mode) + { + self.input_state.clear_pending(); + } else { + self.input_state.set_mode(NORMAL_MODE); + } + Ok(()) + } + + async fn execute_actions( + &mut self, + session_id: Option, + viewport: Option, + actions: Vec, + ) -> Result<()> { + let mut pending = VecDeque::from(actions); + let mut expansions = 0usize; + while let Some(action) = pending.pop_front() { + let result = match action { + Action::Noop => Ok(()), + Action::Chain(actions) => { + prepend_actions_with_limit(&mut pending, actions, &mut expansions) + } + Action::RunNamedAction { name } => { + match self + .config + .active_script() + .run_named_action(&name, self.context_for(session_id, viewport, None)) + { + Ok(actions) => { + prepend_actions_with_limit(&mut pending, actions, &mut expansions) + } + Err(error) => Err(MuxError::invalid_input(error.to_string())), + } + } + Action::EnterMode { mode } => { + let actions = self.transition_mode(mode, session_id, viewport).await?; + prepend_actions_with_limit(&mut pending, actions, &mut expansions) + } + Action::LeaveMode => { + let actions = self + .transition_mode(NORMAL_MODE.to_owned(), session_id, viewport) + .await?; + prepend_actions_with_limit(&mut pending, actions, &mut expansions) + } + Action::ToggleMode { mode } => { + let next_mode = if self.input_state.current_mode() == mode { + NORMAL_MODE.to_owned() + } else { + mode + }; + let actions = self + .transition_mode(next_mode, session_id, viewport) + .await?; + prepend_actions_with_limit(&mut pending, actions, &mut expansions) + } + Action::ClearPendingKeys => { + self.input_state.clear_pending(); + Ok(()) + } + Action::Notify { level, message } => { + self.record_notification(format_notification(level, &message)); + Ok(()) + } + action => { + let Some((session_id, viewport)) = session_id.zip(viewport) else { + continue; + }; + let presentation = self.prepare_presentation(session_id, viewport).await?; + self.execute_action(session_id, viewport, &presentation, action) + .await + } + }; + if let Err(error) = result { + self.record_notification(error.to_string()); + } + } + Ok(()) + } + + async fn transition_mode( + &mut self, + mode: String, + session_id: Option, + viewport: Option, + ) -> Result> { + if !self + .config + .active_script() + .loaded_config() + .modes + .contains_key(&mode) + { + return Err(MuxError::invalid_input(format!("unknown mode '{mode}'"))); + } + + let previous_mode = self.input_state.current_mode().to_owned(); + if previous_mode == mode { + return Ok(Vec::new()); + } + + let mut actions = self + .config + .active_script() + .run_leave_hook(&previous_mode, self.context_for(session_id, viewport, None)) + .map_err(|error| MuxError::invalid_input(error.to_string()))?; + self.input_state.set_mode(mode.clone()); + actions.extend( + self.config + .active_script() + .run_enter_hook(&mode, self.context_for(session_id, viewport, None)) + .map_err(|error| { + self.input_state.set_mode(previous_mode.clone()); + MuxError::invalid_input(error.to_string()) + })?, + ); + Ok(actions) + } + + async fn execute_action( + &mut self, + session_id: SessionId, + viewport: Size, + presentation: &PresentationModel, + action: Action, + ) -> Result<()> { + match action { + Action::FocusDirection { direction } => { + let Some(node_id) = presentation.focus_target(direction) else { + return Ok(()); + }; + self.focus_node(session_id, node_id).await + } + Action::ResizeDirection { .. } => Err(MuxError::invalid_input( + "resize actions are not implemented yet", + )), + Action::ScrollLineUp => { + let leaf = self.focused_leaf(presentation)?; + self.scroll_view_by(leaf.node_id, -1).await + } + Action::ScrollLineDown => { + let leaf = self.focused_leaf(presentation)?; + self.scroll_view_by(leaf.node_id, 1).await + } + Action::ScrollPageUp => { + let leaf = self.focused_leaf(presentation)?; + let page = self + .client + .state() + .view_state(leaf.node_id) + .map(|state| i64::from(state.visible_line_count.max(1))) + .unwrap_or(1); + self.scroll_view_by(leaf.node_id, -page).await + } + Action::ScrollPageDown => { + let leaf = self.focused_leaf(presentation)?; + let page = self + .client + .state() + .view_state(leaf.node_id) + .map(|state| i64::from(state.visible_line_count.max(1))) + .unwrap_or(1); + self.scroll_view_by(leaf.node_id, page).await + } + Action::ScrollToTop => { + let leaf = self.focused_leaf(presentation)?; + self.set_view_scroll_top(leaf.node_id, 0).await + } + Action::ScrollToBottom | Action::FollowOutput => { + let leaf = self.focused_leaf(presentation)?; + self.follow_output_for_view(leaf.node_id).await + } + Action::EnterSearchMode => { + self.enter_search_mode(session_id, viewport, presentation) + .await + } + Action::SearchNext => self.navigate_search(presentation, true).await, + Action::SearchPrev => self.navigate_search(presentation, false).await, + Action::CancelSearch => self.cancel_search_prompt(session_id, viewport).await, + Action::EnterSelect { kind } => { + self.enter_select_mode(session_id, viewport, presentation, kind) + .await + } + Action::SelectMove { direction } => { + let leaf = self.focused_leaf(presentation)?; + self.move_selection(leaf.node_id, direction).await + } + Action::CopySelection => { + let leaf = self.focused_leaf(presentation)?; + self.copy_selection(session_id, viewport, leaf.node_id) + .await + } + Action::CancelSelection => { + let leaf = self.focused_leaf(presentation)?; + self.cancel_selection(session_id, viewport, leaf.node_id) + .await + } + Action::SelectTab { + tabs_node_id, + index, + } => { + let tabs = self.resolve_tabs_target(presentation, tabs_node_id)?; + if index >= tabs.tabs.len() { + return Err(MuxError::invalid_input(format!( + "tab index {index} is out of range for {} tabs", + tabs.tabs.len() + ))); + } + let index = u32::try_from(index).map_err(|_| { + MuxError::invalid_input(format!("tab index {index} exceeds protocol limits")) + })?; + self.client + .request_message(ClientMessage::Node(NodeRequest::SelectTab { + request_id: self.client.next_request_id(), + tabs_node_id: tabs.node_id, + index, + })) + .await?; + self.client.resync_session(session_id).await + } + Action::NextTab { tabs_node_id } => { + let tabs = self.resolve_tabs_target(presentation, tabs_node_id)?; + if tabs.tabs.is_empty() { + return Ok(()); + } + let index = (tabs.active + 1) % tabs.tabs.len(); + self.client + .request_message(ClientMessage::Node(NodeRequest::SelectTab { + request_id: self.client.next_request_id(), + tabs_node_id: tabs.node_id, + index: u32::try_from(index).map_err(|_| { + MuxError::invalid_input("tab index exceeds protocol limits") + })?, + })) + .await?; + self.client.resync_session(session_id).await + } + Action::PrevTab { tabs_node_id } => { + let tabs = self.resolve_tabs_target(presentation, tabs_node_id)?; + if tabs.tabs.is_empty() { + return Ok(()); + } + let index = if tabs.active == 0 { + tabs.tabs.len() - 1 + } else { + tabs.active - 1 + }; + self.client + .request_message(ClientMessage::Node(NodeRequest::SelectTab { + request_id: self.client.next_request_id(), + tabs_node_id: tabs.node_id, + index: u32::try_from(index).map_err(|_| { + MuxError::invalid_input("tab index exceeds protocol limits") + })?, + })) + .await?; + self.client.resync_session(session_id).await + } + Action::SplitCurrent { + direction, + new_child, + } => { + let focused_leaf = presentation + .focused_leaf() + .ok_or_else(|| MuxError::invalid_input("no focused leaf to split"))?; + let buffer = self + .resolve_tree_buffer(session_id, presentation, new_child) + .await?; + let result = self + .client + .request_message(ClientMessage::Node(NodeRequest::Split { + request_id: self.client.next_request_id(), + leaf_node_id: focused_leaf.node_id, + direction, + new_buffer_id: buffer.id(), + })) + .await; + rollback_created_buffer_on_error( + self, + buffer.created_by_helper(), + "split pane", + result, + ) + .await?; + self.client.resync_all_sessions().await + } + Action::OpenFloating { spec } => { + let buffer = self + .resolve_tree_buffer(session_id, presentation, spec.tree) + .await?; + let result = self + .client + .request_message(ClientMessage::Floating(FloatingRequest::Create { + request_id: self.client.next_request_id(), + session_id, + root_node_id: None, + buffer_id: Some(buffer.id()), + geometry: resolve_floating_geometry(spec.geometry, viewport), + title: spec.title, + focus: spec.focus, + close_on_empty: spec.close_on_empty, + })) + .await; + rollback_created_buffer_on_error( + self, + buffer.created_by_helper(), + "open floating window", + result, + ) + .await?; + self.client.resync_all_sessions().await + } + Action::DetachBuffer { buffer_id } => { + let buffer_id = self.resolve_buffer_id(buffer_id, presentation)?; + self.client + .request_message(ClientMessage::Buffer(BufferRequest::Detach { + request_id: self.client.next_request_id(), + buffer_id, + })) + .await?; + self.client.resync_all_sessions().await + } + Action::KillBuffer { buffer_id } => { + let buffer_id = self.resolve_buffer_id(buffer_id, presentation)?; + self.client + .request_message(ClientMessage::Buffer(BufferRequest::Kill { + request_id: self.client.next_request_id(), + buffer_id, + force: false, + })) + .await?; + self.client.resync_all_sessions().await + } + Action::SendKeys { buffer_id, keys } => { + let buffer_id = self.resolve_buffer_id(buffer_id, presentation)?; + self.send_bytes_to_buffer(buffer_id, session_id, sequence_to_bytes(&keys)?) + .await + } + Action::SendBytes { buffer_id, bytes } => { + let buffer_id = self.resolve_buffer_id(buffer_id, presentation)?; + self.send_bytes_to_buffer(buffer_id, session_id, bytes) + .await + } + Action::CloseFloating { floating_id } => { + let floating_id = floating_id + .or_else(|| presentation.focused_floating_id()) + .ok_or_else(|| MuxError::invalid_input("no floating window is focused"))?; + self.client + .request_message(ClientMessage::Floating(FloatingRequest::Close { + request_id: self.client.next_request_id(), + floating_id, + })) + .await?; + self.client.resync_all_sessions().await + } + Action::CloseView { node_id } => { + let node_id = node_id + .or_else(|| presentation.focused_leaf().map(|leaf| leaf.node_id)) + .ok_or_else(|| MuxError::invalid_input("no focused node to close"))?; + self.client + .request_message(ClientMessage::Node(NodeRequest::Close { + request_id: self.client.next_request_id(), + node_id, + })) + .await?; + self.client.resync_all_sessions().await + } + Action::InsertTabAfter { + tabs_node_id, + title, + child, + } => { + let tabs = self.resolve_tabs_target(presentation, tabs_node_id)?; + let buffer = self + .resolve_tree_buffer(session_id, presentation, child) + .await?; + let result = self + .client + .request_message(ClientMessage::Node(NodeRequest::AddTab { + request_id: self.client.next_request_id(), + tabs_node_id: tabs.node_id, + title: title.unwrap_or_else(|| "tab".to_owned()), + buffer_id: Some(buffer.id()), + child_node_id: None, + index: u32::try_from(tabs.active.saturating_add(1)).map_err(|_| { + MuxError::invalid_input("tab index exceeds protocol limits") + })?, + })) + .await; + rollback_created_buffer_on_error( + self, + buffer.created_by_helper(), + "insert tab", + result, + ) + .await?; + self.client.resync_all_sessions().await + } + Action::InsertTabBefore { + tabs_node_id, + title, + child, + } => { + let tabs = self.resolve_tabs_target(presentation, tabs_node_id)?; + let buffer = self + .resolve_tree_buffer(session_id, presentation, child) + .await?; + let result = self + .client + .request_message(ClientMessage::Node(NodeRequest::AddTab { + request_id: self.client.next_request_id(), + tabs_node_id: tabs.node_id, + title: title.unwrap_or_else(|| "tab".to_owned()), + buffer_id: Some(buffer.id()), + child_node_id: None, + index: u32::try_from(tabs.active).map_err(|_| { + MuxError::invalid_input("tab index exceeds protocol limits") + })?, + })) + .await; + rollback_created_buffer_on_error( + self, + buffer.created_by_helper(), + "insert tab", + result, + ) + .await?; + self.client.resync_all_sessions().await + } + Action::ReplaceNode { node_id, tree } => { + let node_id = node_id + .or_else(|| presentation.focused_leaf().map(|leaf| leaf.node_id)) + .ok_or_else(|| MuxError::invalid_input("no focused node to replace"))?; + let buffer = self + .resolve_tree_buffer(session_id, presentation, tree) + .await?; + let result = self + .client + .request_message(ClientMessage::Node(NodeRequest::MoveBufferToNode { + request_id: self.client.next_request_id(), + buffer_id: buffer.id(), + target_leaf_node_id: node_id, + })) + .await; + rollback_created_buffer_on_error( + self, + buffer.created_by_helper(), + "replace node buffer", + result, + ) + .await?; + self.client.resync_all_sessions().await + } + Action::MoveBufferToNode { buffer_id, node_id } => { + self.client + .request_message(ClientMessage::Node(NodeRequest::MoveBufferToNode { + request_id: self.client.next_request_id(), + buffer_id, + target_leaf_node_id: node_id, + })) + .await?; + self.client.resync_all_sessions().await + } + Action::MoveBufferToFloating { + buffer_id, + geometry, + title, + focus, + } => { + self.client + .request_message(ClientMessage::Floating(FloatingRequest::Create { + request_id: self.client.next_request_id(), + session_id, + root_node_id: None, + buffer_id: Some(buffer_id), + geometry: resolve_floating_geometry(geometry, viewport), + title, + focus, + close_on_empty: true, + })) + .await?; + self.client.resync_all_sessions().await + } + Action::FocusBuffer { buffer_id } | Action::RevealBuffer { buffer_id } => { + self.focus_buffer(session_id, buffer_id).await + } + Action::ReplaceFloatingRoot { .. } + | Action::WrapNodeInSplit { .. } + | Action::WrapNodeInTabs { .. } => Err(MuxError::invalid_input(format!( + "action '{action:?}' is not supported by the live executor yet" + ))), + other => Err(MuxError::invalid_input(format!( + "action '{other:?}' is not supported by the live executor yet" + ))), + } + } + + async fn resolve_tree_buffer( + &self, + _session_id: SessionId, + presentation: &PresentationModel, + tree: TreeSpec, + ) -> Result { + match tree { + TreeSpec::BufferCurrent => presentation + .focused_buffer_id() + .map(ResolvedTreeBuffer::Existing) + .ok_or_else(|| MuxError::invalid_input("no current buffer is focused")), + TreeSpec::BufferAttach { buffer_id } => Ok(ResolvedTreeBuffer::Existing(buffer_id)), + TreeSpec::BufferSpawn(spec) => self + .create_buffer(spec) + .await + .map(ResolvedTreeBuffer::NewlySpawned), + TreeSpec::BufferEmpty => self + .create_buffer(crate::scripting::BufferSpawnSpec { + title: Some("shell".to_owned()), + command: default_shell_command(), + cwd: None, + env: Default::default(), + }) + .await + .map(ResolvedTreeBuffer::NewlySpawned), + other => Err(MuxError::invalid_input(format!( + "tree '{other:?}' is not supported by the live executor yet" + ))), + } + } + + async fn create_buffer(&self, spec: crate::scripting::BufferSpawnSpec) -> Result { + let response = self + .client + .request_message(ClientMessage::Buffer(BufferRequest::Create { + request_id: self.client.next_request_id(), + title: spec.title, + command: spec.command, + cwd: spec.cwd, + env: spec.env, + })) + .await?; + + match response { + ServerResponse::Buffer(BufferResponse { buffer, .. }) => Ok(buffer.id), + other => Err(MuxError::protocol(format!( + "expected buffer response, got {other:?}" + ))), + } + } + + async fn rollback_created_buffer(&mut self, buffer_id: BufferId, operation: &str) { + if let Err(error) = self + .client + .request_message(ClientMessage::Buffer(BufferRequest::Detach { + request_id: self.client.next_request_id(), + buffer_id, + })) + .await + { + warn!( + %buffer_id, + %error, + operation, + "failed to detach created buffer during rollback" + ); + } + if let Err(error) = self + .client + .request_message(ClientMessage::Buffer(BufferRequest::Kill { + request_id: self.client.next_request_id(), + buffer_id, + force: true, + })) + .await + { + warn!( + %buffer_id, + %error, + operation, + "failed to kill created buffer during rollback" + ); + } + } + + async fn send_bytes_to_buffer( + &mut self, + buffer_id: BufferId, + session_id: SessionId, + bytes: Vec, + ) -> Result<()> { + self.client + .request_message(ClientMessage::Input(InputRequest::Send { + request_id: self.client.next_request_id(), + buffer_id, + bytes, + })) + .await?; + self.client.refresh_buffer_snapshot(buffer_id).await?; + self.client.resync_session(session_id).await + } + + fn resolve_buffer_id( + &self, + buffer_id: Option, + presentation: &PresentationModel, + ) -> Result { + match buffer_id { + Some(buffer_id) => Ok(buffer_id), + None => presentation + .focused_buffer_id() + .ok_or_else(|| MuxError::invalid_input("no current buffer is focused")), + } + } + + fn resolve_tabs_target( + &self, + presentation: &PresentationModel, + tabs_node_id: Option, + ) -> Result { + match tabs_node_id { + Some(tabs_node_id) => presentation + .tab_bars + .iter() + .find(|tabs| tabs.node_id == tabs_node_id) + .cloned() + .or_else(|| { + self.client + .state() + .nodes + .get(&tabs_node_id) + .and_then(|node| { + node.tabs.as_ref().map(|tabs| crate::TabsFrame { + node_id: tabs_node_id, + rect: embers_core::Rect::default(), + tabs: tabs + .tabs + .iter() + .enumerate() + .map(|(index, tab)| crate::TabItem { + title: tab.title.clone(), + child_id: tab.child_id, + active: usize::try_from(tabs.active).ok() == Some(index), + activity: ActivityState::Idle, + buffer_count: crate::presentation::subtree_buffer_count( + self.client.state(), + tab.child_id, + ), + }) + .collect(), + active: usize::try_from(tabs.active).unwrap_or(0), + is_root: self + .client + .state() + .sessions + .values() + .any(|session| session.root_node_id == tabs_node_id), + floating_id: None, + }) + }) + }) + .ok_or_else(|| MuxError::invalid_input(format!("node {tabs_node_id} is not tabs"))), + None => presentation + .focused_tabs() + .cloned() + .ok_or_else(|| MuxError::invalid_input("no focused tabs to select from")), + } + } + + async fn prepare_presentation( + &mut self, + session_id: SessionId, + viewport: Size, + ) -> Result { + let mut presentation = + PresentationModel::project(self.client.state(), session_id, viewport)?; + let invalidated = presentation + .leaves + .iter() + .filter(|leaf| { + self.client + .state() + .invalidated_buffers + .contains(&leaf.buffer_id) + }) + .map(|leaf| leaf.buffer_id) + .collect::>(); + for buffer_id in invalidated { + self.client.refresh_buffer_snapshot(buffer_id).await?; + } + if !self.client.state().invalidated_buffers.is_empty() { + presentation = PresentationModel::project(self.client.state(), session_id, viewport)?; + } + self.refresh_local_viewports(&presentation).await?; + Ok(presentation) + } + + fn context_for( + &self, + session_id: Option, + viewport: Option, + event: Option, + ) -> Context { + let context = if let Some((session_id, viewport)) = session_id.zip(viewport) + && let Ok(presentation) = + PresentationModel::project(self.client.state(), session_id, viewport) + { + Context::from_state_with_mode( + self.client.state(), + Some(&presentation), + self.input_state.current_mode(), + None, + None, + None, + None, + ) + } else { + Context::from_state_with_mode( + self.client.state(), + None, + self.input_state.current_mode(), + session_id, + None, + None, + None, + ) + }; + if let Some(event) = event { + context.with_event(event) + } else { + context + } + } + + fn event_session_id(&self, event: &ServerEvent) -> Option { + event.session_id().or_else(|| match event { + ServerEvent::BufferCreated(event) => self.session_id_for_buffer_record(&event.buffer), + ServerEvent::BufferDetached(event) => self.session_id_for_buffer(event.buffer_id), + ServerEvent::RenderInvalidated(event) => self.session_id_for_buffer(event.buffer_id), + ServerEvent::SessionCreated(_) + | ServerEvent::SessionClosed(_) + | ServerEvent::NodeChanged(_) + | ServerEvent::FloatingChanged(_) + | ServerEvent::FocusChanged(_) => None, + }) + } + + fn session_id_for_buffer_record(&self, buffer: &BufferRecord) -> Option { + buffer + .attachment_node_id + .and_then(|node_id| self.session_id_for_node(node_id)) + .or_else(|| self.session_id_for_buffer(buffer.id)) + } + + fn session_id_for_buffer(&self, buffer_id: BufferId) -> Option { + let state = self.client.state(); + state + .buffers + .get(&buffer_id) + .and_then(|buffer| buffer.attachment_node_id) + .and_then(|node_id| state.nodes.get(&node_id)) + .map(|node| node.session_id) + .or_else(|| { + state.nodes.values().find_map(|node| { + node.buffer_view + .as_ref() + .filter(|view| view.buffer_id == buffer_id) + .map(|_| node.session_id) + }) + }) + } + + fn session_id_for_node(&self, node_id: NodeId) -> Option { + self.client + .state() + .nodes + .get(&node_id) + .map(|node| node.session_id) + } + + fn set_active_view(&mut self, session_id: SessionId, viewport: Size) { + self.active_session_id = Some(session_id); + self.viewport = Some(viewport); + } + + fn current_fallback_policy(&self) -> FallbackPolicy { + self.config + .active_script() + .loaded_config() + .modes + .get(self.input_state.current_mode()) + .map(|mode| mode.fallback_policy) + .unwrap_or(FallbackPolicy::Ignore) + } + + async fn focus_buffer(&mut self, session_id: SessionId, buffer_id: BufferId) -> Result<()> { + let node_id = self + .client + .state() + .buffers + .get(&buffer_id) + .and_then(|buffer| buffer.attachment_node_id) + .ok_or_else(|| MuxError::invalid_input(format!("buffer {buffer_id} is detached")))?; + + let mut selections = Vec::new(); + let mut child_id = node_id; + let mut parent_id = self + .client + .state() + .nodes + .get(&node_id) + .and_then(|node| node.parent_id); + while let Some(current_parent) = parent_id { + if let Some(tabs) = self + .client + .state() + .nodes + .get(¤t_parent) + .and_then(|node| node.tabs.as_ref()) + && let Some(index) = tabs.tabs.iter().position(|tab| tab.child_id == child_id) + { + selections.push((current_parent, index)); + } + child_id = current_parent; + parent_id = self + .client + .state() + .nodes + .get(¤t_parent) + .and_then(|node| node.parent_id); + } + selections.reverse(); + + for (tabs_node_id, index) in selections { + self.client + .request_message(ClientMessage::Node(NodeRequest::SelectTab { + request_id: self.client.next_request_id(), + tabs_node_id, + index: u32::try_from(index).map_err(|_| { + MuxError::invalid_input("tab index exceeds protocol limits") + })?, + })) + .await?; + } + self.focus_node(session_id, node_id).await + } + + fn focused_leaf<'a>(&self, presentation: &'a PresentationModel) -> Result<&'a LeafFrame> { + presentation + .focused_leaf() + .ok_or_else(|| MuxError::invalid_input("no focused leaf")) + } + + fn mouse_target_leaf<'a>( + &self, + presentation: &'a PresentationModel, + point: Point, + ) -> Option<&'a LeafFrame> { + if let Some(floating) = presentation.floating_at(point) { + return presentation.leaves.iter().rev().find(|leaf| { + leaf.floating_id == Some(floating.floating_id) && leaf.rect.contains(point) + }); + } + presentation.leaf_at(point) + } + + fn view_is_alternate_screen(&self, node_id: NodeId) -> bool { + self.client + .state() + .view_state(node_id) + .is_some_and(|state| state.alternate_screen) + } + + fn should_passthrough_binding_in_alternate_screen( + &self, + presentation: &PresentationModel, + actions: &[Action], + ) -> bool { + let Some(leaf) = presentation.focused_leaf() else { + return false; + }; + let Some(view_state) = self.client.state().view_state(leaf.node_id) else { + return false; + }; + (view_state.alternate_screen && actions.iter().all(action_is_local_terminal_action)) + || (self.input_state.current_mode() == NORMAL_MODE + && view_state.follow_output + && view_state.search_state.is_none() + && view_state.selection_state.is_none() + && actions.iter().all(action_requires_local_context)) + } + + async fn refresh_local_viewports(&mut self, presentation: &PresentationModel) -> Result<()> { + let refreshes = presentation + .leaves + .iter() + .filter_map(|leaf| { + let state = self.client.state().view_state(leaf.node_id)?; + if state.alternate_screen + || state.follow_output + || state.visible_line_count == 0 + || !state.visible_lines.is_empty() + { + return None; + } + Some((leaf.node_id, state.scroll_top_line)) + }) + .collect::>(); + + for (node_id, scroll_top_line) in refreshes { + self.fetch_view_slice(node_id, scroll_top_line).await?; + } + Ok(()) + } + + async fn scroll_view_by(&mut self, node_id: NodeId, delta: i64) -> Result<()> { + let Some(state) = self.client.state().view_state(node_id) else { + return Ok(()); + }; + if state.alternate_screen { + return Ok(()); + } + let current = i128::from(state.scroll_top_line); + let delta = i128::from(delta); + // Negative deltas scroll up by subtracting the absolute value; positive deltas + // scroll down by adding. We do the math in i128 with saturating ops so + // unsigned_abs/subtraction cannot underflow or overflow before clamping at 0. + let next = if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs() as i128) + } else { + current.saturating_add(delta) + }; + let next = next.max(0) as u64; + self.set_view_scroll_top(node_id, next).await + } + + async fn set_view_scroll_top(&mut self, node_id: NodeId, scroll_top_line: u64) -> Result<()> { + let Some(state) = self.client.state().view_state(node_id) else { + return Ok(()); + }; + if state.alternate_screen { + return Ok(()); + } + let bottom = state + .total_line_count + .saturating_sub(u64::from(state.visible_line_count)); + let scroll_top_line = scroll_top_line.min(bottom); + if scroll_top_line == bottom { + return self.follow_output_for_view(node_id).await; + } + + self.fetch_view_slice(node_id, scroll_top_line).await?; + if let Some(state) = self.client.state_mut().view_state_mut(node_id) { + state.follow_output = false; + } + Ok(()) + } + + async fn follow_output_for_view(&mut self, node_id: NodeId) -> Result<()> { + let Some(state) = self.client.state().view_state(node_id) else { + return Ok(()); + }; + if state.alternate_screen { + return Ok(()); + } + self.client + .state_mut() + .set_view_follow_output(node_id, true); + Ok(()) + } + + async fn fetch_view_slice(&mut self, node_id: NodeId, scroll_top_line: u64) -> Result<()> { + let Some(state) = self.client.state().view_state(node_id) else { + return Ok(()); + }; + if state.visible_line_count == 0 { + return Ok(()); + } + let response = self + .client + .capture_scrollback_slice( + state.buffer_id, + scroll_top_line, + u32::from(state.visible_line_count), + ) + .await?; + if let Some(state) = self.client.state_mut().view_state_mut(node_id) { + state.total_line_count = response + .total_lines + .max(u64::from(state.visible_line_count)); + } + self.client + .state_mut() + .set_view_visible_lines(node_id, scroll_top_line, response.lines); + Ok(()) + } + + async fn enter_search_mode( + &mut self, + _session_id: SessionId, + _viewport: Size, + presentation: &PresentationModel, + ) -> Result<()> { + let leaf = self.focused_leaf(presentation)?; + if self.view_is_alternate_screen(leaf.node_id) { + return Ok(()); + } + + let query = self + .client + .state() + .view_state(leaf.node_id) + .and_then(|state| state.search_state.as_ref()) + .map(|state| state.query.clone()) + .unwrap_or_default(); + self.search_prompt = Some(SearchPrompt { + node_id: leaf.node_id, + query, + }); + self.input_state.set_mode(SEARCH_MODE); + Ok(()) + } + + async fn handle_search_key( + &mut self, + session_id: SessionId, + viewport: Size, + presentation: &PresentationModel, + key: KeyEvent, + ) -> Result<()> { + if self.search_prompt.is_none() { + return self + .enter_search_mode(session_id, viewport, presentation) + .await; + } + + match key { + KeyEvent::Char(ch) => { + if let Some(prompt) = &mut self.search_prompt { + prompt.query.push(ch); + } + Ok(()) + } + KeyEvent::Tab => { + if let Some(prompt) = &mut self.search_prompt { + prompt.query.push('\t'); + } + Ok(()) + } + KeyEvent::Backspace => { + if let Some(prompt) = &mut self.search_prompt { + prompt.query.pop(); + } + Ok(()) + } + KeyEvent::Enter => self.commit_search_prompt(session_id, viewport).await, + KeyEvent::Escape => self.cancel_search_prompt(session_id, viewport).await, + KeyEvent::Bytes(bytes) => { + if let Some(prompt) = &mut self.search_prompt { + prompt.query.push_str(&String::from_utf8_lossy(&bytes)); + } + Ok(()) + } + KeyEvent::Ctrl(_) + | KeyEvent::Alt(_) + | KeyEvent::Up + | KeyEvent::Down + | KeyEvent::Left + | KeyEvent::Right + | KeyEvent::Home + | KeyEvent::End + | KeyEvent::Insert + | KeyEvent::Delete + | KeyEvent::PageUp + | KeyEvent::PageDown => Ok(()), + } + } + + async fn handle_search_paste( + &mut self, + session_id: SessionId, + viewport: Size, + bytes: Vec, + ) -> Result<()> { + if self.search_prompt.is_none() { + return self.cancel_search_prompt(session_id, viewport).await; + } + if let Some(prompt) = &mut self.search_prompt { + prompt.query.push_str(&String::from_utf8_lossy(&bytes)); + } + Ok(()) + } + + async fn commit_search_prompt(&mut self, session_id: SessionId, viewport: Size) -> Result<()> { + let Some(prompt) = self.search_prompt.take() else { + return self.cancel_search_prompt(session_id, viewport).await; + }; + let Some(buffer_id) = self + .client + .state() + .view_state(prompt.node_id) + .map(|state| state.buffer_id) + else { + return self.cancel_search_prompt(session_id, viewport).await; + }; + + let matches = if prompt.query.is_empty() { + Vec::new() + } else { + let snapshot = self.client.capture_buffer(buffer_id).await?; + compute_search_matches(&snapshot.lines, &prompt.query) + }; + + if let Some(state) = self.client.state_mut().view_state_mut(prompt.node_id) { + if prompt.query.is_empty() { + state.search_state = None; + } else { + state.search_state = Some(SearchState { + query: prompt.query.clone(), + matches, + active_match_index: None, + }); + } + } + + if self + .client + .state() + .view_state(prompt.node_id) + .and_then(|state| state.search_state.as_ref()) + .is_some_and(|state| !state.matches.is_empty()) + { + self.jump_to_search_index(prompt.node_id, 0).await?; + } + self.input_state.set_mode(NORMAL_MODE); + Ok(()) + } + + async fn cancel_search_prompt( + &mut self, + _session_id: SessionId, + _viewport: Size, + ) -> Result<()> { + self.search_prompt = None; + self.input_state.set_mode(NORMAL_MODE); + Ok(()) + } + + async fn navigate_search( + &mut self, + presentation: &PresentationModel, + forward: bool, + ) -> Result<()> { + let leaf = self.focused_leaf(presentation)?; + if self.view_is_alternate_screen(leaf.node_id) { + return Ok(()); + } + let Some(search_state) = self + .client + .state() + .view_state(leaf.node_id) + .and_then(|state| state.search_state.as_ref()) + else { + return Ok(()); + }; + if search_state.matches.is_empty() { + return Ok(()); + } + let current = search_state.active_match_index.unwrap_or(0); + let next = if forward { + (current + 1) % search_state.matches.len() + } else if current == 0 { + search_state.matches.len() - 1 + } else { + current - 1 + }; + self.jump_to_search_index(leaf.node_id, next).await + } + + async fn jump_to_search_index(&mut self, node_id: NodeId, index: usize) -> Result<()> { + let Some((selected, current_top, visible_line_count)) = + self.client.state().view_state(node_id).and_then(|state| { + state.search_state.as_ref().and_then(|search| { + search + .matches + .get(index) + .copied() + .map(|selected| (selected, state.scroll_top_line, state.visible_line_count)) + }) + }) + else { + return Ok(()); + }; + + let visible_line_count = u64::from(visible_line_count.max(1)); + let new_top = if selected.line < current_top { + selected.line + } else if selected.line >= current_top.saturating_add(visible_line_count) { + selected + .line + .saturating_sub(visible_line_count.saturating_sub(1)) + } else { + current_top + }; + if new_top != current_top + || self + .client + .state() + .view_state(node_id) + .is_some_and(|state| state.follow_output) + { + self.fetch_view_slice(node_id, new_top).await?; + } + + if let Some(state) = self.client.state_mut().view_state_mut(node_id) { + if let Some(search) = &mut state.search_state { + search.active_match_index = Some(index); + } + state.follow_output = false; + } + Ok(()) + } + + async fn enter_select_mode( + &mut self, + _session_id: SessionId, + _viewport: Size, + presentation: &PresentationModel, + kind: SelectionKind, + ) -> Result<()> { + let leaf = self.focused_leaf(presentation)?; + if self.view_is_alternate_screen(leaf.node_id) { + return Ok(()); + } + + let start = self.selection_origin(leaf.node_id); + if let Some(state) = self.client.state_mut().view_state_mut(leaf.node_id) { + state.selection_state = Some(SelectionState { + kind, + anchor: start, + cursor: start, + }); + } + self.input_state.set_mode(SELECT_MODE); + Ok(()) + } + + fn selection_origin(&self, node_id: NodeId) -> SelectionPoint { + let Some(state) = self.client.state().view_state(node_id) else { + return SelectionPoint::default(); + }; + let fallback = SelectionPoint { + line: state.scroll_top_line, + column: 0, + }; + if !state.follow_output { + return fallback; + } + self.client + .state() + .snapshots + .get(&state.buffer_id) + .and_then(|snapshot| { + snapshot.cursor.map(|cursor| SelectionPoint { + line: state.scroll_top_line + u64::from(cursor.position.row), + column: cursor.position.col, + }) + }) + .unwrap_or(fallback) + } + + async fn move_selection( + &mut self, + node_id: NodeId, + direction: NavigationDirection, + ) -> Result<()> { + let Some((mut selection, total_line_count, scroll_top_line, visible_line_count)) = + self.client.state().view_state(node_id).and_then(|state| { + state.selection_state.clone().map(|selection| { + ( + selection, + state.total_line_count, + state.scroll_top_line, + state.visible_line_count, + ) + }) + }) + else { + return Ok(()); + }; + + match direction { + NavigationDirection::Left => { + selection.cursor.column = selection.cursor.column.saturating_sub(1); + } + NavigationDirection::Right => { + selection.cursor.column = selection.cursor.column.saturating_add(1); + } + NavigationDirection::Up => { + selection.cursor.line = selection.cursor.line.saturating_sub(1); + } + NavigationDirection::Down => { + selection.cursor.line = selection + .cursor + .line + .saturating_add(1) + .min(total_line_count.saturating_sub(1)); + } + } + + let visible_line_count = u64::from(visible_line_count.max(1)); + let mut new_top = scroll_top_line; + if selection.cursor.line < new_top { + new_top = selection.cursor.line; + } else if selection.cursor.line >= new_top.saturating_add(visible_line_count) { + new_top = selection + .cursor + .line + .saturating_sub(visible_line_count.saturating_sub(1)); + } + if new_top != scroll_top_line { + self.fetch_view_slice(node_id, new_top).await?; + } + + if let Some(state) = self.client.state_mut().view_state_mut(node_id) { + state.follow_output = false; + state.selection_state = Some(selection); + } + Ok(()) + } + + async fn copy_selection( + &mut self, + _session_id: SessionId, + _viewport: Size, + node_id: NodeId, + ) -> Result<()> { + let Some((buffer_id, selection_state)) = + self.client.state().view_state(node_id).and_then(|state| { + state + .selection_state + .clone() + .map(|selection| (state.buffer_id, selection)) + }) + else { + return Ok(()); + }; + + let snapshot = self.client.capture_buffer(buffer_id).await?; + let copied = serialize_selection(&snapshot.lines, &selection_state); + self.enqueue_clipboard(copied); + + if let Some(state) = self.client.state_mut().view_state_mut(node_id) { + state.selection_state = None; + } + self.input_state.set_mode(NORMAL_MODE); + Ok(()) + } + + async fn cancel_selection( + &mut self, + _session_id: SessionId, + _viewport: Size, + node_id: NodeId, + ) -> Result<()> { + if let Some(state) = self.client.state_mut().view_state_mut(node_id) { + state.selection_state = None; + } + self.input_state.set_mode(NORMAL_MODE); + Ok(()) + } + + fn enqueue_clipboard(&mut self, text: String) { + use base64::Engine as _; + + let encoded = base64::engine::general_purpose::STANDARD.encode(text); + self.terminal_output + .push_back(format!("\x1b]52;c;{encoded}\x07").into_bytes()); + } + + async fn focus_node(&mut self, session_id: SessionId, node_id: NodeId) -> Result<()> { + if self + .client + .state() + .sessions + .get(&session_id) + .and_then(|session| session.focused_leaf_id) + == Some(node_id) + { + return Ok(()); + } + + let previous_buffer = self.focused_buffer_for_session(session_id); + let sent_focus_out = if let Some(buffer_id) = previous_buffer { + self.maybe_send_focus_sequence(buffer_id, false).await? + } else { + false + }; + + self.client + .request_message(ClientMessage::Node(NodeRequest::Focus { + request_id: self.client.next_request_id(), + session_id, + node_id, + })) + .await?; + self.client.resync_session(session_id).await?; + + let new_buffer = self.focused_buffer_for_session(session_id); + if new_buffer != previous_buffer { + let sent_focus_in = if let Some(buffer_id) = new_buffer { + self.maybe_send_focus_sequence(buffer_id, true).await? + } else { + false + }; + if sent_focus_in && let Some(buffer_id) = new_buffer { + self.client.refresh_buffer_snapshot(buffer_id).await?; + } + if sent_focus_out && let Some(buffer_id) = previous_buffer { + self.client.refresh_buffer_snapshot(buffer_id).await?; + } + } + Ok(()) + } + + fn focused_buffer_for_session(&self, session_id: SessionId) -> Option { + self.client + .state() + .sessions + .get(&session_id) + .and_then(|session| session.focused_leaf_id) + .and_then(|node_id| self.node_buffer_id(node_id)) + } + + fn node_buffer_id(&self, node_id: NodeId) -> Option { + self.client + .state() + .nodes + .get(&node_id) + .and_then(|node| node.buffer_view.as_ref()) + .map(|buffer_view| buffer_view.buffer_id) + } + + async fn maybe_send_focus_sequence( + &mut self, + buffer_id: BufferId, + focused: bool, + ) -> Result { + if !self + .client + .state() + .snapshots + .get(&buffer_id) + .is_some_and(|snapshot| snapshot.focus_reporting) + { + return Ok(false); + } + let bytes = if focused { + b"\x1b[I".to_vec() + } else { + b"\x1b[O".to_vec() + }; + self.send_input_only(buffer_id, bytes).await?; + Ok(true) + } + + async fn send_input_only(&self, buffer_id: BufferId, bytes: Vec) -> Result<()> { + self.client + .request_message(ClientMessage::Input(InputRequest::Send { + request_id: self.client.next_request_id(), + buffer_id, + bytes, + })) + .await?; + Ok(()) + } + + fn record_notification(&mut self, message: impl Into) { + let message = message.into(); + warn!("{message}"); + self.notifications.push(message); + if self.notifications.len() > 64 { + let overflow = self.notifications.len() - 64; + self.notifications.drain(0..overflow); + } + } +} + +fn action_is_local_terminal_action(action: &Action) -> bool { + match action { + Action::Chain(actions) => actions.iter().all(action_is_local_terminal_action), + Action::EnterMode { mode } | Action::ToggleMode { mode } => { + mode == SEARCH_MODE || mode == SELECT_MODE + } + Action::ScrollLineUp + | Action::ScrollLineDown + | Action::ScrollPageUp + | Action::ScrollPageDown + | Action::ScrollToTop + | Action::ScrollToBottom + | Action::FollowOutput + | Action::EnterSearchMode + | Action::SearchNext + | Action::SearchPrev + | Action::CancelSearch + | Action::EnterSelect { .. } + | Action::SelectMove { .. } + | Action::CopySelection + | Action::CancelSelection => true, + _ => false, + } +} + +fn action_requires_local_context(action: &Action) -> bool { + match action { + Action::Chain(actions) => actions.iter().all(action_requires_local_context), + Action::EnterSearchMode + | Action::SearchNext + | Action::SearchPrev + | Action::EnterSelect { .. } => true, + Action::EnterMode { mode } | Action::ToggleMode { mode } => { + mode == SEARCH_MODE || mode == SELECT_MODE + } + _ => false, + } +} + +fn compute_search_matches(lines: &[String], query: &str) -> Vec { + if query.is_empty() { + return Vec::new(); + } + + let mut matches = Vec::new(); + for (line_index, line) in lines.iter().enumerate() { + let mut search_from = 0; + while let Some(found) = line[search_from..].find(query) { + let byte_start = search_from + found; + let byte_end = byte_start + query.len(); + let start_column = display_width(&line[..byte_start]); + let end_column = + start_column.saturating_add(display_width(&line[byte_start..byte_end])); + matches.push(SearchMatch { + line: u64::try_from(line_index).unwrap_or(u64::MAX), + start_column, + end_column, + }); + search_from = byte_end.max(search_from + 1); + } + } + matches +} + +#[doc(hidden)] +pub fn benchmark_search_matches(lines: &[String], query: &str) -> Vec { + compute_search_matches(lines, query) +} + +#[doc(hidden)] +pub fn benchmark_serialize_selection(lines: &[String], selection: &SelectionState) -> String { + serialize_selection(lines, selection) +} + +fn serialize_selection(lines: &[String], selection: &SelectionState) -> String { + match selection.kind { + SelectionKind::Line => serialize_line_selection(lines, selection), + SelectionKind::Block => serialize_block_selection(lines, selection), + SelectionKind::Character => serialize_character_selection(lines, selection), + } +} + +fn serialize_character_selection(lines: &[String], selection: &SelectionState) -> String { + let (start, end) = ordered_points(selection.anchor, selection.cursor); + let mut parts = Vec::new(); + for line_index in start.line..=end.line { + let line = lines + .get(usize::try_from(line_index).unwrap_or(usize::MAX)) + .map(String::as_str) + .unwrap_or(""); + let fragment = if start.line == end.line { + slice_display_range(line, start.column, end.column.saturating_add(1)) + } else if line_index == start.line { + slice_display_range(line, start.column, u16::MAX) + } else if line_index == end.line { + slice_display_range(line, 0, end.column.saturating_add(1)) + } else { + line.to_owned() + }; + parts.push(fragment); + } + parts.join("\n") +} + +fn serialize_line_selection(lines: &[String], selection: &SelectionState) -> String { + let start_line = selection.anchor.line.min(selection.cursor.line); + let end_line = selection.anchor.line.max(selection.cursor.line); + (start_line..=end_line) + .map(|line_index| { + lines + .get(usize::try_from(line_index).unwrap_or(usize::MAX)) + .cloned() + .unwrap_or_default() + }) + .collect::>() + .join("\n") +} + +fn serialize_block_selection(lines: &[String], selection: &SelectionState) -> String { + let start_line = selection.anchor.line.min(selection.cursor.line); + let end_line = selection.anchor.line.max(selection.cursor.line); + let start_column = selection.anchor.column.min(selection.cursor.column); + let end_column = selection + .anchor + .column + .max(selection.cursor.column) + .saturating_add(1); + (start_line..=end_line) + .map(|line_index| { + let line = lines + .get(usize::try_from(line_index).unwrap_or(usize::MAX)) + .map(String::as_str) + .unwrap_or(""); + slice_display_range(line, start_column, end_column) + }) + .collect::>() + .join("\n") +} + +fn ordered_points(left: SelectionPoint, right: SelectionPoint) -> (SelectionPoint, SelectionPoint) { + if (left.line, left.column) <= (right.line, right.column) { + (left, right) + } else { + (right, left) + } +} + +fn slice_display_range(line: &str, start_column: u16, end_column: u16) -> String { + if start_column >= end_column { + return String::new(); + } + + let mut column = 0_u16; + let mut output = String::new(); + for grapheme in UnicodeSegmentation::graphemes(line, true) { + let width = display_width(grapheme).max(1); + let next_column = column.saturating_add(width); + if next_column > start_column && column < end_column { + output.push_str(grapheme); + } + if column >= end_column { + break; + } + column = next_column; + } + output +} + +fn encode_mouse_event(leaf: &LeafFrame, event: MouseEvent) -> Result> { + let origin_x = clamp_origin(leaf.rect.origin.x); + let origin_y = clamp_origin(leaf.rect.origin.y).saturating_add(1); + let local_column = event + .column + .checked_sub(origin_x) + .ok_or_else(|| MuxError::invalid_input("mouse event fell outside pane bounds"))?; + let local_row = event + .row + .checked_sub(origin_y) + .ok_or_else(|| MuxError::invalid_input("mouse event fell outside pane content"))?; + + let mut code = 0_u16; + if event.modifiers.shift { + code |= 0b00100; + } + if event.modifiers.alt { + code |= 0b01000; + } + if event.modifiers.ctrl { + code |= 0b10000; + } + + let suffix = match event.kind { + MouseEventKind::Press(button) => { + code |= mouse_button_code(button); + 'M' + } + MouseEventKind::Release(button) => { + code |= button.map(mouse_button_code).unwrap_or(0b11); + 'm' + } + MouseEventKind::Drag(button) => { + code |= 0b100000 | mouse_button_code(button); + 'M' + } + MouseEventKind::WheelUp => { + code |= 0b1_000000; + 'M' + } + MouseEventKind::WheelDown => { + code |= 0b1_000001; + 'M' + } + }; + + Ok(format!( + "\x1b[<{code};{};{}{suffix}", + local_column.saturating_add(1), + local_row.saturating_add(1) + ) + .into_bytes()) +} + +fn mouse_button_code(button: MouseButton) -> u16 { + match button { + MouseButton::Left => 0, + MouseButton::Middle => 1, + MouseButton::Right => 2, + } +} + +fn clamp_origin(value: i32) -> u16 { + u16::try_from(value.max(0)).unwrap_or(u16::MAX) +} + +fn display_width(text: &str) -> u16 { + u16::try_from(UnicodeWidthStr::width(text)).unwrap_or(u16::MAX) +} + +fn prepend_actions(pending: &mut VecDeque, actions: Vec) { + for action in actions.into_iter().rev() { + pending.push_front(action); + } +} + +fn prepend_actions_with_limit( + pending: &mut VecDeque, + actions: Vec, + expansions: &mut usize, +) -> Result<()> { + *expansions = expansions.saturating_add(actions.len()); + if *expansions > MAX_EXPANDED_ACTIONS { + return Err(MuxError::invalid_input("action expansion limit reached")); + } + prepend_actions(pending, actions); + Ok(()) +} + +fn format_notification(level: NotifyLevel, message: &str) -> String { + match level { + NotifyLevel::Info => message.to_owned(), + NotifyLevel::Warn => format!("warn: {message}"), + NotifyLevel::Error => format!("error: {message}"), + } +} + +fn resolve_floating_geometry(spec: FloatingGeometrySpec, viewport: Size) -> FloatGeometry { + let width = resolve_floating_size(spec.width, viewport.width); + let height = resolve_floating_size(spec.height, viewport.height); + let max_x = viewport.width.saturating_sub(width); + let max_y = viewport.height.saturating_sub(height); + + let (base_x, base_y) = match spec.anchor { + FloatingAnchor::Center => (max_x / 2, max_y / 2), + FloatingAnchor::TopLeft => (0, 0), + FloatingAnchor::TopRight => (max_x, 0), + FloatingAnchor::BottomLeft => (0, max_y), + FloatingAnchor::BottomRight => (max_x, max_y), + }; + + let x = (i32::from(base_x) + i32::from(spec.offset_x)).clamp(0, i32::from(max_x)); + let y = (i32::from(base_y) + i32::from(spec.offset_y)).clamp(0, i32::from(max_y)); + + FloatGeometry::new(x as u16, y as u16, width, height) +} + +fn resolve_floating_size(size: FloatingSize, max: u16) -> u16 { + match size { + FloatingSize::Cells(cells) => cells.min(max.max(1)), + FloatingSize::Percent(percent) => { + let max = u32::from(max.max(1)); + let percent = u32::from(percent.clamp(1, 100)); + ((max * percent) / 100).max(1) as u16 + } + } +} + +fn default_shell_command() -> Vec { + vec![std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned())] +} + +fn key_event_to_token(key: KeyEvent) -> Result { + match key { + KeyEvent::Char(ch) => Ok(KeyToken::Char(ch)), + KeyEvent::Enter => Ok(KeyToken::Enter), + KeyEvent::Tab => Ok(KeyToken::Tab), + KeyEvent::Backspace => Ok(KeyToken::Backspace), + KeyEvent::Escape => Ok(KeyToken::Escape), + KeyEvent::Ctrl(ch) => Ok(KeyToken::Ctrl(ch.to_ascii_lowercase())), + KeyEvent::Alt(ch) => Ok(KeyToken::Alt(ch.to_ascii_lowercase())), + KeyEvent::Up => Ok(KeyToken::Up), + KeyEvent::Down => Ok(KeyToken::Down), + KeyEvent::Left => Ok(KeyToken::Left), + KeyEvent::Right => Ok(KeyToken::Right), + KeyEvent::Home => Ok(KeyToken::Home), + KeyEvent::End => Ok(KeyToken::End), + KeyEvent::Insert => Ok(KeyToken::Insert), + KeyEvent::Delete => Ok(KeyToken::Delete), + KeyEvent::PageUp => Ok(KeyToken::PageUp), + KeyEvent::PageDown => Ok(KeyToken::PageDown), + KeyEvent::Bytes(_) => Err(MuxError::invalid_input("raw bytes are handled separately")), + } +} + +fn sequence_to_bytes(sequence: &[KeyToken]) -> Result> { + let mut bytes = Vec::new(); + for token in sequence { + match token { + KeyToken::Char(ch) => { + let mut encoded = [0; 4]; + bytes.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); + } + KeyToken::Space => bytes.push(b' '), + KeyToken::Tab => bytes.push(b'\t'), + KeyToken::Enter => bytes.push(b'\r'), + KeyToken::Backspace => bytes.push(0x7f), + KeyToken::Escape => bytes.push(0x1b), + KeyToken::Ctrl(ch) => bytes.push(ctrl_byte(*ch)?), + KeyToken::Alt(ch) => { + bytes.push(0x1b); + bytes.extend(sequence_to_bytes(&[KeyToken::Char(*ch)])?); + } + KeyToken::Up => bytes.extend_from_slice(b"\x1b[A"), + KeyToken::Down => bytes.extend_from_slice(b"\x1b[B"), + KeyToken::Left => bytes.extend_from_slice(b"\x1b[D"), + KeyToken::Right => bytes.extend_from_slice(b"\x1b[C"), + KeyToken::Home => bytes.extend_from_slice(b"\x1b[H"), + KeyToken::End => bytes.extend_from_slice(b"\x1b[F"), + KeyToken::Insert => bytes.extend_from_slice(b"\x1b[2~"), + KeyToken::Delete => bytes.extend_from_slice(b"\x1b[3~"), + KeyToken::PageUp => bytes.extend_from_slice(b"\x1b[5~"), + KeyToken::PageDown => bytes.extend_from_slice(b"\x1b[6~"), + KeyToken::Leader => { + return Err(MuxError::invalid_input( + "leader placeholders cannot be sent directly", + )); + } + } + } + Ok(bytes) +} + +fn ctrl_byte(ch: char) -> Result { + if !ch.is_ascii() { + return Err(MuxError::invalid_input("control keys must be ASCII")); + } + Ok((ch.to_ascii_lowercase() as u8) & 0x1f) +} + +async fn rollback_created_buffer_on_error( + configured: &mut ConfiguredClient, + buffer_id: Option, + operation: &str, + result: Result, +) -> Result +where + U: Transport, +{ + match result { + Ok(value) => Ok(value), + Err(error) => { + if let Some(buffer_id) = buffer_id { + configured + .rollback_created_buffer(buffer_id, operation) + .await; + } + Err(error) + } + } +} + +fn event_name(event: &ServerEvent) -> &'static str { + match event { + ServerEvent::SessionCreated(_) => "session_created", + ServerEvent::SessionClosed(_) => "session_closed", + ServerEvent::BufferCreated(_) => "buffer_created", + ServerEvent::BufferDetached(_) => "buffer_detached", + ServerEvent::NodeChanged(_) => "node_changed", + ServerEvent::FloatingChanged(_) => "floating_changed", + ServerEvent::FocusChanged(_) => "focus_changed", + ServerEvent::RenderInvalidated(_) => "render_invalidated", + } +} + +fn event_info(name: &str, event: &ServerEvent) -> EventInfo { + match event { + ServerEvent::SessionCreated(event) => EventInfo { + name: name.to_owned(), + session_id: Some(event.session.id), + buffer_id: None, + node_id: None, + floating_id: None, + }, + ServerEvent::SessionClosed(event) => EventInfo { + name: name.to_owned(), + session_id: Some(event.session_id), + buffer_id: None, + node_id: None, + floating_id: None, + }, + ServerEvent::BufferCreated(event) => EventInfo { + name: name.to_owned(), + session_id: None, + buffer_id: Some(event.buffer.id), + node_id: event.buffer.attachment_node_id, + floating_id: None, + }, + ServerEvent::BufferDetached(event) => EventInfo { + name: name.to_owned(), + session_id: None, + buffer_id: Some(event.buffer_id), + node_id: None, + floating_id: None, + }, + ServerEvent::NodeChanged(event) => EventInfo { + name: name.to_owned(), + session_id: Some(event.session_id), + buffer_id: None, + node_id: None, + floating_id: None, + }, + ServerEvent::FloatingChanged(event) => EventInfo { + name: name.to_owned(), + session_id: Some(event.session_id), + buffer_id: None, + node_id: None, + floating_id: None, + }, + ServerEvent::FocusChanged(event) => EventInfo { + name: name.to_owned(), + session_id: Some(event.session_id), + buffer_id: None, + node_id: event.focused_leaf_id, + floating_id: event.focused_floating_id, + }, + ServerEvent::RenderInvalidated(event) => EventInfo { + name: name.to_owned(), + session_id: None, + buffer_id: Some(event.buffer_id), + node_id: None, + floating_id: None, + }, + } +} diff --git a/crates/embers-client/src/controller.rs b/crates/embers-client/src/controller.rs new file mode 100644 index 0000000..ba0a6a5 --- /dev/null +++ b/crates/embers-client/src/controller.rs @@ -0,0 +1,168 @@ +use embers_core::RequestId; +use embers_protocol::{ClientMessage, FloatingRequest, InputRequest, NodeRequest}; + +use crate::presentation::{NavigationDirection, PresentationModel}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum KeyEvent { + Char(char), + Bytes(Vec), + Enter, + Tab, + Backspace, + Escape, + Ctrl(char), + Alt(char), + Up, + Down, + Left, + Right, + Home, + End, + Insert, + Delete, + PageUp, + PageDown, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MouseButton { + Left, + Middle, + Right, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct MouseModifiers { + pub shift: bool, + pub alt: bool, + pub ctrl: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MouseEventKind { + Press(MouseButton), + Release(Option), + Drag(MouseButton), + WheelUp, + WheelDown, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MouseEvent { + pub row: u16, + pub column: u16, + pub modifiers: MouseModifiers, + pub kind: MouseEventKind, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Controller; + +impl Controller { + pub fn map_key( + &self, + presentation: &PresentationModel, + request_id: RequestId, + key: KeyEvent, + ) -> Option { + match key { + KeyEvent::Ctrl(ch) => { + let ch = ch.to_ascii_lowercase(); + match ch { + 'h' | 'j' | 'k' | 'l' => { + let direction = match ch { + 'h' => NavigationDirection::Left, + 'j' => NavigationDirection::Down, + 'k' => NavigationDirection::Up, + 'l' => NavigationDirection::Right, + _ => unreachable!(), + }; + + Some(ClientMessage::Node(NodeRequest::Focus { + request_id, + session_id: presentation.session_id, + node_id: presentation.focus_target(direction)?, + })) + } + _ => input_request(presentation, request_id, vec![ctrl_byte(ch)?]), + } + } + KeyEvent::Alt(ch) if ('1'..='9').contains(&ch) => { + let index = ch.to_digit(10)?.saturating_sub(1); + let Some(index_usize) = usize::try_from(index).ok() else { + return alt_bytes_request(presentation, request_id, ch); + }; + if let Some(tabs) = presentation.focused_tabs() + && index_usize < tabs.tabs.len() + { + return Some(ClientMessage::Node(NodeRequest::SelectTab { + request_id, + tabs_node_id: tabs.node_id, + index, + })); + } + alt_bytes_request(presentation, request_id, ch) + } + KeyEvent::Alt(ch) => alt_bytes_request(presentation, request_id, ch), + KeyEvent::Escape => { + if let Some(floating_id) = presentation.focused_floating_id() { + Some(ClientMessage::Floating(FloatingRequest::Close { + request_id, + floating_id, + })) + } else { + input_request(presentation, request_id, vec![0x1b]) + } + } + KeyEvent::Char(ch) => { + input_request(presentation, request_id, ch.to_string().into_bytes()) + } + KeyEvent::Bytes(bytes) if !bytes.is_empty() => { + input_request(presentation, request_id, bytes) + } + KeyEvent::Tab => input_request(presentation, request_id, b"\t".to_vec()), + KeyEvent::Enter => input_request(presentation, request_id, b"\r".to_vec()), + KeyEvent::Backspace => input_request(presentation, request_id, vec![0x7f]), + KeyEvent::Up => input_request(presentation, request_id, b"\x1b[A".to_vec()), + KeyEvent::Down => input_request(presentation, request_id, b"\x1b[B".to_vec()), + KeyEvent::Right => input_request(presentation, request_id, b"\x1b[C".to_vec()), + KeyEvent::Left => input_request(presentation, request_id, b"\x1b[D".to_vec()), + KeyEvent::Home => input_request(presentation, request_id, b"\x1b[H".to_vec()), + KeyEvent::End => input_request(presentation, request_id, b"\x1b[F".to_vec()), + KeyEvent::Insert => input_request(presentation, request_id, b"\x1b[2~".to_vec()), + KeyEvent::Delete => input_request(presentation, request_id, b"\x1b[3~".to_vec()), + KeyEvent::PageUp => input_request(presentation, request_id, b"\x1b[5~".to_vec()), + KeyEvent::PageDown => input_request(presentation, request_id, b"\x1b[6~".to_vec()), + KeyEvent::Bytes(_) => None, + } + } +} + +fn ctrl_byte(ch: char) -> Option { + ch.is_ascii() + .then_some((ch.to_ascii_lowercase() as u8) & 0x1f) +} + +fn alt_bytes_request( + presentation: &PresentationModel, + request_id: RequestId, + ch: char, +) -> Option { + let mut encoded = [0; 4]; + let mut bytes = vec![0x1b]; + bytes.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); + input_request(presentation, request_id, bytes) +} + +fn input_request( + presentation: &PresentationModel, + request_id: RequestId, + bytes: Vec, +) -> Option { + Some(ClientMessage::Input(InputRequest::Send { + request_id, + buffer_id: presentation.focused_buffer_id()?, + bytes, + })) +} diff --git a/crates/embers-client/src/grid.rs b/crates/embers-client/src/grid.rs new file mode 100644 index 0000000..312076e --- /dev/null +++ b/crates/embers-client/src/grid.rs @@ -0,0 +1,540 @@ +use std::fmt::Write; + +use embers_core::{CursorShape, Rect}; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Color { + pub red: u8, + pub green: u8, + pub blue: u8, +} + +impl From for Color { + fn from(value: crate::scripting::RgbColor) -> Self { + Self { + red: value.red, + green: value.green, + blue: value.blue, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CellStyle { + pub fg: Option, + pub bg: Option, + pub bold: bool, + pub italic: bool, + pub underline: bool, + pub dim: bool, + pub reverse: bool, +} + +impl CellStyle { + pub const fn with_reverse(mut self) -> Self { + self.reverse = true; + self + } + + pub const fn with_bold(mut self) -> Self { + self.bold = true; + self + } + + pub fn is_plain(self) -> bool { + self == Self::default() + } +} + +impl From<&crate::scripting::StyleSpec> for CellStyle { + fn from(value: &crate::scripting::StyleSpec) -> Self { + Self { + fg: value.fg.map(Into::into), + bg: value.bg.map(Into::into), + bold: value.bold, + italic: value.italic, + underline: value.underline, + dim: value.dim, + reverse: false, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GridCursor { + pub x: u16, + pub y: u16, + pub shape: CursorShape, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Cell { + text: String, + style: CellStyle, + continuation: bool, +} + +impl Default for Cell { + fn default() -> Self { + Self { + text: " ".to_owned(), + style: CellStyle::default(), + continuation: false, + } + } +} + +impl Cell { + fn blank(fill: char) -> Self { + Self { + text: fill.to_string(), + style: CellStyle::default(), + continuation: false, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RenderGrid { + width: u16, + height: u16, + cells: Vec, + cursor: Option, +} + +impl RenderGrid { + pub fn new(width: u16, height: u16) -> Self { + let len = usize::from(width) * usize::from(height); + Self { + width, + height, + cells: vec![Cell::default(); len], + cursor: None, + } + } + + pub fn clear(&mut self, fill: char) { + self.cells.fill(Cell::blank(fill)); + self.cursor = None; + } + + pub fn width(&self) -> u16 { + self.width + } + + pub fn height(&self) -> u16 { + self.height + } + + pub fn cursor(&self) -> Option { + self.cursor + } + + pub fn set_cursor(&mut self, cursor: Option) { + self.cursor = cursor.filter(|cursor| cursor.x < self.width && cursor.y < self.height); + } + + pub fn put_char(&mut self, x: u16, y: u16, ch: char) { + self.put_char_styled(x, y, ch, CellStyle::default()); + } + + pub fn put_char_styled(&mut self, x: u16, y: u16, ch: char, style: CellStyle) { + self.put_str_styled(x, y, &ch.to_string(), style); + } + + pub fn put_str(&mut self, x: u16, y: u16, text: &str) { + self.put_str_styled(x, y, text, CellStyle::default()); + } + + pub fn put_str_styled(&mut self, x: u16, y: u16, text: &str, style: CellStyle) { + if y >= self.height || x >= self.width { + return; + } + + let mut x_pos = x; + for grapheme in UnicodeSegmentation::graphemes(text, true) { + if x_pos >= self.width { + break; + } + let width = grapheme_width(grapheme); + if width == 0 { + continue; + } + if x_pos.saturating_add(width) > self.width { + break; + } + + self.clear_overlapping_cells(x_pos, y, width); + self.set_cell(x_pos, y, grapheme, style, width); + x_pos = x_pos.saturating_add(width); + } + } + + pub fn draw_hline(&mut self, x: u16, y: u16, width: u16, ch: char) { + self.draw_hline_styled(x, y, width, ch, CellStyle::default()); + } + + pub fn draw_hline_styled(&mut self, x: u16, y: u16, width: u16, ch: char, style: CellStyle) { + for offset in 0..width { + self.put_char_styled(x.saturating_add(offset), y, ch, style); + } + } + + pub fn draw_vline(&mut self, x: u16, y: u16, height: u16, ch: char) { + self.draw_vline_styled(x, y, height, ch, CellStyle::default()); + } + + pub fn draw_vline_styled(&mut self, x: u16, y: u16, height: u16, ch: char, style: CellStyle) { + for offset in 0..height { + self.put_char_styled(x, y.saturating_add(offset), ch, style); + } + } + + pub fn fill_rect(&mut self, rect: Rect, fill: char, style: CellStyle) { + let Some(rect) = self.clip_rect(rect) else { + return; + }; + let x = clamp_i32_to_u16(rect.origin.x); + let y = clamp_i32_to_u16(rect.origin.y); + for row in 0..rect.size.height { + for col in 0..rect.size.width { + self.put_char_styled(x.saturating_add(col), y.saturating_add(row), fill, style); + } + } + } + + pub fn draw_box(&mut self, rect: Rect, border: BorderStyle) { + self.draw_box_styled(rect, border, CellStyle::default()); + } + + pub fn draw_box_styled(&mut self, rect: Rect, border: BorderStyle, style: CellStyle) { + let Some(rect) = self.clip_rect(rect) else { + return; + }; + + let x = clamp_i32_to_u16(rect.origin.x); + let y = clamp_i32_to_u16(rect.origin.y); + let width = rect.size.width; + let height = rect.size.height; + let right = x.saturating_add(width.saturating_sub(1)); + let bottom = y.saturating_add(height.saturating_sub(1)); + + self.put_char_styled(x, y, border.top_left, style); + self.put_char_styled(right, y, border.top_right, style); + self.put_char_styled(x, bottom, border.bottom_left, style); + self.put_char_styled(right, bottom, border.bottom_right, style); + + if width > 2 { + self.draw_hline_styled(x.saturating_add(1), y, width - 2, border.horizontal, style); + self.draw_hline_styled( + x.saturating_add(1), + bottom, + width - 2, + border.horizontal, + style, + ); + } + + if height > 2 { + self.draw_vline_styled(x, y.saturating_add(1), height - 2, border.vertical, style); + self.draw_vline_styled( + right, + y.saturating_add(1), + height - 2, + border.vertical, + style, + ); + } + } + + fn clip_rect(&self, rect: Rect) -> Option { + let left = rect.origin.x.max(0); + let top = rect.origin.y.max(0); + let right = (i64::from(rect.origin.x) + i64::from(rect.size.width)) + .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32; + let bottom = (i64::from(rect.origin.y) + i64::from(rect.size.height)) + .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32; + let right = right.min(i32::from(self.width)); + let bottom = bottom.min(i32::from(self.height)); + + if right <= left || bottom <= top { + return None; + } + + Some(Rect { + origin: embers_core::Point { x: left, y: top }, + size: embers_core::Size { + width: u16::try_from(right - left).unwrap_or(0), + height: u16::try_from(bottom - top).unwrap_or(0), + }, + }) + } + + pub fn lines(&self) -> Vec { + (0..self.height) + .map(|row| { + let start = usize::from(row) * usize::from(self.width); + let end = start + usize::from(self.width); + let mut output = String::new(); + for cell in &self.cells[start..end] { + if cell.continuation { + continue; + } + if cell.text.is_empty() { + output.push(' '); + } else { + output.push_str(&cell.text); + } + } + output + }) + .collect() + } + + pub fn ansi_lines(&self) -> Vec { + (0..self.height) + .map(|row| { + let start = usize::from(row) * usize::from(self.width); + let end = start + usize::from(self.width); + let mut output = String::new(); + let mut current_style = CellStyle::default(); + for cell in &self.cells[start..end] { + if cell.continuation { + continue; + } + write_style_transition(&mut output, current_style, cell.style); + current_style = cell.style; + if cell.text.is_empty() { + output.push(' '); + } else { + output.push_str(&cell.text); + } + } + if !current_style.is_plain() { + output.push_str("\x1b[0m"); + } + output + }) + .collect() + } + + pub fn render(&self) -> String { + self.lines().join("\n") + } + + fn clear_overlapping_cells(&mut self, x: u16, y: u16, width: u16) { + let mut start = x; + while start > 0 && self.cells[self.index(start, y)].continuation { + start -= 1; + } + + let mut end = x.saturating_add(width); + while end < self.width && self.cells[self.index(end, y)].continuation { + end += 1; + } + + for clear_x in start..end { + let idx = self.index(clear_x, y); + self.cells[idx] = Cell::default(); + } + } + + fn set_cell(&mut self, x: u16, y: u16, grapheme: &str, style: CellStyle, width: u16) { + let idx = self.index(x, y); + self.cells[idx] = Cell { + text: grapheme.to_owned(), + style, + continuation: false, + }; + + for offset in 1..width { + let idx = self.index(x + offset, y); + self.cells[idx] = Cell { + text: String::new(), + style, + continuation: true, + }; + } + } + + fn index(&self, x: u16, y: u16) -> usize { + usize::from(y) * usize::from(self.width) + usize::from(x) + } +} + +fn grapheme_width(grapheme: &str) -> u16 { + let width = UnicodeWidthStr::width(grapheme); + u16::try_from(width.max(1)).unwrap_or(u16::MAX) +} + +fn write_style_transition(output: &mut String, from: CellStyle, to: CellStyle) { + if from == to { + return; + } + output.push_str("\x1b[0m"); + if to.bold { + output.push_str("\x1b[1m"); + } + if to.dim { + output.push_str("\x1b[2m"); + } + if to.italic { + output.push_str("\x1b[3m"); + } + if to.underline { + output.push_str("\x1b[4m"); + } + if to.reverse { + output.push_str("\x1b[7m"); + } + if let Some(fg) = to.fg { + let _ = write!(output, "\x1b[38;2;{};{};{}m", fg.red, fg.green, fg.blue); + } + if let Some(bg) = to.bg { + let _ = write!(output, "\x1b[48;2;{};{};{}m", bg.red, bg.green, bg.blue); + } +} + +fn clamp_i32_to_u16(value: i32) -> u16 { + value.clamp(0, i32::from(u16::MAX)) as u16 +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BorderStyle { + pub top_left: char, + pub top_right: char, + pub bottom_left: char, + pub bottom_right: char, + pub horizontal: char, + pub vertical: char, +} + +impl BorderStyle { + pub const ASCII: Self = Self { + top_left: '+', + top_right: '+', + bottom_left: '+', + bottom_right: '+', + horizontal: '-', + vertical: '|', + }; + + pub const FOCUSED: Self = Self { + top_left: '#', + top_right: '#', + bottom_left: '#', + bottom_right: '#', + horizontal: '#', + vertical: '#', + }; +} + +#[cfg(test)] +mod tests { + use super::{CellStyle, Color, GridCursor, RenderGrid}; + use embers_core::{CursorShape, Point, Rect, Size}; + + #[test] + fn render_preserves_plain_text_rows() { + let mut grid = RenderGrid::new(6, 2); + grid.put_str(1, 0, "embers"); + grid.put_str(0, 1, "ok"); + + assert_eq!(grid.render(), " ember\nok "); + } + + #[test] + fn ansi_lines_include_style_sequences() { + let mut grid = RenderGrid::new(4, 1); + grid.put_str_styled( + 0, + 0, + "ab", + CellStyle { + fg: Some(Color { + red: 1, + green: 2, + blue: 3, + }), + bold: true, + ..CellStyle::default() + }, + ); + + let line = &grid.ansi_lines()[0]; + assert!(line.contains("\x1b[1m")); + assert!(line.contains("\x1b[38;2;1;2;3m")); + assert!(line.contains("ab")); + } + + #[test] + fn wide_graphemes_preserve_cell_alignment() { + let mut grid = RenderGrid::new(4, 1); + grid.put_str(0, 0, "界a"); + + assert_eq!(grid.lines()[0], "界a "); + } + + #[test] + fn overwriting_a_wide_grapheme_clears_its_trailing_continuation() { + let mut grid = RenderGrid::new(4, 1); + grid.put_str(0, 0, "界"); + grid.put_char(0, 0, 'a'); + + assert_eq!(grid.lines()[0], "a "); + } + + #[test] + fn overwriting_inside_a_wide_grapheme_clears_the_lead_cell() { + let mut grid = RenderGrid::new(4, 1); + grid.put_str(0, 0, "界"); + grid.put_char(1, 0, 'a'); + + assert_eq!(grid.lines()[0], " a "); + } + + #[test] + fn cursor_is_clamped_to_the_grid() { + let mut grid = RenderGrid::new(4, 2); + grid.set_cursor(Some(GridCursor { + x: 1, + y: 1, + shape: CursorShape::Beam, + })); + assert_eq!( + grid.cursor(), + Some(GridCursor { + x: 1, + y: 1, + shape: CursorShape::Beam + }) + ); + + grid.set_cursor(Some(GridCursor { + x: 5, + y: 1, + shape: CursorShape::Block, + })); + assert_eq!(grid.cursor(), None); + } + + #[test] + fn fill_rect_clips_negative_origin_to_visible_bounds() { + let mut grid = RenderGrid::new(4, 2); + grid.fill_rect( + Rect { + origin: Point { x: -1, y: 0 }, + size: Size { + width: 3, + height: 1, + }, + }, + '#', + CellStyle::default(), + ); + + assert_eq!(grid.lines(), vec!["## ".to_owned(), " ".to_owned()]); + } +} diff --git a/crates/embers-client/src/input/keymap.rs b/crates/embers-client/src/input/keymap.rs new file mode 100644 index 0000000..6cf8f55 --- /dev/null +++ b/crates/embers-client/src/input/keymap.rs @@ -0,0 +1,161 @@ +use std::collections::BTreeMap; + +use super::keyparse::{KeySequence, KeyToken}; +use super::modes::{FallbackPolicy, InputState, ModeSpec}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BindingSpec { + pub notation: String, + pub sequence: KeySequence, + pub target: T, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BindingMatch { + pub mode: String, + pub sequence: KeySequence, + pub target: T, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum InputResolution { + ExactMatch(BindingMatch), + PrefixMatch, + Unmatched { + mode: String, + sequence: KeySequence, + fallback_policy: FallbackPolicy, + }, +} + +pub fn resolve_key( + bindings: &BTreeMap>>, + modes: &BTreeMap, + state: &mut InputState, + key: KeyToken, +) -> InputResolution { + state.push_pending(key); + let mode = state.current_mode().to_owned(); + let pending = state.pending_sequence().to_vec(); + let mode_bindings = bindings.get(&mode).map(Vec::as_slice).unwrap_or(&[]); + + if let Some(binding) = mode_bindings + .iter() + .find(|binding| binding.sequence == pending) + { + state.clear_pending(); + return InputResolution::ExactMatch(BindingMatch { + mode, + sequence: binding.sequence.clone(), + target: binding.target.clone(), + }); + } + + if mode_bindings + .iter() + .any(|binding| binding.sequence.starts_with(&pending)) + { + return InputResolution::PrefixMatch; + } + + let fallback_policy = modes + .get(&mode) + .map(|mode| mode.fallback_policy) + .unwrap_or(FallbackPolicy::Ignore); + state.clear_pending(); + InputResolution::Unmatched { + mode, + sequence: pending, + fallback_policy, + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::{BindingSpec, InputResolution, resolve_key}; + use crate::input::{FallbackPolicy, InputState, KeyToken, ModeSpec, builtin_modes}; + + #[test] + fn exact_match_beats_prefix() { + let mut state = InputState::default(); + let bindings = bindings(&[("normal", "ab", "exact"), ("normal", "abc", "longer")]); + let modes = builtin_modes(); + + assert_eq!( + resolve_key(&bindings, &modes, &mut state, KeyToken::Char('a')), + InputResolution::PrefixMatch + ); + assert_eq!( + resolve_key(&bindings, &modes, &mut state, KeyToken::Char('b')), + InputResolution::ExactMatch(super::BindingMatch { + mode: "normal".to_owned(), + sequence: vec![KeyToken::Char('a'), KeyToken::Char('b')], + target: "exact".to_owned(), + }) + ); + } + + #[test] + fn unmatched_sequences_follow_mode_fallback_policy() { + let mut state = InputState::default(); + let bindings = bindings(&[]); + let modes = BTreeMap::from([( + "locked".to_owned(), + ModeSpec::new("locked", FallbackPolicy::Ignore), + )]); + state.set_mode("locked"); + + assert_eq!( + resolve_key(&bindings, &modes, &mut state, KeyToken::Char('x')), + InputResolution::Unmatched { + mode: "locked".to_owned(), + sequence: vec![KeyToken::Char('x')], + fallback_policy: FallbackPolicy::Ignore, + } + ); + } + + #[test] + fn mode_specific_bindings_resolve_independently() { + let mut state = InputState::default(); + let bindings = bindings(&[("normal", "a", "normal-a"), ("copy", "a", "copy-a")]); + let modes = builtin_modes(); + + assert_eq!( + resolve_key(&bindings, &modes, &mut state, KeyToken::Char('a')), + InputResolution::ExactMatch(super::BindingMatch { + mode: "normal".to_owned(), + sequence: vec![KeyToken::Char('a')], + target: "normal-a".to_owned(), + }) + ); + + state.set_mode("copy"); + + assert_eq!( + resolve_key(&bindings, &modes, &mut state, KeyToken::Char('a')), + InputResolution::ExactMatch(super::BindingMatch { + mode: "copy".to_owned(), + sequence: vec![KeyToken::Char('a')], + target: "copy-a".to_owned(), + }) + ); + } + + fn bindings(entries: &[(&str, &str, &str)]) -> BTreeMap>> { + let mut bindings = BTreeMap::>>::new(); + for (mode, sequence, target) in entries { + bindings + .entry((*mode).to_owned()) + .or_default() + .push(BindingSpec { + notation: (*sequence).to_owned(), + sequence: sequence.chars().map(KeyToken::Char).collect(), + target: (*target).to_owned(), + }); + } + bindings + } +} diff --git a/crates/embers-client/src/input/keyparse.rs b/crates/embers-client/src/input/keyparse.rs new file mode 100644 index 0000000..4517b64 --- /dev/null +++ b/crates/embers-client/src/input/keyparse.rs @@ -0,0 +1,221 @@ +use thiserror::Error; + +pub type KeySequence = Vec; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum KeyToken { + Char(char), + Ctrl(char), + Alt(char), + Enter, + Escape, + Backspace, + Tab, + Space, + Leader, + Up, + Down, + Left, + Right, + Home, + End, + Insert, + Delete, + PageUp, + PageDown, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum KeyParseError { + #[error("key sequence cannot be empty")] + EmptySequence, + #[error("key token '<{token}>' is invalid")] + InvalidToken { token: String }, + #[error("key modifier in '<{token}>' is invalid")] + InvalidModifier { token: String }, + #[error("key token '<{token}>' must contain exactly one character after the modifier")] + InvalidModifiedKey { token: String }, + #[error("key sequence '{notation}' has an unterminated token")] + UnterminatedToken { notation: String }, + #[error("'' cannot be used before a leader is configured")] + MissingLeader, +} + +pub fn parse_key_sequence(notation: &str) -> Result { + if notation.is_empty() { + return Err(KeyParseError::EmptySequence); + } + + let mut sequence = Vec::new(); + let chars = notation.chars().collect::>(); + let mut index = 0; + while index < chars.len() { + match chars[index] { + '<' => { + let mut end = index + 1; + while end < chars.len() && chars[end] != '>' { + end += 1; + } + if end >= chars.len() { + return Err(KeyParseError::UnterminatedToken { + notation: notation.to_owned(), + }); + } + + let token = chars[index + 1..end].iter().collect::(); + sequence.push(parse_token(&token)?); + index = end + 1; + } + ' ' => { + sequence.push(KeyToken::Space); + index += 1; + } + ch => { + sequence.push(KeyToken::Char(ch)); + index += 1; + } + } + } + + Ok(sequence) +} + +pub fn expand_leader( + sequence: impl IntoIterator, + leader: &[KeyToken], +) -> Result { + let mut expanded = Vec::new(); + for token in sequence { + if token == KeyToken::Leader { + if leader.is_empty() { + return Err(KeyParseError::MissingLeader); + } + expanded.extend(leader.iter().cloned()); + } else { + expanded.push(token); + } + } + Ok(expanded) +} + +fn parse_token(token: &str) -> Result { + let lower = token.to_ascii_lowercase(); + match lower.as_str() { + "leader" => Ok(KeyToken::Leader), + "enter" | "return" | "cr" => Ok(KeyToken::Enter), + "esc" | "escape" => Ok(KeyToken::Escape), + "bs" | "backspace" => Ok(KeyToken::Backspace), + "tab" => Ok(KeyToken::Tab), + "space" => Ok(KeyToken::Space), + "up" => Ok(KeyToken::Up), + "down" => Ok(KeyToken::Down), + "left" => Ok(KeyToken::Left), + "right" => Ok(KeyToken::Right), + "home" => Ok(KeyToken::Home), + "end" => Ok(KeyToken::End), + "ins" | "insert" => Ok(KeyToken::Insert), + "del" | "delete" => Ok(KeyToken::Delete), + "pageup" | "pgup" => Ok(KeyToken::PageUp), + "pagedown" | "pgdown" | "pgdn" => Ok(KeyToken::PageDown), + _ => parse_modified_token(token), + } +} + +fn parse_modified_token(token: &str) -> Result { + let Some((modifier, key)) = token.split_once('-') else { + return single_char_token(token).map(KeyToken::Char).ok_or_else(|| { + KeyParseError::InvalidToken { + token: token.to_owned(), + } + }); + }; + + let ch = single_char_token(key).ok_or_else(|| KeyParseError::InvalidModifiedKey { + token: token.to_owned(), + })?; + + match modifier.to_ascii_lowercase().as_str() { + "c" | "ctrl" => Ok(KeyToken::Ctrl(ch.to_ascii_lowercase())), + "a" | "alt" | "m" => Ok(KeyToken::Alt(ch.to_ascii_lowercase())), + _ => Err(KeyParseError::InvalidModifier { + token: token.to_owned(), + }), + } +} + +fn single_char_token(token: &str) -> Option { + let mut chars = token.chars(); + let ch = chars.next()?; + chars.next().is_none().then_some(ch) +} + +#[cfg(test)] +mod tests { + use super::{KeyParseError, KeyToken, expand_leader, parse_key_sequence}; + + #[test] + fn parses_plain_and_modified_keys() { + assert_eq!( + parse_key_sequence( + "ab", + ) + .unwrap(), + vec![ + KeyToken::Char('a'), + KeyToken::Char('b'), + KeyToken::Ctrl('x'), + KeyToken::Alt('z'), + KeyToken::Enter, + KeyToken::Escape, + KeyToken::Tab, + KeyToken::Space, + KeyToken::Home, + KeyToken::Insert, + KeyToken::Delete, + KeyToken::End, + KeyToken::Up, + KeyToken::PageDown, + ] + ); + } + + #[test] + fn rejects_invalid_tokens() { + assert_eq!( + parse_key_sequence("").unwrap_err(), + KeyParseError::InvalidModifier { + token: "Hyper-x".to_owned(), + } + ); + assert_eq!( + parse_key_sequence("").unwrap_err(), + KeyParseError::InvalidModifiedKey { + token: "C-ab".to_owned(), + } + ); + } + + #[test] + fn expands_leader_tokens() { + let sequence = parse_key_sequence("ws").unwrap(); + let leader = parse_key_sequence("").unwrap(); + + assert_eq!( + expand_leader(sequence, &leader).unwrap(), + vec![ + KeyToken::Ctrl('a'), + KeyToken::Char('w'), + KeyToken::Char('s'), + ] + ); + } + + #[test] + fn leader_expansion_requires_configured_leader() { + let sequence = parse_key_sequence("x").unwrap(); + assert_eq!( + expand_leader(sequence, &[]).unwrap_err(), + KeyParseError::MissingLeader + ); + } +} diff --git a/crates/embers-client/src/input/mod.rs b/crates/embers-client/src/input/mod.rs new file mode 100644 index 0000000..f53b140 --- /dev/null +++ b/crates/embers-client/src/input/mod.rs @@ -0,0 +1,10 @@ +mod keymap; +mod keyparse; +mod modes; + +pub use keymap::{BindingMatch, BindingSpec, InputResolution, resolve_key}; +pub use keyparse::{KeyParseError, KeySequence, KeyToken, expand_leader, parse_key_sequence}; +pub use modes::{ + COPY_MODE, FallbackPolicy, InputState, ModeSpec, NORMAL_MODE, SEARCH_MODE, SELECT_MODE, + builtin_modes, +}; diff --git a/crates/embers-client/src/input/modes.rs b/crates/embers-client/src/input/modes.rs new file mode 100644 index 0000000..c3985c0 --- /dev/null +++ b/crates/embers-client/src/input/modes.rs @@ -0,0 +1,117 @@ +use std::collections::BTreeMap; + +use crate::input::keyparse::KeySequence; + +pub const NORMAL_MODE: &str = "normal"; +pub const COPY_MODE: &str = "copy"; +pub const SEARCH_MODE: &str = "search"; +pub const SELECT_MODE: &str = "select"; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub enum FallbackPolicy { + #[default] + Passthrough, + Ignore, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ModeSpec { + pub name: String, + pub fallback_policy: FallbackPolicy, +} + +impl ModeSpec { + pub fn new(name: impl Into, fallback_policy: FallbackPolicy) -> Self { + Self { + name: name.into(), + fallback_policy, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InputState { + current_mode: String, + pending_sequence: KeySequence, +} + +impl Default for InputState { + fn default() -> Self { + Self { + current_mode: NORMAL_MODE.to_owned(), + pending_sequence: Vec::new(), + } + } +} + +impl InputState { + pub fn current_mode(&self) -> &str { + &self.current_mode + } + + pub fn pending_sequence(&self) -> &[crate::input::KeyToken] { + &self.pending_sequence + } + + pub fn set_mode(&mut self, mode: impl Into) { + let mode = mode.into(); + if self.current_mode != mode { + self.current_mode = mode; + self.pending_sequence.clear(); + } + } + + pub fn clear_pending(&mut self) { + self.pending_sequence.clear(); + } + + pub(crate) fn push_pending(&mut self, key: crate::input::KeyToken) { + self.pending_sequence.push(key); + } +} + +pub fn builtin_modes() -> BTreeMap { + BTreeMap::from([ + ( + NORMAL_MODE.to_owned(), + ModeSpec::new(NORMAL_MODE, FallbackPolicy::Passthrough), + ), + ( + COPY_MODE.to_owned(), + ModeSpec::new(COPY_MODE, FallbackPolicy::Ignore), + ), + ( + SEARCH_MODE.to_owned(), + ModeSpec::new(SEARCH_MODE, FallbackPolicy::Ignore), + ), + ( + SELECT_MODE.to_owned(), + ModeSpec::new(SELECT_MODE, FallbackPolicy::Ignore), + ), + ]) +} + +#[cfg(test)] +mod tests { + use super::{InputState, builtin_modes}; + + #[test] + fn switching_modes_clears_pending_state() { + let mut state = InputState::default(); + state.push_pending(crate::input::KeyToken::Char('a')); + + state.set_mode("copy"); + + assert_eq!(state.current_mode(), "copy"); + assert!(state.pending_sequence().is_empty()); + } + + #[test] + fn builtin_modes_include_normal_copy_search_and_select() { + let modes = builtin_modes(); + assert!(modes.contains_key("normal")); + assert!(modes.contains_key("copy")); + assert!(modes.contains_key("search")); + assert!(modes.contains_key("select")); + } +} diff --git a/crates/embers-client/src/lib.rs b/crates/embers-client/src/lib.rs new file mode 100644 index 0000000..ecd978f --- /dev/null +++ b/crates/embers-client/src/lib.rs @@ -0,0 +1,49 @@ +pub mod client; +pub mod config; +pub mod configured_client; +pub mod controller; +pub mod grid; +pub mod input; +pub mod presentation; +pub mod renderer; +pub mod scripting; +pub mod socket_transport; +pub mod state; +pub mod testing; +pub mod transport; + +pub use client::MuxClient; +pub use config::{ + BUILTIN_CONFIG_SOURCE, CONFIG_ENV_VAR, ConfigDiscoveryOptions, ConfigError, ConfigManager, + ConfigManagerError, ConfigOrigin, DiscoveredConfig, LoadedConfigSource, config_file_in_dir, + default_config_path, discover_config, load_config_source, +}; +pub use configured_client::ConfiguredClient; +pub use controller::{ + Controller, KeyEvent, MouseButton, MouseEvent, MouseEventKind, MouseModifiers, +}; +pub use grid::{BorderStyle, CellStyle, Color, GridCursor, RenderGrid}; +pub use input::{ + BindingMatch, BindingSpec, COPY_MODE, FallbackPolicy, InputResolution, InputState, + KeyParseError, KeySequence, KeyToken, ModeSpec, NORMAL_MODE, SEARCH_MODE, SELECT_MODE, + expand_leader, parse_key_sequence, resolve_key, +}; +pub use presentation::{ + DividerFrame, FloatingFrame, LeafFrame, NavigationDirection, PresentationModel, TabItem, + TabsFrame, +}; +pub use renderer::Renderer; +pub use scripting::{ + Action, BarSegment, BarSpec, BarTarget, BufferRef, BufferSpawnSpec, Context, EventInfo, + FloatingAnchor, FloatingGeometrySpec, FloatingRef, FloatingSize, FloatingSpec, LoadedConfig, + ModeHooks, MouseSettings, NodeRef, NotifyLevel, PaletteError, RgbColor, ScriptEngine, + ScriptError, ScriptFunctionRef, ScriptHarness, SessionRef, StyleSpec, TabBarContext, TabInfo, + TabSpec, TabsSpec, ThemeSpec, TreeSpec, +}; +pub use socket_transport::SocketTransport; +pub use state::{ + BufferViewState, ClientState, SearchMatch, SearchState, SelectionKind, SelectionPoint, + SelectionState, +}; +pub use testing::{FakeTransport, ScriptedTransport, TestGrid}; +pub use transport::Transport; diff --git a/crates/embers-client/src/presentation.rs b/crates/embers-client/src/presentation.rs new file mode 100644 index 0000000..b36a720 --- /dev/null +++ b/crates/embers-client/src/presentation.rs @@ -0,0 +1,786 @@ +use std::collections::BTreeMap; + +use embers_core::{ + ActivityState, BufferId, FloatGeometry, FloatingId, MuxError, NodeId, Point, Rect, Result, + SessionId, Size, SplitDirection, +}; +use embers_protocol::{NodeRecordKind, SessionRecord}; + +use crate::state::ClientState; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NavigationDirection { + Left, + Right, + Up, + Down, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TabItem { + pub title: String, + pub child_id: NodeId, + pub active: bool, + pub activity: ActivityState, + pub buffer_count: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TabsFrame { + pub node_id: NodeId, + pub rect: Rect, + pub tabs: Vec, + pub active: usize, + pub is_root: bool, + pub floating_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LeafFrame { + pub node_id: NodeId, + pub buffer_id: BufferId, + pub rect: Rect, + pub title: String, + pub activity: ActivityState, + pub focused: bool, + pub floating_id: Option, + pub tabs_path: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DividerFrame { + pub direction: SplitDirection, + pub rect: Rect, + pub floating_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FloatingFrame { + pub floating_id: FloatingId, + pub rect: Rect, + pub content_rect: Rect, + pub title: Option, + pub focused: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PresentationModel { + pub session_id: SessionId, + pub viewport: Size, + pub root_tabs: Option, + pub tab_bars: Vec, + pub leaves: Vec, + pub dividers: Vec, + pub floating: Vec, +} + +impl PresentationModel { + pub fn project(state: &ClientState, session_id: SessionId, viewport: Size) -> Result { + let session = state + .sessions + .get(&session_id) + .ok_or_else(|| MuxError::not_found(format!("session {session_id} is not cached")))?; + let root_bounds = Rect { + origin: Point { x: 0, y: 0 }, + size: viewport, + }; + let mut projection = Projection::default(); + let mut projector = Projector { + state, + session, + projection: &mut projection, + activity_by_node: BTreeMap::new(), + buffer_count_by_node: BTreeMap::new(), + }; + projector.project_node(session.root_node_id, root_bounds, None, true, Vec::new())?; + + let overlay_bounds = root_bounds; + for floating_id in &session.floating_ids { + let Some(window) = state.floating.get(floating_id) else { + continue; + }; + if !window.visible { + continue; + } + + let rect = clip_rect(geometry_rect(window.geometry), overlay_bounds); + if rect.size.width == 0 || rect.size.height == 0 { + continue; + } + + let content_rect = inset_border(rect); + projector.projection.floating.push(FloatingFrame { + floating_id: window.id, + rect, + content_rect, + title: window.title.clone(), + focused: window.focused, + }); + + projector.project_node( + window.root_node_id, + content_rect, + Some(window.id), + false, + Vec::new(), + )?; + } + + let root_tabs = projection.tab_bars.iter().find(|bar| bar.is_root).cloned(); + + Ok(Self { + session_id, + viewport, + root_tabs, + tab_bars: projection.tab_bars, + leaves: projection.leaves, + dividers: projection.dividers, + floating: projection.floating, + }) + } + + pub fn focused_leaf(&self) -> Option<&LeafFrame> { + self.leaves.iter().find(|leaf| leaf.focused) + } + + pub fn focused_buffer_id(&self) -> Option { + self.focused_leaf().map(|leaf| leaf.buffer_id) + } + + pub fn focused_floating_id(&self) -> Option { + self.focused_leaf() + .and_then(|leaf| leaf.floating_id) + .or_else(|| { + self.floating + .iter() + .find(|floating| floating.focused) + .map(|floating| floating.floating_id) + }) + } + + pub fn focused_tabs(&self) -> Option<&TabsFrame> { + let focused_leaf = self.focused_leaf()?; + let tabs_node_id = focused_leaf.tabs_path.last().copied()?; + self.tab_bars.iter().find(|bar| bar.node_id == tabs_node_id) + } + + pub fn focus_target(&self, direction: NavigationDirection) -> Option { + let focused = self.focused_leaf()?; + let focused_context = focused.floating_id; + let focused_center = rect_center(focused.rect); + + self.leaves + .iter() + .filter(|candidate| { + candidate.node_id != focused.node_id && candidate.floating_id == focused_context + }) + .filter_map(|candidate| { + direction_score(focused.rect, candidate.rect, focused_center, direction) + .map(|score| (score, candidate.node_id)) + }) + .min_by(|left, right| left.0.cmp(&right.0)) + .map(|(_, node_id)| node_id) + } + + pub fn leaf_at(&self, point: Point) -> Option<&LeafFrame> { + self.leaves + .iter() + .rev() + .find(|leaf| leaf.rect.contains(point)) + } + + pub fn floating_at(&self, point: Point) -> Option<&FloatingFrame> { + self.floating + .iter() + .rev() + .find(|floating| floating.rect.contains(point)) + } +} + +#[derive(Default)] +struct Projection { + tab_bars: Vec, + leaves: Vec, + dividers: Vec, + floating: Vec, +} + +struct Projector<'a> { + state: &'a ClientState, + session: &'a SessionRecord, + projection: &'a mut Projection, + activity_by_node: BTreeMap, + buffer_count_by_node: BTreeMap, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct FocusScore { + primary: u32, + secondary: u32, + tertiary: u32, +} + +impl Projector<'_> { + fn project_node( + &mut self, + node_id: NodeId, + rect: Rect, + floating_id: Option, + is_root: bool, + tabs_path: Vec, + ) -> Result<()> { + if rect.size.width == 0 || rect.size.height == 0 { + return Ok(()); + } + + let node = self + .state + .nodes + .get(&node_id) + .ok_or_else(|| MuxError::not_found(format!("node {node_id} is not cached")))?; + + match node.kind { + NodeRecordKind::BufferView => { + let buffer_view = node.buffer_view.as_ref().ok_or_else(|| { + MuxError::protocol(format!("buffer-view node {} is missing payload", node.id)) + })?; + let buffer = self + .state + .buffers + .get(&buffer_view.buffer_id) + .ok_or_else(|| { + MuxError::not_found(format!( + "buffer {} is not cached", + buffer_view.buffer_id + )) + })?; + self.projection.leaves.push(LeafFrame { + node_id: node.id, + buffer_id: buffer.id, + rect, + title: buffer.title.clone(), + activity: buffer.activity, + focused: self.session.focused_leaf_id == Some(node.id), + floating_id, + tabs_path, + }); + Ok(()) + } + NodeRecordKind::Tabs => { + let tabs = node.tabs.as_ref().ok_or_else(|| { + MuxError::protocol(format!("tabs node {} is missing payload", node.id)) + })?; + let active_index = usize::try_from(tabs.active).map_err(|_| { + MuxError::protocol(format!( + "tabs node {} has active index {} that exceeds platform limits", + node.id, tabs.active + )) + })?; + let active_child = tabs.tabs.get(active_index).ok_or_else(|| { + MuxError::protocol(format!( + "tabs node {} has invalid active index {}", + node.id, tabs.active + )) + })?; + + let bar_rect = Rect { + origin: rect.origin, + size: Size { + width: rect.size.width, + height: 1, + }, + }; + let mut tab_items = Vec::with_capacity(tabs.tabs.len()); + for (index, tab) in tabs.tabs.iter().enumerate() { + tab_items.push(TabItem { + title: tab.title.clone(), + child_id: tab.child_id, + active: u32::try_from(index).ok() == Some(tabs.active), + activity: subtree_activity_cached( + self.state, + tab.child_id, + &mut self.activity_by_node, + ), + buffer_count: subtree_buffer_count_cached( + self.state, + tab.child_id, + &mut self.buffer_count_by_node, + ), + }); + } + + self.projection.tab_bars.push(TabsFrame { + node_id: node.id, + rect: bar_rect, + tabs: tab_items, + active: active_index, + is_root, + floating_id, + }); + + let mut child_tabs_path = tabs_path; + child_tabs_path.push(node.id); + self.project_node( + active_child.child_id, + inset_top(rect, 1), + floating_id, + false, + child_tabs_path, + ) + } + NodeRecordKind::Split => { + let split = node.split.as_ref().ok_or_else(|| { + MuxError::protocol(format!("split node {} is missing payload", node.id)) + })?; + if split.child_ids.is_empty() { + return Ok(()); + } + + let child_rects = + split_rects(rect, split.direction, &split.sizes, split.child_ids.len()); + for (index, child_id) in split.child_ids.iter().enumerate() { + self.project_node( + *child_id, + child_rects[index], + floating_id, + false, + tabs_path.clone(), + )?; + + if let Some(divider_rect) = divider_rect_for( + split.direction, + child_rects[index], + index, + split.child_ids.len(), + ) { + self.projection.dividers.push(DividerFrame { + direction: split.direction, + rect: divider_rect, + floating_id, + }); + } + } + + Ok(()) + } + } + } +} + +#[allow(dead_code)] +fn subtree_activity(state: &ClientState, node_id: NodeId) -> ActivityState { + subtree_activity_cached(state, node_id, &mut BTreeMap::new()) +} + +fn subtree_activity_cached( + state: &ClientState, + node_id: NodeId, + cache: &mut BTreeMap, +) -> ActivityState { + if let Some(activity) = cache.get(&node_id).copied() { + return activity; + } + + let Some(node) = state.nodes.get(&node_id) else { + return ActivityState::Idle; + }; + + let activity = match node.kind { + NodeRecordKind::BufferView => node + .buffer_view + .as_ref() + .and_then(|view| state.buffers.get(&view.buffer_id)) + .map_or(ActivityState::Idle, |buffer| buffer.activity), + NodeRecordKind::Tabs => node + .tabs + .as_ref() + .map(|tabs| { + tabs.tabs.iter().fold(ActivityState::Idle, |activity, tab| { + max_activity( + activity, + subtree_activity_cached(state, tab.child_id, cache), + ) + }) + }) + .unwrap_or(ActivityState::Idle), + NodeRecordKind::Split => node + .split + .as_ref() + .map(|split| { + split + .child_ids + .iter() + .fold(ActivityState::Idle, |activity, child_id| { + max_activity(activity, subtree_activity_cached(state, *child_id, cache)) + }) + }) + .unwrap_or(ActivityState::Idle), + }; + + cache.insert(node_id, activity); + activity +} + +pub(crate) fn subtree_buffer_count(state: &ClientState, node_id: NodeId) -> usize { + subtree_buffer_count_cached(state, node_id, &mut BTreeMap::new()) +} + +fn subtree_buffer_count_cached( + state: &ClientState, + node_id: NodeId, + cache: &mut BTreeMap, +) -> usize { + if let Some(count) = cache.get(&node_id).copied() { + return count; + } + + let Some(node) = state.nodes.get(&node_id) else { + return 0; + }; + + let count = match node.kind { + NodeRecordKind::BufferView => usize::from(node.buffer_view.is_some()), + NodeRecordKind::Tabs => node + .tabs + .as_ref() + .map(|tabs| { + tabs.tabs + .iter() + .map(|tab| subtree_buffer_count_cached(state, tab.child_id, cache)) + .sum() + }) + .unwrap_or(0), + NodeRecordKind::Split => node + .split + .as_ref() + .map(|split| { + split + .child_ids + .iter() + .map(|child_id| subtree_buffer_count_cached(state, *child_id, cache)) + .sum() + }) + .unwrap_or(0), + }; + + cache.insert(node_id, count); + count +} + +fn max_activity(left: ActivityState, right: ActivityState) -> ActivityState { + if activity_rank(right) > activity_rank(left) { + right + } else { + left + } +} + +fn activity_rank(activity: ActivityState) -> u8 { + match activity { + ActivityState::Idle => 0, + ActivityState::Activity => 1, + ActivityState::Bell => 2, + } +} + +fn split_rects( + rect: Rect, + direction: SplitDirection, + sizes: &[u16], + child_count: usize, +) -> Vec { + if child_count == 0 { + return Vec::new(); + } + + let divider_count = u16::try_from(child_count.saturating_sub(1)).unwrap_or(u16::MAX); + let available = match direction { + SplitDirection::Horizontal => rect.size.height.saturating_sub(divider_count), + SplitDirection::Vertical => rect.size.width.saturating_sub(divider_count), + }; + let lengths = proportional_lengths(available, sizes, child_count); + + let mut rects = Vec::with_capacity(child_count); + let mut x = rect.origin.x; + let mut y = rect.origin.y; + for length in lengths { + let child_rect = match direction { + SplitDirection::Horizontal => Rect { + origin: Point { x, y }, + size: Size { + width: rect.size.width, + height: length, + }, + }, + SplitDirection::Vertical => Rect { + origin: Point { x, y }, + size: Size { + width: length, + height: rect.size.height, + }, + }, + }; + rects.push(child_rect); + + match direction { + SplitDirection::Horizontal => { + y += i32::from(length) + 1; + } + SplitDirection::Vertical => { + x += i32::from(length) + 1; + } + } + } + + rects +} + +fn proportional_lengths(total: u16, sizes: &[u16], child_count: usize) -> Vec { + if child_count == 0 { + return Vec::new(); + } + + if total == 0 { + return vec![0; child_count]; + } + + let weights = if sizes.len() == child_count && sizes.iter().any(|weight| *weight > 0) { + sizes.to_vec() + } else { + vec![1; child_count] + }; + let weight_sum = weights + .iter() + .map(|weight| u32::from(*weight)) + .sum::() + .max(1); + let total_u32 = u32::from(total); + + let mut lengths = vec![0_u16; child_count]; + let mut used = 0_u16; + for (index, weight) in weights.iter().enumerate() { + if index + 1 == child_count { + lengths[index] = total.saturating_sub(used); + break; + } + + let length = ((total_u32 * u32::from(*weight)) / weight_sum) as u16; + lengths[index] = length; + used = used.saturating_add(length); + } + + let mut remainder = total.saturating_sub(lengths.iter().sum::()); + let mut index = 0; + while remainder > 0 { + lengths[index % child_count] = lengths[index % child_count].saturating_add(1); + remainder -= 1; + index += 1; + } + + lengths +} + +fn divider_rect_for( + direction: SplitDirection, + rect: Rect, + index: usize, + child_count: usize, +) -> Option { + if index + 1 == child_count { + return None; + } + + match direction { + SplitDirection::Horizontal => Some(Rect { + origin: Point { + x: rect.origin.x, + y: rect.origin.y + i32::from(rect.size.height), + }, + size: Size { + width: rect.size.width, + height: 1, + }, + }), + SplitDirection::Vertical => Some(Rect { + origin: Point { + x: rect.origin.x + i32::from(rect.size.width), + y: rect.origin.y, + }, + size: Size { + width: 1, + height: rect.size.height, + }, + }), + } +} + +fn inset_top(rect: Rect, amount: u16) -> Rect { + let consumed = amount.min(rect.size.height); + Rect { + origin: Point { + x: rect.origin.x, + y: rect.origin.y + i32::from(consumed), + }, + size: Size { + width: rect.size.width, + height: rect.size.height.saturating_sub(consumed), + }, + } +} + +fn inset_border(rect: Rect) -> Rect { + Rect { + origin: Point { + x: rect.origin.x + 1, + y: rect.origin.y + 1, + }, + size: Size { + width: rect.size.width.saturating_sub(2), + height: rect.size.height.saturating_sub(2), + }, + } +} + +fn geometry_rect(geometry: FloatGeometry) -> Rect { + Rect { + origin: Point { + x: i32::from(geometry.x), + y: i32::from(geometry.y), + }, + size: Size { + width: geometry.width, + height: geometry.height, + }, + } +} + +fn clip_rect(rect: Rect, bounds: Rect) -> Rect { + let left = rect.origin.x.max(bounds.origin.x); + let top = rect.origin.y.max(bounds.origin.y); + let right = (rect.origin.x + i32::from(rect.size.width)) + .min(bounds.origin.x + i32::from(bounds.size.width)); + let bottom = (rect.origin.y + i32::from(rect.size.height)) + .min(bounds.origin.y + i32::from(bounds.size.height)); + + if right <= left || bottom <= top { + return Rect { + origin: Point { x: left, y: top }, + size: Size { + width: 0, + height: 0, + }, + }; + } + + Rect { + origin: Point { x: left, y: top }, + size: Size { + width: u16::try_from(right - left).unwrap_or(0), + height: u16::try_from(bottom - top).unwrap_or(0), + }, + } +} + +fn rect_center(rect: Rect) -> Point { + Point { + x: rect.origin.x + i32::from(rect.size.width / 2), + y: rect.origin.y + i32::from(rect.size.height / 2), + } +} + +fn direction_score( + focused: Rect, + candidate: Rect, + focused_center: Point, + direction: NavigationDirection, +) -> Option { + let candidate_center = rect_center(candidate); + + let (primary, secondary, tertiary) = match direction { + NavigationDirection::Left => { + let candidate_right = candidate.origin.x + i32::from(candidate.size.width); + if candidate_right > focused.origin.x { + return None; + } + ( + (focused.origin.x - candidate_right) as u32, + (focused_center.y - candidate_center.y).unsigned_abs(), + (focused_center.x - candidate_center.x).unsigned_abs(), + ) + } + NavigationDirection::Right => { + let focused_right = focused.origin.x + i32::from(focused.size.width); + if candidate.origin.x < focused_right { + return None; + } + ( + (candidate.origin.x - focused_right) as u32, + (focused_center.y - candidate_center.y).unsigned_abs(), + (focused_center.x - candidate_center.x).unsigned_abs(), + ) + } + NavigationDirection::Up => { + let candidate_bottom = candidate.origin.y + i32::from(candidate.size.height); + if candidate_bottom > focused.origin.y { + return None; + } + ( + (focused.origin.y - candidate_bottom) as u32, + (focused_center.x - candidate_center.x).unsigned_abs(), + (focused_center.y - candidate_center.y).unsigned_abs(), + ) + } + NavigationDirection::Down => { + let focused_bottom = focused.origin.y + i32::from(focused.size.height); + if candidate.origin.y < focused_bottom { + return None; + } + ( + (candidate.origin.y - focused_bottom) as u32, + (focused_center.x - candidate_center.x).unsigned_abs(), + (focused_center.y - candidate_center.y).unsigned_abs(), + ) + } + }; + + Some(FocusScore { + primary, + secondary, + tertiary, + }) +} + +#[cfg(test)] +mod tests { + use embers_core::{Point, Rect, Size}; + + use super::{NavigationDirection, direction_score}; + + #[test] + fn direction_score_rejects_candidates_outside_requested_axis() { + let focused = Rect { + origin: Point { x: 10, y: 5 }, + size: Size { + width: 4, + height: 3, + }, + }; + let overlapping_left = Rect { + origin: Point { x: 8, y: 5 }, + size: Size { + width: 3, + height: 3, + }, + }; + + assert_eq!( + direction_score( + focused, + overlapping_left, + Point { x: 12, y: 6 }, + NavigationDirection::Left + ), + None + ); + } +} diff --git a/crates/embers-client/src/renderer.rs b/crates/embers-client/src/renderer.rs new file mode 100644 index 0000000..4219577 --- /dev/null +++ b/crates/embers-client/src/renderer.rs @@ -0,0 +1,684 @@ +use std::collections::BTreeMap; + +use embers_core::{ActivityState, Point, Rect, Size}; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +use crate::grid::{BorderStyle, CellStyle, GridCursor, RenderGrid}; +use crate::presentation::{DividerFrame, FloatingFrame, LeafFrame, PresentationModel, TabsFrame}; +use crate::scripting::{BarSegment, BarSpec}; +use crate::state::{ClientState, SelectionKind, SelectionPoint, SelectionState}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct Renderer; + +impl Renderer { + pub fn render(&self, state: &ClientState, model: &PresentationModel) -> RenderGrid { + self.render_with_tab_bars(state, model, &BTreeMap::new()) + } + + pub fn render_with_tab_bars( + &self, + state: &ClientState, + model: &PresentationModel, + tab_bars: &BTreeMap, + ) -> RenderGrid { + let mut grid = RenderGrid::new(model.viewport.width, model.viewport.height); + self.render_into_with_tab_bars(state, model, &mut grid, tab_bars); + grid + } + + pub fn render_into( + &self, + state: &ClientState, + model: &PresentationModel, + grid: &mut RenderGrid, + ) { + self.render_into_with_tab_bars(state, model, grid, &BTreeMap::new()); + } + + pub fn render_into_with_tab_bars( + &self, + state: &ClientState, + model: &PresentationModel, + grid: &mut RenderGrid, + tab_bars: &BTreeMap, + ) { + grid.clear(' '); + self.render_layer(state, model, grid, None, tab_bars); + + for window in &model.floating { + self.render_floating_frame(grid, window); + self.render_layer(state, model, grid, Some(window.floating_id), tab_bars); + } + } + + fn render_layer( + &self, + state: &ClientState, + model: &PresentationModel, + grid: &mut RenderGrid, + floating_id: Option, + tab_bars: &BTreeMap, + ) { + for leaf in model + .leaves + .iter() + .filter(|leaf| leaf.floating_id == floating_id) + { + self.render_leaf(state, grid, leaf); + } + + for divider in model + .dividers + .iter() + .filter(|divider| divider.floating_id == floating_id) + { + self.render_divider(grid, divider); + } + + for tabs in model + .tab_bars + .iter() + .filter(|tabs| tabs.floating_id == floating_id) + { + self.render_tabs(grid, tabs, tab_bars.get(&tabs.node_id)); + } + } + + fn render_tabs(&self, grid: &mut RenderGrid, tabs: &TabsFrame, custom: Option<&BarSpec>) { + if tabs.rect.size.height == 0 || tabs.rect.size.width == 0 { + return; + } + + let mut x = clamp_i32_to_u16(tabs.rect.origin.x); + let y = clamp_i32_to_u16(tabs.rect.origin.y); + let end_x = x.saturating_add(tabs.rect.size.width); + grid.put_str(x, y, &" ".repeat(usize::from(tabs.rect.size.width))); + + if let Some(bar) = custom { + let width = tabs.rect.size.width; + let left_width = bar + .left + .iter() + .map(|segment| display_width(&segment.text)) + .sum::() + .min(width); + let right_width = bar + .right + .iter() + .map(|segment| display_width(&segment.text)) + .sum::() + .min(width); + let left_end = x.saturating_add(left_width); + let right_start = end_x.saturating_sub(right_width); + render_bar_segments(grid, x, y, left_end, &bar.left); + render_bar_segments(grid, right_start, y, end_x, &bar.right); + + let center_span = right_start.saturating_sub(left_end); + let center_width = bar + .center + .iter() + .map(|segment| display_width(&segment.text)) + .sum::() + .min(center_span); + let center_x = left_end.saturating_add(center_span.saturating_sub(center_width) / 2); + render_bar_segments( + grid, + center_x, + y, + center_x.saturating_add(center_width), + &bar.center, + ); + return; + } + + for tab in &tabs.tabs { + if x >= end_x { + break; + } + + let available = end_x.saturating_sub(x); + if available == 0 { + break; + } + + let label = format_tab_label(tab, available); + grid.put_str_styled(x, y, &label, tab_style(tab.active)); + x = x.saturating_add(display_width(&label)); + + if x < end_x { + grid.put_char(x, y, ' '); + x += 1; + } + } + } + + fn render_leaf(&self, state: &ClientState, grid: &mut RenderGrid, leaf: &LeafFrame) { + if leaf.rect.size.width == 0 || leaf.rect.size.height == 0 { + return; + } + + let x = clamp_i32_to_u16(leaf.rect.origin.x); + let y = clamp_i32_to_u16(leaf.rect.origin.y); + let width = leaf.rect.size.width; + let height = leaf.rect.size.height; + let blank_line = " ".repeat(usize::from(width)); + for row in 0..height { + grid.put_str(x, y + row, &blank_line); + } + + let activity = activity_marker(leaf.activity); + let title = truncate( + &format!( + "{}{} {}", + if leaf.focused { '>' } else { ' ' }, + activity, + leaf.title + ), + width, + ); + grid.put_str_styled(x, y, &title, leaf_title_style(leaf.focused)); + + if height <= 1 { + return; + } + + let view_state = state.view_state(leaf.node_id); + let lines = view_state + .map(|view| view.visible_lines.as_slice()) + .filter(|lines| !lines.is_empty()) + .or_else(|| { + state + .snapshots + .get(&leaf.buffer_id) + .map(|snapshot| snapshot.lines.as_slice()) + }); + + if let Some(lines) = lines { + for (row, line) in lines + .iter() + .take(usize::from(height.saturating_sub(1))) + .enumerate() + { + let Some(row) = u16::try_from(row).ok() else { + break; + }; + grid.put_str(x, y + 1 + row, &truncate(line, width)); + } + } + + if let Some(view_state) = view_state + && !view_state.alternate_screen + { + if let Some(search_state) = &view_state.search_state { + render_search_overlay( + grid, + x, + y + 1, + width, + view_state.scroll_top_line, + &view_state.visible_lines, + search_state, + ); + } + if let Some(selection_state) = &view_state.selection_state { + render_selection_overlay( + grid, + x, + y + 1, + width, + view_state.scroll_top_line, + &view_state.visible_lines, + selection_state, + ); + } + if !view_state.follow_output { + render_scroll_indicator( + grid, + x, + y, + width, + view_state.scroll_top_line, + view_state.total_line_count, + ); + } + } + + if let Some(snapshot) = state.snapshots.get(&leaf.buffer_id) + && leaf.focused + { + let locally_scrolled = view_state.is_some_and(|view| { + !view.alternate_screen && view.scroll_top_line != snapshot.viewport_top_line + }); + let selection_active = view_state.is_some_and(|view| view.selection_state.is_some()); + if !locally_scrolled + && !selection_active + && let Some(cursor) = snapshot.cursor + && cursor.position.col < width + { + let cursor_y = y + 1 + cursor.position.row; + if cursor_y < y.saturating_add(height) { + grid.set_cursor(Some(GridCursor { + x: x + cursor.position.col, + y: cursor_y, + shape: cursor.shape, + })); + } + } + } + } + + fn render_divider(&self, grid: &mut RenderGrid, divider: &DividerFrame) { + if divider.rect.size.width == 0 || divider.rect.size.height == 0 { + return; + } + + let x = clamp_i32_to_u16(divider.rect.origin.x); + let y = clamp_i32_to_u16(divider.rect.origin.y); + match divider.direction { + embers_core::SplitDirection::Horizontal => { + grid.draw_hline_styled(x, y, divider.rect.size.width, '-', divider_style()); + } + embers_core::SplitDirection::Vertical => { + grid.draw_vline_styled(x, y, divider.rect.size.height, '|', divider_style()); + } + } + } + + fn render_floating_frame(&self, grid: &mut RenderGrid, floating: &FloatingFrame) { + let blank_line = " ".repeat(usize::from(floating.rect.size.width)); + let x = clamp_i32_to_u16(floating.rect.origin.x); + let y = clamp_i32_to_u16(floating.rect.origin.y); + for row in 0..floating.rect.size.height { + grid.put_str(x, y + row, &blank_line); + } + + let border = if floating.focused { + BorderStyle::FOCUSED + } else { + BorderStyle::ASCII + }; + grid.draw_box_styled( + floating.rect, + border, + floating_border_style(floating.focused), + ); + + if let Some(title) = &floating.title { + let title_rect = Rect { + origin: Point { + x: floating.rect.origin.x + 2, + y: floating.rect.origin.y, + }, + size: Size { + width: floating.rect.size.width.saturating_sub(4), + height: 1, + }, + }; + if title_rect.size.width > 0 { + grid.put_str_styled( + clamp_i32_to_u16(title_rect.origin.x), + clamp_i32_to_u16(title_rect.origin.y), + &truncate(title, title_rect.size.width), + floating_title_style(floating.focused), + ); + } + } + } +} + +fn render_scroll_indicator( + grid: &mut RenderGrid, + x: u16, + y: u16, + width: u16, + top_line: u64, + total_lines: u64, +) { + if width == 0 || total_lines == 0 { + return; + } + let label = truncate( + &format!("{}/{}", top_line.saturating_add(1), total_lines), + width, + ); + let label_width = display_width(&label).min(width); + let origin_x = x.saturating_add(width.saturating_sub(label_width)); + grid.put_str_styled(origin_x, y, &label, scroll_indicator_style()); +} + +fn render_search_overlay( + grid: &mut RenderGrid, + x: u16, + y: u16, + width: u16, + top_line: u64, + lines: &[String], + search_state: &crate::state::SearchState, +) { + let Some(active_index) = search_state.active_match_index else { + return; + }; + for (index, search_match) in search_state.matches.iter().enumerate() { + if search_match.line < top_line { + continue; + } + let relative_row = search_match.line - top_line; + let Some(relative_row) = u16::try_from(relative_row).ok() else { + continue; + }; + if relative_row >= u16::try_from(lines.len()).unwrap_or(u16::MAX) { + continue; + } + let line = &lines[usize::from(relative_row)]; + overlay_display_range( + grid, + OverlayLine { + x, + y: y.saturating_add(relative_row), + width, + text: line, + }, + search_match.start_column, + search_match.end_column, + if index == active_index { + active_search_style() + } else { + search_style() + }, + ); + } +} + +fn render_selection_overlay( + grid: &mut RenderGrid, + x: u16, + y: u16, + width: u16, + top_line: u64, + lines: &[String], + selection_state: &SelectionState, +) { + for (row, line) in lines.iter().enumerate() { + let Some(row_u16) = u16::try_from(row).ok() else { + break; + }; + let line_number = top_line.saturating_add(u64::try_from(row).unwrap_or(u64::MAX)); + let Some((start_column, end_column)) = + selection_range_for_line(selection_state, line_number, width, line) + else { + continue; + }; + overlay_display_range( + grid, + OverlayLine { + x, + y: y.saturating_add(row_u16), + width, + text: line, + }, + start_column, + end_column, + selection_style(), + ); + } +} + +fn selection_range_for_line( + selection_state: &SelectionState, + line_number: u64, + width: u16, + line: &str, +) -> Option<(u16, u16)> { + match selection_state.kind { + SelectionKind::Line => { + let start_line = selection_state.anchor.line.min(selection_state.cursor.line); + let end_line = selection_state.anchor.line.max(selection_state.cursor.line); + (start_line..=end_line) + .contains(&line_number) + .then_some((0, width)) + } + SelectionKind::Block => { + let start_line = selection_state.anchor.line.min(selection_state.cursor.line); + let end_line = selection_state.anchor.line.max(selection_state.cursor.line); + if !(start_line..=end_line).contains(&line_number) { + return None; + } + Some(( + selection_state + .anchor + .column + .min(selection_state.cursor.column), + selection_state + .anchor + .column + .max(selection_state.cursor.column) + .saturating_add(1), + )) + } + SelectionKind::Character => { + let (start, end) = ordered_points(selection_state.anchor, selection_state.cursor); + if !(start.line..=end.line).contains(&line_number) { + return None; + } + let line_width = display_width(line).max(1); + if start.line == end.line { + Some((start.column, end.column.saturating_add(1))) + } else if line_number == start.line { + Some((start.column, line_width.max(start.column.saturating_add(1)))) + } else if line_number == end.line { + Some((0, end.column.saturating_add(1))) + } else { + Some((0, line_width)) + } + } + } +} + +fn ordered_points(left: SelectionPoint, right: SelectionPoint) -> (SelectionPoint, SelectionPoint) { + if (left.line, left.column) <= (right.line, right.column) { + (left, right) + } else { + (right, left) + } +} + +struct OverlayLine<'a> { + x: u16, + y: u16, + width: u16, + text: &'a str, +} + +fn overlay_display_range( + grid: &mut RenderGrid, + line: OverlayLine<'_>, + start_column: u16, + end_column: u16, + style: CellStyle, +) { + if start_column >= end_column || line.width == 0 { + return; + } + + let visible_end = end_column.min(line.width); + let mut column = 0_u16; + for grapheme in UnicodeSegmentation::graphemes(line.text, true) { + let grapheme_width = display_width(grapheme).max(1); + let next_column = column.saturating_add(grapheme_width); + if next_column > start_column && column < visible_end { + grid.put_str_styled(line.x.saturating_add(column), line.y, grapheme, style); + } + column = next_column; + if column >= visible_end { + return; + } + } + + for column in column.max(start_column)..visible_end { + grid.put_char_styled(line.x.saturating_add(column), line.y, ' ', style); + } +} + +fn format_tab_label(tab: &crate::presentation::TabItem, width: u16) -> String { + if width == 0 { + return String::new(); + } + + let marker = activity_marker(tab.activity); + let body_width = width.saturating_sub(2); + let body = truncate(&format!("{marker}{}", tab.title), body_width); + if tab.active { + truncate(&format!("[{body}]"), width) + } else { + truncate(&format!(" {body} "), width) + } +} + +fn activity_marker(activity: ActivityState) -> char { + match activity { + ActivityState::Idle => ' ', + ActivityState::Activity => '+', + ActivityState::Bell => '!', + } +} + +fn render_bar_segments( + grid: &mut RenderGrid, + mut x: u16, + y: u16, + end_x: u16, + segments: &[BarSegment], +) { + for segment in segments { + if x >= end_x { + break; + } + let available = end_x.saturating_sub(x); + if available == 0 { + break; + } + let label = truncate(&segment.text, available); + grid.put_str_styled(x, y, &label, CellStyle::from(&segment.style)); + x = x.saturating_add(display_width(&label)); + } +} + +fn truncate(text: &str, width: u16) -> String { + if width == 0 { + return String::new(); + } + + let width = usize::from(width); + if UnicodeWidthStr::width(text) <= width { + return text.to_owned(); + } + + if width == 1 { + return "~".to_owned(); + } + + let mut output = String::new(); + let mut used = 0; + for grapheme in UnicodeSegmentation::graphemes(text, true) { + let grapheme_width = UnicodeWidthStr::width(grapheme).max(1); + if used + grapheme_width > width - 1 { + break; + } + output.push_str(grapheme); + used += grapheme_width; + } + output.push('~'); + output +} + +fn clamp_i32_to_u16(value: i32) -> u16 { + value.clamp(0, i32::from(u16::MAX)) as u16 +} + +fn clamp_usize_to_u16(value: usize) -> u16 { + u16::try_from(value).unwrap_or(u16::MAX) +} + +fn display_width(text: &str) -> u16 { + clamp_usize_to_u16(UnicodeWidthStr::width(text)) +} + +fn tab_style(active: bool) -> CellStyle { + if active { + CellStyle::default().with_reverse().with_bold() + } else { + CellStyle { + dim: true, + ..CellStyle::default() + } + } +} + +fn leaf_title_style(focused: bool) -> CellStyle { + if focused { + CellStyle::default().with_bold() + } else { + CellStyle::default() + } +} + +fn divider_style() -> CellStyle { + CellStyle { + dim: true, + ..CellStyle::default() + } +} + +fn floating_border_style(focused: bool) -> CellStyle { + if focused { + CellStyle::default().with_bold().with_reverse() + } else { + CellStyle { + dim: true, + ..CellStyle::default() + } + } +} + +fn floating_title_style(focused: bool) -> CellStyle { + if focused { + CellStyle::default().with_bold() + } else { + CellStyle { + dim: true, + ..CellStyle::default() + } + } +} + +fn scroll_indicator_style() -> CellStyle { + CellStyle { + dim: true, + reverse: true, + ..CellStyle::default() + } +} + +fn search_style() -> CellStyle { + CellStyle { + underline: true, + ..CellStyle::default() + } +} + +fn active_search_style() -> CellStyle { + CellStyle { + underline: true, + reverse: true, + ..CellStyle::default() + } +} + +fn selection_style() -> CellStyle { + CellStyle { + reverse: true, + ..CellStyle::default() + } +} diff --git a/crates/embers-client/src/scripting/context.rs b/crates/embers-client/src/scripting/context.rs new file mode 100644 index 0000000..9aeacb6 --- /dev/null +++ b/crates/embers-client/src/scripting/context.rs @@ -0,0 +1,486 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use embers_core::{ActivityState, BufferId, FloatGeometry, FloatingId, NodeId, Rect, SessionId}; +use embers_protocol::{BufferRecordState, NodeRecordKind}; + +use crate::input::NORMAL_MODE; +use crate::{ClientState, PresentationModel, TabsFrame}; + +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct Context { + current_mode: String, + event: Option, + current_session_id: Option, + current_node_id: Option, + current_buffer_id: Option, + current_floating_id: Option, + sessions: BTreeMap, + buffers: BTreeMap, + nodes: BTreeMap, + floating: BTreeMap, +} + +impl Context { + pub fn from_state(state: &ClientState, presentation: Option<&PresentationModel>) -> Self { + Self::from_state_with_mode(state, presentation, NORMAL_MODE, None, None, None, None) + } + + pub fn from_state_with_mode( + state: &ClientState, + presentation: Option<&PresentationModel>, + current_mode: impl Into, + current_session_id: Option, + current_node_id: Option, + current_buffer_id: Option, + current_floating_id: Option, + ) -> Self { + let current_mode = current_mode.into(); + let current_session_id = + current_session_id.or_else(|| presentation.map(|presentation| presentation.session_id)); + let current_node_id = current_node_id.or_else(|| { + presentation + .and_then(|presentation| presentation.focused_leaf()) + .map(|leaf| leaf.node_id) + }); + let current_buffer_id = current_buffer_id + .or_else(|| presentation.and_then(PresentationModel::focused_buffer_id)); + let current_floating_id = current_floating_id + .or_else(|| presentation.and_then(PresentationModel::focused_floating_id)); + + let visible_buffer_ids = presentation + .map(|presentation| { + presentation + .leaves + .iter() + .map(|leaf| leaf.buffer_id) + .collect::>() + }) + .unwrap_or_default(); + let geometry_by_node = presentation.map(geometry_by_node).unwrap_or_default(); + let visible_node_ids = visible_node_ids(state, &geometry_by_node); + let visible_floating_ids = presentation + .map(|presentation| { + presentation + .floating + .iter() + .map(|floating| floating.floating_id) + .collect::>() + }) + .unwrap_or_default(); + let focused_leaf_ids = state + .sessions + .values() + .filter_map(|session| session.focused_leaf_id) + .collect::>(); + let session_root_ids = state + .sessions + .values() + .map(|session| session.root_node_id) + .collect::>(); + let floating_root_ids = state + .floating + .values() + .map(|floating| floating.root_node_id) + .collect::>(); + + let sessions = state + .sessions + .values() + .map(|session| { + ( + session.id, + SessionRef { + id: session.id, + name: session.name.clone(), + root_node_id: session.root_node_id, + floating_ids: session.floating_ids.clone(), + focused_leaf_id: session.focused_leaf_id, + focused_floating_id: session.focused_floating_id, + }, + ) + }) + .collect::>(); + + let nodes = state + .nodes + .values() + .map(|node| { + let child_ids = node + .split + .as_ref() + .map(|split| split.child_ids.clone()) + .or_else(|| { + node.tabs + .as_ref() + .map(|tabs| tabs.tabs.iter().map(|tab| tab.child_id).collect()) + }) + .unwrap_or_default(); + let buffer_id = node + .buffer_view + .as_ref() + .map(|buffer_view| buffer_view.buffer_id); + let split_direction = node.split.as_ref().map(|split| split.direction); + let split_weights = node.split.as_ref().map(|split| split.sizes.clone()); + let active_tab_index = node.tabs.as_ref().map(|tabs| tabs.active); + let tab_titles = node + .tabs + .as_ref() + .map(|tabs| tabs.tabs.iter().map(|tab| tab.title.clone()).collect()) + .unwrap_or_default(); + ( + node.id, + NodeRef { + id: node.id, + session_id: node.session_id, + kind: node.kind, + parent_id: node.parent_id, + child_ids, + geometry: geometry_by_node.get(&node.id).copied(), + is_root: session_root_ids.contains(&node.id), + is_floating_root: floating_root_ids.contains(&node.id), + is_focused: focused_leaf_ids.contains(&node.id), + visible: visible_node_ids.contains(&node.id), + buffer_id, + split_direction, + split_weights, + active_tab_index, + tab_titles, + }, + ) + }) + .collect::>(); + + let buffers = state + .buffers + .values() + .map(|buffer| { + let session_id = buffer + .attachment_node_id + .and_then(|node_id| state.nodes.get(&node_id).map(|node| node.session_id)); + let snapshot_lines = state + .snapshots + .get(&buffer.id) + .map(|snapshot| snapshot.lines.clone()) + .unwrap_or_default(); + ( + buffer.id, + BufferRef { + id: buffer.id, + title: buffer.title.clone(), + command: buffer.command.clone(), + cwd: buffer.cwd.clone(), + pid: buffer.pid, + env: buffer.env.clone(), + state: buffer.state, + activity: buffer.activity, + attachment_node_id: buffer.attachment_node_id, + session_id, + visible: visible_buffer_ids.contains(&buffer.id), + exit_code: buffer.exit_code, + tty_path: None, + snapshot_lines, + }, + ) + }) + .collect::>(); + + let floating = state + .floating + .values() + .map(|window| { + ( + window.id, + FloatingRef { + id: window.id, + session_id: window.session_id, + root_node_id: window.root_node_id, + title: window.title.clone(), + geometry: window.geometry, + focused: window.focused, + visible: if presentation.is_some() { + visible_floating_ids.contains(&window.id) + } else { + window.visible + }, + close_on_empty: window.close_on_empty, + }, + ) + }) + .collect::>(); + + Self { + current_mode, + event: None, + current_session_id, + current_node_id, + current_buffer_id, + current_floating_id, + sessions, + buffers, + nodes, + floating, + } + } + + pub fn with_event(mut self, event: EventInfo) -> Self { + self.event = Some(event); + self + } + + pub fn current_mode(&self) -> &str { + &self.current_mode + } + + pub fn event(&self) -> Option { + self.event.clone() + } + + pub fn current_session(&self) -> Option { + self.current_session_id + .and_then(|session_id| self.sessions.get(&session_id).cloned()) + } + + pub fn current_node(&self) -> Option { + self.current_node_id + .and_then(|node_id| self.nodes.get(&node_id).cloned()) + } + + pub fn current_buffer(&self) -> Option { + self.current_buffer_id + .and_then(|buffer_id| self.buffers.get(&buffer_id).cloned()) + } + + pub fn current_floating(&self) -> Option { + self.current_floating_id + .and_then(|floating_id| self.floating.get(&floating_id).cloned()) + } + + pub fn sessions(&self) -> Vec { + self.sessions.values().cloned().collect() + } + + pub fn find_buffer(&self, buffer_id: BufferId) -> Option { + self.buffers.get(&buffer_id).cloned() + } + + pub fn find_node(&self, node_id: NodeId) -> Option { + self.nodes.get(&node_id).cloned() + } + + pub fn find_floating(&self, floating_id: FloatingId) -> Option { + self.floating.get(&floating_id).cloned() + } + + pub fn detached_buffers(&self) -> Vec { + self.buffers + .values() + .filter(|buffer| buffer.is_detached()) + .cloned() + .collect() + } + + pub fn visible_buffers(&self) -> Vec { + self.buffers + .values() + .filter(|buffer| buffer.visible) + .cloned() + .collect() + } + + pub fn visible_floating(&self) -> Vec { + self.floating + .values() + .filter(|floating| floating.visible) + .cloned() + .collect() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EventInfo { + pub name: String, + pub session_id: Option, + pub buffer_id: Option, + pub node_id: Option, + pub floating_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SessionRef { + pub id: SessionId, + pub name: String, + pub root_node_id: NodeId, + pub floating_ids: Vec, + pub focused_leaf_id: Option, + pub focused_floating_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BufferRef { + pub id: BufferId, + pub title: String, + pub command: Vec, + pub cwd: Option, + pub pid: Option, + pub env: BTreeMap, + pub state: BufferRecordState, + pub activity: ActivityState, + pub attachment_node_id: Option, + pub session_id: Option, + pub visible: bool, + pub exit_code: Option, + pub tty_path: Option, + pub snapshot_lines: Vec, +} + +impl BufferRef { + pub fn node_id(&self) -> Option { + self.attachment_node_id + } + + pub fn process_name(&self) -> Option { + let command = self.command.first()?; + Some( + std::path::Path::new(command) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(command) + .to_owned(), + ) + } + + pub fn env_hint(&self, key: &str) -> Option { + self.env.get(key).cloned() + } + + pub fn snapshot_text(&self, limit: usize) -> String { + if limit == 0 { + return String::new(); + } + let start = self.snapshot_lines.len().saturating_sub(limit); + self.snapshot_lines[start..].join("\n") + } + + pub fn history_text(&self) -> String { + self.snapshot_lines.join("\n") + } + + pub fn is_attached(&self) -> bool { + self.attachment_node_id.is_some() + } + + pub fn is_detached(&self) -> bool { + self.attachment_node_id.is_none() + } + + pub fn is_running(&self) -> bool { + matches!(self.state, BufferRecordState::Running) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NodeRef { + pub id: NodeId, + pub session_id: SessionId, + pub kind: NodeRecordKind, + pub parent_id: Option, + pub child_ids: Vec, + pub geometry: Option, + pub is_root: bool, + pub is_floating_root: bool, + pub is_focused: bool, + pub visible: bool, + pub buffer_id: Option, + pub split_direction: Option, + pub split_weights: Option>, + pub active_tab_index: Option, + pub tab_titles: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FloatingRef { + pub id: FloatingId, + pub session_id: SessionId, + pub root_node_id: NodeId, + pub title: Option, + pub geometry: FloatGeometry, + pub focused: bool, + pub visible: bool, + pub close_on_empty: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TabBarContext { + pub node_id: NodeId, + pub is_root: bool, + pub active: usize, + pub mode: String, + pub viewport_width: u16, + pub tabs: Vec, +} + +impl TabBarContext { + pub fn from_frame(frame: &TabsFrame, mode: impl Into, viewport_width: u16) -> Self { + Self { + node_id: frame.node_id, + is_root: frame.is_root, + active: frame.active, + mode: mode.into(), + viewport_width, + tabs: frame + .tabs + .iter() + .enumerate() + .map(|(index, tab)| TabInfo { + index, + title: tab.title.clone(), + active: tab.active, + has_activity: matches!( + tab.activity, + ActivityState::Activity | ActivityState::Bell + ), + has_bell: matches!(tab.activity, ActivityState::Bell), + buffer_count: tab.buffer_count, + }) + .collect(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TabInfo { + pub index: usize, + pub title: String, + pub active: bool, + pub has_activity: bool, + pub has_bell: bool, + pub buffer_count: usize, +} + +fn geometry_by_node(presentation: &PresentationModel) -> BTreeMap { + let mut geometry = BTreeMap::new(); + for tabs in &presentation.tab_bars { + geometry.insert(tabs.node_id, tabs.rect); + } + for leaf in &presentation.leaves { + geometry.insert(leaf.node_id, leaf.rect); + } + geometry +} + +fn visible_node_ids( + state: &ClientState, + geometry_by_node: &BTreeMap, +) -> BTreeSet { + let mut visible = BTreeSet::new(); + for node_id in geometry_by_node.keys().copied() { + let mut current = Some(node_id); + while let Some(node_id) = current { + if !visible.insert(node_id) { + break; + } + current = state.nodes.get(&node_id).and_then(|node| node.parent_id); + } + } + visible +} diff --git a/crates/embers-client/src/scripting/engine.rs b/crates/embers-client/src/scripting/engine.rs new file mode 100644 index 0000000..ad26634 --- /dev/null +++ b/crates/embers-client/src/scripting/engine.rs @@ -0,0 +1,857 @@ +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; + +use rhai::{ + CallFnOptions, Dynamic, Engine, EvalAltResult, FnPtr, ImmutableString, Map, NativeCallContext, + Position, +}; + +use crate::config::ConfigOrigin; +use crate::config::LoadedConfigSource; +use crate::input::{ + BindingSpec, FallbackPolicy, KeySequence, ModeSpec, builtin_modes, expand_leader, + parse_key_sequence, +}; + +use super::error::ScriptError; +use super::runtime::{ + normalize_actions, normalize_bar, register_runtime_api, registration_scope, runtime_scope, +}; +use super::types::{ + LoadedConfig, ModeHooks, MouseSettings, RgbColor, ScriptFunctionRef, ThemeSpec, +}; +use super::{Action, Context, TabBarContext}; + +type RhaiResult = Result>; +type SharedRegistration = Arc>; + +pub struct ScriptEngine { + engine: Engine, + loaded: LoadedConfig, +} + +impl ScriptEngine { + pub fn load(source: &LoadedConfigSource) -> Result { + Self::load_with_overlay("", source) + } + + pub fn load_with_overlay( + builtins: &str, + source: &LoadedConfigSource, + ) -> Result { + let registration = Arc::new(Mutex::new(RegistrationState::default())); + let mut engine = Engine::new(); + engine.set_max_expr_depths(256, 256); + engine.set_max_operations(1_000_000); + register_api(&mut engine, registration.clone()); + register_runtime_api(&mut engine); + + let mut scope = registration_scope(); + scope.push_constant("tabbar", TabbarApi::new(registration.clone())); + scope.push_constant("theme", ThemeApi::new(registration.clone())); + scope.push_constant("mouse", MouseApi::new(registration.clone())); + + if !builtins.is_empty() { + let builtins_source = LoadedConfigSource { + origin: ConfigOrigin::BuiltIn, + path: None, + source: builtins.to_owned(), + source_hash: 0, + }; + let builtins_ast = engine + .compile(builtins) + .map_err(|error| ScriptError::compile(&builtins_source, error))?; + let _ = engine + .eval_ast_with_scope::(&mut scope, &builtins_ast) + .map_err(|error| ScriptError::runtime(&builtins_source, error))?; + } + + let ast = engine + .compile(&source.source) + .map_err(|error| ScriptError::compile(source, error))?; + + let _ = engine + .eval_ast_with_scope::(&mut scope, &ast) + .map_err(|error| ScriptError::runtime(source, error))?; + + let loaded = registration + .lock() + .expect("registration lock") + .clone() + .build_loaded_config(source, ast)?; + + Ok(Self { engine, loaded }) + } + + pub fn loaded_config(&self) -> &LoadedConfig { + &self.loaded + } + + pub fn has_action(&self, name: &str) -> bool { + self.loaded.has_action(name) + } + + pub fn has_event_handlers(&self, event: &str) -> bool { + self.loaded.has_event_handlers(event) + } + + pub fn has_tab_bar_formatter(&self) -> bool { + self.loaded.has_tab_bar_formatter() + } + + pub fn engine(&self) -> &Engine { + &self.engine + } + + pub fn run_named_action( + &self, + name: &str, + context: Context, + ) -> Result, ScriptError> { + let callback = self.loaded.named_actions.get(name).ok_or_else(|| { + ScriptError::validation_path( + self.loaded.source_path.as_deref(), + Position::NONE, + format!("unknown named action '{name}'"), + ) + })?; + self.invoke_action_function(&callback.name, context) + } + + pub fn dispatch_event( + &self, + event: &str, + context: Context, + ) -> Result, ScriptError> { + let Some(handlers) = self.loaded.event_handlers.get(event) else { + return Ok(Vec::new()); + }; + + let mut actions = Vec::new(); + for handler in handlers { + actions.extend(self.invoke_action_function(&handler.name, context.clone())?); + } + Ok(actions) + } + + pub fn run_enter_hook(&self, mode: &str, context: Context) -> Result, ScriptError> { + self.run_mode_hook(mode, ModeHook::Enter, context) + } + + pub fn run_leave_hook(&self, mode: &str, context: Context) -> Result, ScriptError> { + self.run_mode_hook(mode, ModeHook::Leave, context) + } + + fn run_mode_hook( + &self, + mode: &str, + hook: ModeHook, + context: Context, + ) -> Result, ScriptError> { + let Some(hooks) = self.loaded.mode_hooks.get(mode) else { + return Ok(Vec::new()); + }; + let callback = match hook { + ModeHook::Enter => hooks.on_enter.as_ref(), + ModeHook::Leave => hooks.on_leave.as_ref(), + }; + let Some(callback) = callback else { + return Ok(Vec::new()); + }; + self.invoke_action_function(&callback.name, context) + } + + pub fn format_tab_bar( + &self, + bar_context: TabBarContext, + ) -> Result, ScriptError> { + let Some(formatter) = &self.loaded.tab_bar_formatter else { + return Ok(None); + }; + self.invoke_bar_function(&formatter.name, bar_context) + .map(Some) + } + + fn invoke_action_function( + &self, + function_name: &str, + context: Context, + ) -> Result, ScriptError> { + let mut scope = runtime_scope(Some(context.clone()), self.loaded.theme.clone()); + let result = self + .engine + .call_fn_with_options::( + CallFnOptions::new().eval_ast(false), + &mut scope, + &self.loaded.ast, + function_name, + (context,), + ) + .map_err(|error| { + ScriptError::runtime_path(self.loaded.source_path.as_deref(), error) + })?; + normalize_actions(result).map_err(|message| { + ScriptError::validation_path( + self.loaded.source_path.as_deref(), + Position::NONE, + message, + ) + }) + } + + fn invoke_bar_function( + &self, + function_name: &str, + bar_context: TabBarContext, + ) -> Result { + let mut scope = runtime_scope(None, self.loaded.theme.clone()); + let result = self + .engine + .call_fn_with_options::( + CallFnOptions::new().eval_ast(false), + &mut scope, + &self.loaded.ast, + function_name, + (bar_context,), + ) + .map_err(|error| { + ScriptError::runtime_path(self.loaded.source_path.as_deref(), error) + })?; + normalize_bar(result).map_err(|message| { + ScriptError::validation_path( + self.loaded.source_path.as_deref(), + Position::NONE, + message, + ) + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ModeHook { + Enter, + Leave, +} + +#[derive(Clone, Debug, Default)] +struct RegistrationState { + leader: Option, + custom_modes: BTreeMap, + mode_hooks: BTreeMap, + binding_ops: Vec, + named_actions: BTreeMap, + event_handlers: BTreeMap>, + tab_bar_formatter: Option, + mouse: MouseSettings, + theme: ThemeSpec, +} + +impl RegistrationState { + fn build_loaded_config( + self, + source: &LoadedConfigSource, + ast: rhai::AST, + ) -> Result { + let mut modes = builtin_modes(); + modes.extend(self.custom_modes); + + let mut bindings = BTreeMap::>>>::new(); + for operation in self.binding_ops { + match operation { + BindingOperation::Bind(pending) => { + if !modes.contains_key(&pending.mode) { + return Err(ScriptError::validation( + source, + pending.position, + format!("binding uses unknown mode '{}'", pending.mode), + )); + } + + validate_action_refs( + source, + pending.position, + &self.named_actions, + &pending.target, + )?; + + let sequence = expand_leader( + pending.raw_sequence.clone(), + self.leader.as_deref().unwrap_or(&[]), + ) + .map_err(|error| { + ScriptError::validation(source, pending.position, error.to_string()) + })?; + + let mode_bindings = bindings.entry(pending.mode.clone()).or_default(); + if mode_bindings + .iter() + .any(|binding| binding.sequence == sequence) + { + return Err(ScriptError::validation( + source, + pending.position, + format!( + "duplicate binding '{}' in mode '{}'", + pending.notation, pending.mode + ), + )); + } + + mode_bindings.push(BindingSpec { + notation: pending.notation, + sequence, + target: pending.target, + }); + } + BindingOperation::Unbind(pending) => { + if !modes.contains_key(&pending.mode) { + return Err(ScriptError::validation( + source, + pending.position, + format!("unbind uses unknown mode '{}'", pending.mode), + )); + } + let sequence = + expand_leader(pending.raw_sequence, self.leader.as_deref().unwrap_or(&[])) + .map_err(|error| { + ScriptError::validation(source, pending.position, error.to_string()) + })?; + if let Some(mode_bindings) = bindings.get_mut(&pending.mode) { + mode_bindings.retain(|binding| binding.sequence != sequence); + } + } + } + } + + for mode_name in self.mode_hooks.keys() { + if !modes.contains_key(mode_name) { + return Err(ScriptError::validation( + source, + Position::NONE, + format!("mode hooks reference unknown mode '{mode_name}'"), + )); + } + } + + Ok(LoadedConfig { + source_path: source.path.clone(), + source_hash: source.source_hash, + ast, + leader: self.leader.unwrap_or_default(), + modes, + mode_hooks: self.mode_hooks, + bindings, + named_actions: self.named_actions, + event_handlers: self.event_handlers, + tab_bar_formatter: self.tab_bar_formatter, + mouse: self.mouse, + theme: self.theme, + }) + } +} + +fn validate_action_refs( + source: &LoadedConfigSource, + position: Position, + named_actions: &BTreeMap, + actions: &[Action], +) -> Result<(), ScriptError> { + for action in actions { + match action { + Action::RunNamedAction { name } => { + if !named_actions.contains_key(name) { + return Err(ScriptError::validation( + source, + position, + format!("binding references unknown action '{name}'"), + )); + } + } + Action::Chain(actions) => { + validate_action_refs(source, position, named_actions, actions)?; + } + _ => {} + } + } + Ok(()) +} + +#[derive(Clone, Debug)] +struct PendingBinding { + mode: String, + notation: String, + raw_sequence: KeySequence, + target: Vec, + position: Position, +} + +#[derive(Clone, Debug)] +struct PendingUnbinding { + mode: String, + raw_sequence: KeySequence, + position: Position, +} + +#[derive(Clone, Debug)] +enum BindingOperation { + Bind(PendingBinding), + Unbind(PendingUnbinding), +} + +#[derive(Clone)] +struct TabbarApi { + registration: SharedRegistration, +} + +impl TabbarApi { + fn new(registration: SharedRegistration) -> Self { + Self { registration } + } + + fn set_formatter(&mut self, position: Position, formatter: FnPtr) -> RhaiResult<()> { + let mut registration = self.registration.lock().expect("registration lock"); + if registration.tab_bar_formatter.is_some() { + return Err(runtime_error("tab bar formatter already defined", position)); + } + registration.tab_bar_formatter = Some(checked_function_ref( + formatter, + "tab bar formatter", + position, + )?); + Ok(()) + } +} + +#[derive(Clone)] +struct ThemeApi { + registration: SharedRegistration, +} + +impl ThemeApi { + fn new(registration: SharedRegistration) -> Self { + Self { registration } + } + + fn set_palette(&mut self, position: Position, palette: Map) -> RhaiResult<()> { + let mut registration = self.registration.lock().expect("registration lock"); + for (name, value) in palette { + let Some(value) = value.try_cast::() else { + return Err(runtime_error( + format!("palette color '{name}' must be a string"), + position, + )); + }; + let color = RgbColor::parse(value.as_str()) + .map_err(|error| runtime_error(error.to_string(), position))?; + if registration.theme.palette.contains_key(name.as_str()) { + return Err(runtime_error( + format!("palette color '{name}' is already defined"), + position, + )); + } + registration.theme.palette.insert(name.to_string(), color); + } + Ok(()) + } +} + +#[derive(Clone)] +struct MouseApi { + registration: SharedRegistration, +} + +impl MouseApi { + fn new(registration: SharedRegistration) -> Self { + Self { registration } + } + + fn set_click_focus(&mut self, value: bool) { + self.registration + .lock() + .expect("registration lock") + .mouse + .click_focus = value; + } + + fn set_click_forward(&mut self, value: bool) { + self.registration + .lock() + .expect("registration lock") + .mouse + .click_forward = value; + } + + fn set_wheel_scroll(&mut self, value: bool) { + self.registration + .lock() + .expect("registration lock") + .mouse + .wheel_scroll = value; + } + + fn set_wheel_forward(&mut self, value: bool) { + self.registration + .lock() + .expect("registration lock") + .mouse + .wheel_forward = value; + } +} + +fn register_api(engine: &mut Engine, registration: SharedRegistration) { + engine.register_type_with_name::("TabbarApi"); + engine.register_type_with_name::("ThemeApi"); + engine.register_type_with_name::("MouseApi"); + + let leader_registration = registration.clone(); + engine.register_fn( + "set_leader", + move |context: NativeCallContext, notation: ImmutableString| -> RhaiResult<()> { + let sequence = parse_key_sequence(notation.as_str()) + .map_err(|error| runtime_error(error.to_string(), context.call_position()))?; + let mut registration = leader_registration.lock().expect("registration lock"); + if registration.leader.is_some() { + return Err(runtime_error( + "leader key is already defined", + context.call_position(), + )); + } + registration.leader = Some(sequence); + Ok(()) + }, + ); + + let mode_registration = registration.clone(); + engine.register_fn( + "define_mode", + move |context: NativeCallContext, mode_name: ImmutableString| -> RhaiResult<()> { + define_mode_impl( + &mode_registration, + context.call_position(), + mode_name, + Map::new(), + ) + }, + ); + + let mode_registration = registration.clone(); + engine.register_fn( + "define_mode", + move |context: NativeCallContext, + mode_name: ImmutableString, + options: Map| + -> RhaiResult<()> { + define_mode_impl( + &mode_registration, + context.call_position(), + mode_name, + options, + ) + }, + ); + + let bind_registration = registration.clone(); + engine.register_fn( + "bind", + move |context: NativeCallContext, + mode: ImmutableString, + notation: ImmutableString, + action_name: ImmutableString| + -> RhaiResult<()> { + register_binding( + &bind_registration, + context.call_position(), + mode, + notation, + vec![Action::RunNamedAction { + name: action_name.to_string(), + }], + ) + }, + ); + + let unbind_registration = registration.clone(); + engine.register_fn( + "unbind", + move |context: NativeCallContext, + mode: ImmutableString, + notation: ImmutableString| + -> RhaiResult<()> { + register_unbinding( + &unbind_registration, + context.call_position(), + mode, + notation, + ) + }, + ); + + let bind_registration = registration.clone(); + engine.register_fn( + "bind", + move |context: NativeCallContext, + mode: ImmutableString, + notation: ImmutableString, + action: Action| + -> RhaiResult<()> { + register_binding( + &bind_registration, + context.call_position(), + mode, + notation, + vec![action], + ) + }, + ); + + let bind_registration = registration.clone(); + engine.register_fn( + "bind", + move |context: NativeCallContext, + mode: ImmutableString, + notation: ImmutableString, + actions: rhai::Array| + -> RhaiResult<()> { + let target = actions + .into_iter() + .map(|action| { + action.try_cast::().ok_or_else(|| { + runtime_error("bind expects Action values", context.call_position()) + }) + }) + .collect::, _>>()?; + register_binding( + &bind_registration, + context.call_position(), + mode, + notation, + target, + ) + }, + ); + + let action_registration = registration.clone(); + engine.register_fn( + "define_action", + move |context: NativeCallContext, + name: ImmutableString, + callback: FnPtr| + -> RhaiResult<()> { + let mut registration = action_registration.lock().expect("registration lock"); + if registration.named_actions.contains_key(name.as_str()) { + return Err(runtime_error( + format!("action '{name}' is already defined"), + context.call_position(), + )); + } + registration.named_actions.insert( + name.into_owned(), + checked_function_ref(callback, "named action", context.call_position())?, + ); + Ok(()) + }, + ); + + let handler_registration = registration.clone(); + engine.register_fn( + "on", + move |context: NativeCallContext, + event_name: ImmutableString, + callback: FnPtr| + -> RhaiResult<()> { + handler_registration + .lock() + .expect("registration lock") + .event_handlers + .entry(event_name.into_owned()) + .or_default() + .push(checked_function_ref( + callback, + "event handler", + context.call_position(), + )?); + Ok(()) + }, + ); + + engine.register_fn( + "set_formatter", + |context: NativeCallContext, tabbar: &mut TabbarApi, callback: FnPtr| -> RhaiResult<()> { + tabbar.set_formatter(context.call_position(), callback) + }, + ); + engine.register_fn( + "set_palette", + |context: NativeCallContext, theme: &mut ThemeApi, palette: Map| -> RhaiResult<()> { + theme.set_palette(context.call_position(), palette) + }, + ); + engine.register_fn( + "set_click_focus", + |_: NativeCallContext, mouse: &mut MouseApi, value: bool| { + mouse.set_click_focus(value); + }, + ); + engine.register_fn( + "set_click_forward", + |_: NativeCallContext, mouse: &mut MouseApi, value: bool| { + mouse.set_click_forward(value); + }, + ); + engine.register_fn( + "set_wheel_scroll", + |_: NativeCallContext, mouse: &mut MouseApi, value: bool| { + mouse.set_wheel_scroll(value); + }, + ); + engine.register_fn( + "set_wheel_forward", + |_: NativeCallContext, mouse: &mut MouseApi, value: bool| { + mouse.set_wheel_forward(value); + }, + ); +} + +fn define_mode_impl( + registration: &SharedRegistration, + position: Position, + mode_name: ImmutableString, + mut options: Map, +) -> RhaiResult<()> { + let fallback_policy = parse_fallback_policy(options.remove("fallback"))?; + let on_enter = + parse_optional_function_ref(options.remove("on_enter"), "mode on_enter", position)?; + let on_leave = + parse_optional_function_ref(options.remove("on_leave"), "mode on_leave", position)?; + if !options.is_empty() { + let unknown = options.keys().cloned().collect::>().join(", "); + return Err(runtime_error( + format!("unknown mode option(s): {unknown}"), + position, + )); + } + + let mut registration = registration.lock().expect("registration lock"); + if registration.custom_modes.contains_key(mode_name.as_str()) { + return Err(runtime_error( + format!("mode '{mode_name}' is already defined"), + position, + )); + } + registration.custom_modes.insert( + mode_name.to_string(), + ModeSpec::new(mode_name.to_string(), fallback_policy), + ); + registration + .mode_hooks + .insert(mode_name.to_string(), ModeHooks { on_enter, on_leave }); + Ok(()) +} + +fn register_binding( + registration: &SharedRegistration, + position: Position, + mode: ImmutableString, + notation: ImmutableString, + target: Vec, +) -> RhaiResult<()> { + let raw_sequence = parse_key_sequence(notation.as_str()) + .map_err(|error| runtime_error(error.to_string(), position))?; + registration + .lock() + .expect("registration lock") + .binding_ops + .push(BindingOperation::Bind(PendingBinding { + mode: mode.to_string(), + notation: notation.to_string(), + raw_sequence, + target, + position, + })); + Ok(()) +} + +fn register_unbinding( + registration: &SharedRegistration, + position: Position, + mode: ImmutableString, + notation: ImmutableString, +) -> RhaiResult<()> { + let raw_sequence = parse_key_sequence(notation.as_str()) + .map_err(|error| runtime_error(error.to_string(), position))?; + registration + .lock() + .expect("registration lock") + .binding_ops + .push(BindingOperation::Unbind(PendingUnbinding { + mode: mode.to_string(), + raw_sequence, + position, + })); + Ok(()) +} + +fn parse_fallback_policy(value: Option) -> RhaiResult { + let Some(value) = value else { + return Ok(FallbackPolicy::Ignore); + }; + if value.is_unit() { + return Ok(FallbackPolicy::Ignore); + } + let Some(value) = value.try_cast::() else { + return Err(runtime_error( + "mode fallback must be 'pass_to_buffer' or 'ignore'", + Position::NONE, + )); + }; + match value.as_str() { + "pass_to_buffer" => Ok(FallbackPolicy::Passthrough), + "ignore" => Ok(FallbackPolicy::Ignore), + other => Err(runtime_error( + format!("unknown fallback policy '{other}'"), + Position::NONE, + )), + } +} + +fn function_ref(callback: FnPtr) -> ScriptFunctionRef { + ScriptFunctionRef::new(callback.fn_name().to_owned()) +} + +fn parse_optional_function_ref( + value: Option, + role: &str, + position: Position, +) -> RhaiResult> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_unit() { + return Ok(None); + } + let Some(callback) = value.try_cast::() else { + return Err(runtime_error( + format!("{role} must be a function"), + position, + )); + }; + checked_function_ref(callback, role, position).map(Some) +} + +fn checked_function_ref( + callback: FnPtr, + role: &str, + position: Position, +) -> RhaiResult { + if callback.is_curried() { + return Err(runtime_error( + format!("{role} callbacks cannot capture curried arguments"), + position, + )); + } + Ok(function_ref(callback)) +} + +fn runtime_error(message: impl Into, position: Position) -> Box { + EvalAltResult::ErrorRuntime(message.into().into(), position).into() +} diff --git a/crates/embers-client/src/scripting/error.rs b/crates/embers-client/src/scripting/error.rs new file mode 100644 index 0000000..de9c873 --- /dev/null +++ b/crates/embers-client/src/scripting/error.rs @@ -0,0 +1,93 @@ +use std::path::Path; + +use rhai::{EvalAltResult, ParseError, Position}; +use thiserror::Error; + +use crate::config::LoadedConfigSource; + +#[derive(Debug, Error)] +pub enum ScriptError { + #[error("failed to compile config '{path}'{location}: {message}")] + Compile { + path: String, + location: String, + message: String, + }, + #[error("failed to evaluate config '{path}'{location}: {message}")] + Runtime { + path: String, + location: String, + message: String, + }, + #[error("config '{path}' is invalid{location}: {message}")] + Validation { + path: String, + location: String, + message: String, + }, +} + +impl ScriptError { + pub fn compile(source: &LoadedConfigSource, error: ParseError) -> Self { + Self::Compile { + path: source_path(source), + location: format_location(error.position()), + message: error.to_string(), + } + } + + pub fn runtime(source: &LoadedConfigSource, error: Box) -> Self { + Self::runtime_path(source.path.as_deref(), error) + } + + pub fn validation( + source: &LoadedConfigSource, + position: Position, + message: impl Into, + ) -> Self { + Self::validation_path(source.path.as_deref(), position, message) + } + + pub fn runtime_path(path: Option<&Path>, error: Box) -> Self { + let position = error.position(); + Self::Runtime { + path: format_path(path), + location: format_location(position), + message: error.to_string(), + } + } + + pub fn validation_path( + path: Option<&Path>, + position: Position, + message: impl Into, + ) -> Self { + Self::Validation { + path: format_path(path), + location: format_location(position), + message: message.into(), + } + } +} + +fn source_path(source: &LoadedConfigSource) -> String { + format_path(source.path.as_deref()) +} + +fn format_path(path: Option<&Path>) -> String { + path.unwrap_or_else(|| Path::new("")) + .display() + .to_string() +} + +fn format_location(position: Position) -> String { + if position.is_none() { + return String::new(); + } + + let line = position.line().unwrap_or(0); + match position.position() { + Some(column) => format!(" at {line}:{column}"), + None => format!(" at {line}"), + } +} diff --git a/crates/embers-client/src/scripting/harness.rs b/crates/embers-client/src/scripting/harness.rs new file mode 100644 index 0000000..f1af859 --- /dev/null +++ b/crates/embers-client/src/scripting/harness.rs @@ -0,0 +1,48 @@ +use std::path::PathBuf; + +use crate::config::{ConfigOrigin, LoadedConfigSource}; +use crate::input::{InputResolution, InputState, parse_key_sequence, resolve_key}; + +use super::{ScriptEngine, ScriptError}; + +pub struct ScriptHarness { + engine: ScriptEngine, + input_state: InputState, +} + +impl ScriptHarness { + pub fn load(source: &str) -> Result { + let loaded_source = LoadedConfigSource { + origin: ConfigOrigin::BuiltIn, + path: Some(PathBuf::from("script-harness.rhai")), + source: source.trim().to_owned(), + source_hash: 0, + }; + Ok(Self { + engine: ScriptEngine::load(&loaded_source)?, + input_state: InputState::default(), + }) + } + + pub fn engine(&self) -> &ScriptEngine { + &self.engine + } + + pub fn resolve_notation( + &mut self, + mode: &str, + notation: &str, + ) -> Result>, crate::input::KeyParseError> { + self.input_state.set_mode(mode); + let mut last_resolution = None; + for key in parse_key_sequence(notation)? { + last_resolution = Some(resolve_key( + &self.engine.loaded_config().bindings, + &self.engine.loaded_config().modes, + &mut self.input_state, + key, + )); + } + last_resolution.ok_or(crate::input::KeyParseError::EmptySequence) + } +} diff --git a/crates/embers-client/src/scripting/mod.rs b/crates/embers-client/src/scripting/mod.rs new file mode 100644 index 0000000..666f365 --- /dev/null +++ b/crates/embers-client/src/scripting/mod.rs @@ -0,0 +1,22 @@ +mod context; +mod engine; +mod error; +mod harness; +mod model; +mod runtime; +mod types; + +pub use context::{ + BufferRef, Context, EventInfo, FloatingRef, NodeRef, SessionRef, TabBarContext, TabInfo, +}; +pub use engine::ScriptEngine; +pub use error::ScriptError; +pub use harness::ScriptHarness; +pub use model::{ + Action, BufferSpawnSpec, FloatingAnchor, FloatingGeometrySpec, FloatingSize, FloatingSpec, + NotifyLevel, TabSpec, TabsSpec, TreeSpec, +}; +pub use types::{ + BarSegment, BarSpec, BarTarget, LoadedConfig, ModeHooks, MouseSettings, PaletteError, RgbColor, + ScriptFunctionRef, StyleSpec, ThemeSpec, +}; diff --git a/crates/embers-client/src/scripting/model.rs b/crates/embers-client/src/scripting/model.rs new file mode 100644 index 0000000..a8a6957 --- /dev/null +++ b/crates/embers-client/src/scripting/model.rs @@ -0,0 +1,237 @@ +use std::collections::BTreeMap; + +use embers_core::{BufferId, FloatingId, NodeId, SplitDirection}; + +use crate::input::KeySequence; +use crate::presentation::NavigationDirection; +use crate::state::SelectionKind; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Action { + Noop, + Chain(Vec), + EnterMode { + mode: String, + }, + LeaveMode, + ToggleMode { + mode: String, + }, + ClearPendingKeys, + FocusDirection { + direction: NavigationDirection, + }, + ResizeDirection { + direction: NavigationDirection, + amount: u16, + }, + SelectTab { + tabs_node_id: Option, + index: usize, + }, + NextTab { + tabs_node_id: Option, + }, + PrevTab { + tabs_node_id: Option, + }, + FocusBuffer { + buffer_id: BufferId, + }, + RevealBuffer { + buffer_id: BufferId, + }, + SplitCurrent { + direction: SplitDirection, + new_child: TreeSpec, + }, + ReplaceNode { + node_id: Option, + tree: TreeSpec, + }, + WrapNodeInSplit { + node_id: Option, + direction: SplitDirection, + sibling: TreeSpec, + }, + WrapNodeInTabs { + node_id: Option, + tabs: TabsSpec, + }, + InsertTabAfter { + tabs_node_id: Option, + title: Option, + child: TreeSpec, + }, + InsertTabBefore { + tabs_node_id: Option, + title: Option, + child: TreeSpec, + }, + OpenFloating { + spec: FloatingSpec, + }, + ReplaceFloatingRoot { + floating_id: Option, + tree: TreeSpec, + }, + CloseFloating { + floating_id: Option, + }, + CloseView { + node_id: Option, + }, + KillBuffer { + buffer_id: Option, + }, + DetachBuffer { + buffer_id: Option, + }, + MoveBufferToNode { + buffer_id: BufferId, + node_id: NodeId, + }, + MoveBufferToFloating { + buffer_id: BufferId, + geometry: FloatingGeometrySpec, + title: Option, + focus: bool, + }, + SendKeys { + buffer_id: Option, + keys: KeySequence, + }, + SendBytes { + buffer_id: Option, + bytes: Vec, + }, + ScrollLineUp, + ScrollLineDown, + ScrollPageUp, + ScrollPageDown, + ScrollToTop, + ScrollToBottom, + FollowOutput, + EnterSearchMode, + SearchNext, + SearchPrev, + CancelSearch, + EnterSelect { + kind: SelectionKind, + }, + SelectMove { + direction: NavigationDirection, + }, + CopySelection, + CancelSelection, + Notify { + level: NotifyLevel, + message: String, + }, + RunNamedAction { + name: String, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NotifyLevel { + Info, + Warn, + Error, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BufferSpawnSpec { + pub title: Option, + pub command: Vec, + pub cwd: Option, + pub env: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FloatingSpec { + pub tree: TreeSpec, + pub geometry: FloatingGeometrySpec, + pub title: Option, + pub focus: bool, + pub close_on_empty: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FloatingGeometrySpec { + pub width: FloatingSize, + pub height: FloatingSize, + pub anchor: FloatingAnchor, + pub offset_x: i16, + pub offset_y: i16, +} + +impl Default for FloatingGeometrySpec { + fn default() -> Self { + Self { + width: FloatingSize::Percent(50), + height: FloatingSize::Percent(50), + anchor: FloatingAnchor::Center, + offset_x: 0, + offset_y: 0, + } + } +} + +/// Floating sizes expressed as percentages are resolved in the inclusive +/// `1..=100` range when converted into concrete geometry. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FloatingSize { + Cells(u16), + Percent(u8), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FloatingAnchor { + Center, + TopLeft, + TopRight, + BottomLeft, + BottomRight, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TabSpec { + pub title: String, + pub tree: Box, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TabsSpec { + pub tabs: Vec, + pub active: usize, +} + +impl TabsSpec { + pub fn try_new(tabs: Vec, active: usize) -> Result { + if tabs.is_empty() { + return Err("tabs cannot be empty".to_owned()); + } + if active >= tabs.len() { + return Err("active tab index is out of bounds".to_owned()); + } + Ok(Self { tabs, active }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TreeSpec { + BufferCurrent, + BufferAttach { + buffer_id: BufferId, + }, + BufferSpawn(BufferSpawnSpec), + BufferEmpty, + CurrentNode, + Split { + direction: SplitDirection, + children: Vec, + sizes: Vec, + }, + Tabs(TabsSpec), +} diff --git a/crates/embers-client/src/scripting/runtime.rs b/crates/embers-client/src/scripting/runtime.rs new file mode 100644 index 0000000..024f456 --- /dev/null +++ b/crates/embers-client/src/scripting/runtime.rs @@ -0,0 +1,1609 @@ +use std::convert::TryFrom; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; + +use embers_core::{BufferId, FloatingId, NodeId, Rect, SplitDirection}; +use rhai::{Array, Dynamic, Engine, EvalAltResult, ImmutableString, Map, Scope}; + +use crate::input::parse_key_sequence; +use crate::presentation::NavigationDirection; + +use super::context::{ + BufferRef, Context, EventInfo, FloatingRef, NodeRef, SessionRef, TabBarContext, TabInfo, +}; +use super::model::{ + Action, BufferSpawnSpec, FloatingAnchor, FloatingGeometrySpec, FloatingSize, FloatingSpec, + NotifyLevel, TabSpec, TabsSpec, TreeSpec, +}; +use super::types::{BarSegment, BarSpec, BarTarget, RgbColor, StyleSpec, ThemeSpec}; + +type RhaiResult = Result>; + +#[derive(Clone, Default)] +struct ActionApi; + +#[derive(Clone, Default)] +struct TreeApi; + +#[derive(Clone, Default)] +struct UiApi; + +#[derive(Clone)] +struct MuxApi { + context: Context, +} + +#[derive(Clone, Default)] +struct SystemApi; + +#[derive(Clone)] +struct ThemeRuntimeApi { + theme: ThemeSpec, +} + +impl MuxApi { + fn new(context: Context) -> Self { + Self { context } + } +} + +pub fn register_runtime_api(engine: &mut Engine) { + engine.register_type_with_name::("Action"); + engine.register_type_with_name::("TreeSpec"); + engine.register_type_with_name::("TabSpec"); + engine.register_type_with_name::("TabsSpec"); + engine.register_type_with_name::("Context"); + engine.register_type_with_name::("EventInfo"); + engine.register_type_with_name::("SessionRef"); + engine.register_type_with_name::("BufferRef"); + engine.register_type_with_name::("NodeRef"); + engine.register_type_with_name::("FloatingRef"); + engine.register_type_with_name::("TabBarContext"); + engine.register_type_with_name::("TabInfo"); + engine.register_type_with_name::("BarSpec"); + engine.register_type_with_name::("BarSegment"); + engine.register_type_with_name::("StyleSpec"); + engine.register_type_with_name::("RgbColor"); + engine.register_type_with_name::("ActionApi"); + engine.register_type_with_name::("TreeApi"); + engine.register_type_with_name::("UiApi"); + engine.register_type_with_name::("MuxApi"); + engine.register_type_with_name::("SystemApi"); + engine.register_type_with_name::("ThemeRuntimeApi"); + + register_context_api(engine); + register_ref_api(engine); + register_action_api(engine); + register_tree_api(engine); + register_mux_api(engine); + register_system_api(engine); + register_ui_api(engine); + register_theme_runtime_api(engine); +} + +pub fn runtime_scope(context: Option, theme: ThemeSpec) -> Scope<'static> { + let mut scope = Scope::new(); + scope.push_constant("system", SystemApi); + scope.push_constant("action", ActionApi); + scope.push_constant("tree", TreeApi); + scope.push_constant("ui", UiApi); + scope.push_constant("theme", ThemeRuntimeApi { theme }); + if let Some(context) = context { + scope.push_constant("mux", MuxApi::new(context)); + } + scope +} + +pub fn registration_scope() -> Scope<'static> { + let mut scope = Scope::new(); + scope.push_constant("system", SystemApi); + scope.push_constant("action", ActionApi); + scope.push_constant("tree", TreeApi); + scope.push_constant("ui", UiApi); + scope +} + +pub fn normalize_actions(result: Dynamic) -> Result, String> { + let actions = if result.is_unit() { + Vec::new() + } else if let Some(action) = result.clone().try_cast::() { + vec![action] + } else if let Some(actions) = result.try_cast::() { + parse_action_array(actions).map_err(|error| error.to_string())? + } else { + return Err("script must return Action, [Action], or ()".to_owned()); + }; + + validate_live_actions(&actions)?; + Ok(actions) +} + +fn validate_live_actions(actions: &[Action]) -> Result<(), String> { + for action in actions { + match action { + Action::Chain(inner) => validate_live_actions(inner)?, + Action::ReplaceFloatingRoot { .. } + | Action::WrapNodeInSplit { .. } + | Action::WrapNodeInTabs { .. } => { + return Err(format!( + "action '{action:?}' is not supported by the live executor" + )); + } + _ => {} + } + } + + Ok(()) +} + +pub fn normalize_bar(result: Dynamic) -> Result { + result + .try_cast::() + .ok_or_else(|| "tab bar formatter must return a BarSpec".to_owned()) +} + +fn register_context_api(engine: &mut Engine) { + engine.register_fn("current_mode", |context: &mut Context| { + context.current_mode().to_owned() + }); + engine.register_fn("event", |context: &mut Context| -> Dynamic { + dynamic_option_custom(context.event()) + }); + engine.register_fn("current_session", |context: &mut Context| -> Dynamic { + dynamic_option_custom(context.current_session()) + }); + engine.register_fn("current_node", |context: &mut Context| -> Dynamic { + dynamic_option_custom(context.current_node()) + }); + engine.register_fn("current_buffer", |context: &mut Context| -> Dynamic { + dynamic_option_custom(context.current_buffer()) + }); + engine.register_fn("current_floating", |context: &mut Context| -> Dynamic { + dynamic_option_custom(context.current_floating()) + }); + engine.register_fn("sessions", |context: &mut Context| -> Array { + context.sessions().into_iter().map(Dynamic::from).collect() + }); + engine.register_fn( + "find_buffer", + |context: &mut Context, buffer_id: i64| -> RhaiResult { + Ok(dynamic_option_custom( + context.find_buffer(parse_buffer_id(buffer_id)?), + )) + }, + ); + engine.register_fn( + "find_node", + |context: &mut Context, node_id: i64| -> RhaiResult { + Ok(dynamic_option_custom( + context.find_node(parse_node_id(node_id)?), + )) + }, + ); + engine.register_fn( + "find_floating", + |context: &mut Context, floating_id: i64| -> RhaiResult { + Ok(dynamic_option_custom( + context.find_floating(parse_floating_id(floating_id)?), + )) + }, + ); + engine.register_fn("detached_buffers", |context: &mut Context| -> Array { + context + .detached_buffers() + .into_iter() + .map(Dynamic::from) + .collect() + }); + engine.register_fn("visible_buffers", |context: &mut Context| -> Array { + context + .visible_buffers() + .into_iter() + .map(Dynamic::from) + .collect() + }); +} + +fn register_ref_api(engine: &mut Engine) { + engine.register_fn("name", |event: &mut EventInfo| event.name.clone()); + engine.register_fn("session_id", |event: &mut EventInfo| -> Dynamic { + event + .session_id + .map(|session_id| dynamic_u64(session_id.0)) + .unwrap_or(Dynamic::UNIT) + }); + engine.register_fn("buffer_id", |event: &mut EventInfo| -> Dynamic { + event + .buffer_id + .map(|buffer_id| dynamic_u64(buffer_id.0)) + .unwrap_or(Dynamic::UNIT) + }); + engine.register_fn("node_id", |event: &mut EventInfo| -> Dynamic { + event + .node_id + .map(|node_id| dynamic_u64(node_id.0)) + .unwrap_or(Dynamic::UNIT) + }); + engine.register_fn("floating_id", |event: &mut EventInfo| -> Dynamic { + event + .floating_id + .map(|floating_id| dynamic_u64(floating_id.0)) + .unwrap_or(Dynamic::UNIT) + }); + + engine.register_fn("id", |session: &mut SessionRef| dynamic_u64(session.id.0)); + engine.register_fn("name", |session: &mut SessionRef| session.name.clone()); + engine.register_fn("root_node", |session: &mut SessionRef| { + dynamic_u64(session.root_node_id.0) + }); + engine.register_fn("floating", |session: &mut SessionRef| -> Array { + session + .floating_ids + .iter() + .map(|floating_id| dynamic_u64(floating_id.0)) + .collect() + }); + + engine.register_fn("id", |buffer: &mut BufferRef| dynamic_u64(buffer.id.0)); + engine.register_fn("title", |buffer: &mut BufferRef| buffer.title.clone()); + engine.register_fn("command", |buffer: &mut BufferRef| -> Array { + buffer.command.iter().cloned().map(Dynamic::from).collect() + }); + engine.register_fn("cwd", |buffer: &mut BufferRef| -> Dynamic { + dynamic_option_string(buffer.cwd.clone()) + }); + engine.register_fn("pid", |buffer: &mut BufferRef| -> Dynamic { + buffer.pid.map(dynamic_u32).unwrap_or(Dynamic::UNIT) + }); + engine.register_fn("process_name", |buffer: &mut BufferRef| -> Dynamic { + dynamic_option_string(buffer.process_name()) + }); + engine.register_fn("tty_path", |buffer: &mut BufferRef| -> Dynamic { + dynamic_option_string(buffer.tty_path.clone()) + }); + engine.register_fn( + "env_hint", + |buffer: &mut BufferRef, key: ImmutableString| -> Dynamic { + dynamic_option_string(buffer.env_hint(key.as_str())) + }, + ); + engine.register_fn( + "snapshot_text", + |buffer: &mut BufferRef, limit: i64| -> RhaiResult { + Ok(buffer.snapshot_text(parse_count(limit, "snapshot_text limit")?)) + }, + ); + engine.register_fn("history_text", |buffer: &mut BufferRef| { + buffer.history_text() + }); + engine.register_fn("is_attached", |buffer: &mut BufferRef| buffer.is_attached()); + engine.register_fn("is_detached", |buffer: &mut BufferRef| buffer.is_detached()); + engine.register_fn("is_running", |buffer: &mut BufferRef| buffer.is_running()); + engine.register_fn("exit_code", |buffer: &mut BufferRef| -> Dynamic { + buffer.exit_code.map(Dynamic::from).unwrap_or(Dynamic::UNIT) + }); + engine.register_fn("is_visible", |buffer: &mut BufferRef| buffer.visible); + engine.register_fn("session_id", |buffer: &mut BufferRef| -> Dynamic { + buffer + .session_id + .map(|session_id| dynamic_u64(session_id.0)) + .unwrap_or(Dynamic::UNIT) + }); + engine.register_fn("node_id", |buffer: &mut BufferRef| -> Dynamic { + buffer + .node_id() + .map(|node_id| dynamic_u64(node_id.0)) + .unwrap_or(Dynamic::UNIT) + }); + engine.register_fn("activity", |buffer: &mut BufferRef| { + activity_name(buffer.activity) + }); + + engine.register_fn("id", |node: &mut NodeRef| dynamic_u64(node.id.0)); + engine.register_fn("kind", |node: &mut NodeRef| node_kind_name(node.kind)); + engine.register_fn("parent", |node: &mut NodeRef| -> Dynamic { + node.parent_id + .map(|node_id| dynamic_u64(node_id.0)) + .unwrap_or(Dynamic::UNIT) + }); + engine.register_fn("children", |node: &mut NodeRef| -> Array { + node.child_ids + .iter() + .map(|child_id| dynamic_u64(child_id.0)) + .collect() + }); + engine.register_fn("session_id", |node: &mut NodeRef| { + dynamic_u64(node.session_id.0) + }); + engine.register_fn("geometry", |node: &mut NodeRef| -> Dynamic { + node.geometry + .map(rect_map) + .map(Dynamic::from) + .unwrap_or(Dynamic::UNIT) + }); + engine.register_fn("is_root", |node: &mut NodeRef| node.is_root); + engine.register_fn("is_floating_root", |node: &mut NodeRef| { + node.is_floating_root + }); + engine.register_fn("is_visible", |node: &mut NodeRef| node.visible); + engine.register_fn("is_focused", |node: &mut NodeRef| node.is_focused); + engine.register_fn("buffer", |node: &mut NodeRef| -> Dynamic { + node.buffer_id + .map(|buffer_id| dynamic_u64(buffer_id.0)) + .unwrap_or(Dynamic::UNIT) + }); + engine.register_fn("split_direction", |node: &mut NodeRef| -> Dynamic { + node.split_direction + .map(split_direction_name) + .map(Dynamic::from) + .unwrap_or(Dynamic::UNIT) + }); + engine.register_fn("split_weights", |node: &mut NodeRef| -> Dynamic { + node.split_weights + .as_ref() + .map(|weights| { + weights + .iter() + .copied() + .map(|weight| Dynamic::from(i64::from(weight))) + .collect::() + }) + .map(Dynamic::from) + .unwrap_or(Dynamic::UNIT) + }); + engine.register_fn("active_tab_index", |node: &mut NodeRef| -> Dynamic { + node.active_tab_index + .map(dynamic_u32) + .unwrap_or(Dynamic::UNIT) + }); + engine.register_fn("tab_titles", |node: &mut NodeRef| -> Array { + node.tab_titles.iter().cloned().map(Dynamic::from).collect() + }); + + engine.register_fn("id", |floating: &mut FloatingRef| { + dynamic_u64(floating.id.0) + }); + engine.register_fn("session_id", |floating: &mut FloatingRef| { + dynamic_u64(floating.session_id.0) + }); + engine.register_fn("root_node", |floating: &mut FloatingRef| { + dynamic_u64(floating.root_node_id.0) + }); + engine.register_fn("title", |floating: &mut FloatingRef| -> Dynamic { + dynamic_option_string(floating.title.clone()) + }); + engine.register_fn("is_visible", |floating: &mut FloatingRef| floating.visible); + engine.register_fn("is_focused", |floating: &mut FloatingRef| floating.focused); + engine.register_fn("geometry", |floating: &mut FloatingRef| -> Map { + float_geometry_map(floating.geometry) + }); + + engine.register_fn("node_id", |bar: &mut TabBarContext| { + dynamic_u64(bar.node_id.0) + }); + engine.register_fn("is_root", |bar: &mut TabBarContext| bar.is_root); + engine.register_fn("active_index", |bar: &mut TabBarContext| { + dynamic_usize(bar.active) + }); + engine.register_fn("mode", |bar: &mut TabBarContext| bar.mode.clone()); + engine.register_fn("viewport_width", |bar: &mut TabBarContext| { + Dynamic::from(i64::from(bar.viewport_width)) + }); + engine.register_fn("tabs", |bar: &mut TabBarContext| -> Array { + bar.tabs.iter().cloned().map(Dynamic::from).collect() + }); + + engine.register_fn("index", |tab: &mut TabInfo| dynamic_usize(tab.index)); + engine.register_fn("title", |tab: &mut TabInfo| tab.title.clone()); + engine.register_fn("is_active", |tab: &mut TabInfo| tab.active); + engine.register_fn("has_activity", |tab: &mut TabInfo| tab.has_activity); + engine.register_fn("has_bell", |tab: &mut TabInfo| tab.has_bell); + engine.register_fn("buffer_count", |tab: &mut TabInfo| { + dynamic_usize(tab.buffer_count) + }); +} + +fn register_action_api(engine: &mut Engine) { + engine.register_fn("noop", |_: &mut ActionApi| Action::Noop); + engine.register_fn( + "chain", + |_: &mut ActionApi, actions: Array| -> RhaiResult { + Ok(Action::Chain(parse_action_array(actions)?)) + }, + ); + engine.register_fn("enter_mode", |_: &mut ActionApi, mode: ImmutableString| { + Action::EnterMode { + mode: mode.to_string(), + } + }); + engine.register_fn("leave_mode", |_: &mut ActionApi| Action::LeaveMode); + engine.register_fn("toggle_mode", |_: &mut ActionApi, mode: ImmutableString| { + Action::ToggleMode { + mode: mode.to_string(), + } + }); + engine.register_fn("clear_pending_keys", |_: &mut ActionApi| { + Action::ClearPendingKeys + }); + + engine.register_fn("focus_left", |_: &mut ActionApi| Action::FocusDirection { + direction: NavigationDirection::Left, + }); + engine.register_fn("focus_right", |_: &mut ActionApi| Action::FocusDirection { + direction: NavigationDirection::Right, + }); + engine.register_fn("focus_up", |_: &mut ActionApi| Action::FocusDirection { + direction: NavigationDirection::Up, + }); + engine.register_fn("focus_down", |_: &mut ActionApi| Action::FocusDirection { + direction: NavigationDirection::Down, + }); + + engine.register_fn( + "resize_left", + |_: &mut ActionApi, amount: i64| -> RhaiResult { + Ok(Action::ResizeDirection { + direction: NavigationDirection::Left, + amount: parse_amount(amount, "resize amount")?, + }) + }, + ); + engine.register_fn( + "resize_right", + |_: &mut ActionApi, amount: i64| -> RhaiResult { + Ok(Action::ResizeDirection { + direction: NavigationDirection::Right, + amount: parse_amount(amount, "resize amount")?, + }) + }, + ); + engine.register_fn( + "resize_up", + |_: &mut ActionApi, amount: i64| -> RhaiResult { + Ok(Action::ResizeDirection { + direction: NavigationDirection::Up, + amount: parse_amount(amount, "resize amount")?, + }) + }, + ); + engine.register_fn( + "resize_down", + |_: &mut ActionApi, amount: i64| -> RhaiResult { + Ok(Action::ResizeDirection { + direction: NavigationDirection::Down, + amount: parse_amount(amount, "resize amount")?, + }) + }, + ); + + engine.register_fn( + "select_tab", + |_: &mut ActionApi, tabs_node_id: i64, index: i64| -> RhaiResult { + Ok(Action::SelectTab { + tabs_node_id: Some(parse_node_id(tabs_node_id)?), + index: parse_index(index, "tab index")?, + }) + }, + ); + engine.register_fn( + "select_current_tabs", + |_: &mut ActionApi, index: i64| -> RhaiResult { + Ok(Action::SelectTab { + tabs_node_id: None, + index: parse_index(index, "tab index")?, + }) + }, + ); + engine.register_fn( + "next_tab", + |_: &mut ActionApi, tabs_node_id: i64| -> RhaiResult { + Ok(Action::NextTab { + tabs_node_id: Some(parse_node_id(tabs_node_id)?), + }) + }, + ); + engine.register_fn("next_current_tabs", |_: &mut ActionApi| Action::NextTab { + tabs_node_id: None, + }); + engine.register_fn( + "prev_tab", + |_: &mut ActionApi, tabs_node_id: i64| -> RhaiResult { + Ok(Action::PrevTab { + tabs_node_id: Some(parse_node_id(tabs_node_id)?), + }) + }, + ); + engine.register_fn("prev_current_tabs", |_: &mut ActionApi| Action::PrevTab { + tabs_node_id: None, + }); + + engine.register_fn( + "focus_buffer", + |_: &mut ActionApi, buffer_id: i64| -> RhaiResult { + Ok(Action::FocusBuffer { + buffer_id: parse_buffer_id(buffer_id)?, + }) + }, + ); + engine.register_fn( + "reveal_buffer", + |_: &mut ActionApi, buffer_id: i64| -> RhaiResult { + Ok(Action::RevealBuffer { + buffer_id: parse_buffer_id(buffer_id)?, + }) + }, + ); + + engine.register_fn( + "split_with", + |_: &mut ActionApi, direction: ImmutableString, tree: TreeSpec| -> RhaiResult { + Ok(Action::SplitCurrent { + direction: parse_split_direction(direction.as_str())?, + new_child: tree, + }) + }, + ); + + engine.register_fn( + "replace_current_with", + |_: &mut ActionApi, tree: TreeSpec| Action::ReplaceNode { + node_id: None, + tree, + }, + ); + engine.register_fn( + "replace_node", + |_: &mut ActionApi, node_id: i64, tree: TreeSpec| -> RhaiResult { + Ok(Action::ReplaceNode { + node_id: Some(parse_node_id(node_id)?), + tree, + }) + }, + ); + + engine.register_fn( + "wrap_current_in_split", + |_: &mut ActionApi, direction: ImmutableString, tree: TreeSpec| -> RhaiResult { + Ok(Action::WrapNodeInSplit { + node_id: None, + direction: parse_split_direction(direction.as_str())?, + sibling: tree, + }) + }, + ); + engine.register_fn( + "wrap_node_in_split", + |_: &mut ActionApi, + node_id: i64, + direction: ImmutableString, + tree: TreeSpec| + -> RhaiResult { + Ok(Action::WrapNodeInSplit { + node_id: Some(parse_node_id(node_id)?), + direction: parse_split_direction(direction.as_str())?, + sibling: tree, + }) + }, + ); + + engine.register_fn( + "wrap_current_in_tabs", + |_: &mut ActionApi, tabs: TreeSpec| -> RhaiResult { + Ok(Action::WrapNodeInTabs { + node_id: None, + tabs: parse_tabs_tree(tabs)?, + }) + }, + ); + engine.register_fn( + "wrap_node_in_tabs", + |_: &mut ActionApi, node_id: i64, tabs: TreeSpec| -> RhaiResult { + Ok(Action::WrapNodeInTabs { + node_id: Some(parse_node_id(node_id)?), + tabs: parse_tabs_tree(tabs)?, + }) + }, + ); + + engine.register_fn( + "insert_tab_after", + |_: &mut ActionApi, + tabs_node_id: i64, + title: ImmutableString, + tree: TreeSpec| + -> RhaiResult { + Ok(Action::InsertTabAfter { + tabs_node_id: Some(parse_node_id(tabs_node_id)?), + title: Some(title.to_string()), + child: tree, + }) + }, + ); + engine.register_fn( + "insert_tab_after_current", + |_: &mut ActionApi, title: ImmutableString, tree: TreeSpec| Action::InsertTabAfter { + tabs_node_id: None, + title: Some(title.to_string()), + child: tree, + }, + ); + engine.register_fn( + "insert_tab_before", + |_: &mut ActionApi, + tabs_node_id: i64, + title: ImmutableString, + tree: TreeSpec| + -> RhaiResult { + Ok(Action::InsertTabBefore { + tabs_node_id: Some(parse_node_id(tabs_node_id)?), + title: Some(title.to_string()), + child: tree, + }) + }, + ); + engine.register_fn( + "insert_tab_before_current", + |_: &mut ActionApi, title: ImmutableString, tree: TreeSpec| Action::InsertTabBefore { + tabs_node_id: None, + title: Some(title.to_string()), + child: tree, + }, + ); + + engine.register_fn( + "open_floating", + |_: &mut ActionApi, tree: TreeSpec, options: Map| -> RhaiResult { + Ok(Action::OpenFloating { + spec: parse_floating_spec(tree, options)?, + }) + }, + ); + engine.register_fn( + "replace_floating_root", + |_: &mut ActionApi, floating_id: i64, tree: TreeSpec| -> RhaiResult { + Ok(Action::ReplaceFloatingRoot { + floating_id: Some(parse_floating_id(floating_id)?), + tree, + }) + }, + ); + engine.register_fn( + "replace_current_floating_root", + |_: &mut ActionApi, tree: TreeSpec| Action::ReplaceFloatingRoot { + floating_id: None, + tree, + }, + ); + engine.register_fn("close_floating", |_: &mut ActionApi| { + Action::CloseFloating { floating_id: None } + }); + engine.register_fn( + "close_floating_id", + |_: &mut ActionApi, floating_id: i64| -> RhaiResult { + Ok(Action::CloseFloating { + floating_id: Some(parse_floating_id(floating_id)?), + }) + }, + ); + engine.register_fn("close_view", |_: &mut ActionApi| Action::CloseView { + node_id: None, + }); + engine.register_fn( + "close_node", + |_: &mut ActionApi, node_id: i64| -> RhaiResult { + Ok(Action::CloseView { + node_id: Some(parse_node_id(node_id)?), + }) + }, + ); + + engine.register_fn("kill_buffer", |_: &mut ActionApi| Action::KillBuffer { + buffer_id: None, + }); + engine.register_fn( + "kill_buffer_id", + |_: &mut ActionApi, buffer_id: i64| -> RhaiResult { + Ok(Action::KillBuffer { + buffer_id: Some(parse_buffer_id(buffer_id)?), + }) + }, + ); + engine.register_fn("detach_buffer", |_: &mut ActionApi| Action::DetachBuffer { + buffer_id: None, + }); + engine.register_fn( + "detach_buffer_id", + |_: &mut ActionApi, buffer_id: i64| -> RhaiResult { + Ok(Action::DetachBuffer { + buffer_id: Some(parse_buffer_id(buffer_id)?), + }) + }, + ); + + engine.register_fn( + "move_buffer_to_node", + |_: &mut ActionApi, buffer_id: i64, node_id: i64| -> RhaiResult { + Ok(Action::MoveBufferToNode { + buffer_id: parse_buffer_id(buffer_id)?, + node_id: parse_node_id(node_id)?, + }) + }, + ); + engine.register_fn( + "move_buffer_to_floating", + |_: &mut ActionApi, buffer_id: i64, options: Map| -> RhaiResult { + let spec = parse_floating_options(options)?; + Ok(Action::MoveBufferToFloating { + buffer_id: parse_buffer_id(buffer_id)?, + geometry: spec.geometry, + title: spec.title, + focus: spec.focus, + }) + }, + ); + + engine.register_fn( + "send_keys_current", + |_: &mut ActionApi, notation: ImmutableString| -> RhaiResult { + Ok(Action::SendKeys { + buffer_id: None, + keys: parse_key_sequence(notation.as_str()) + .map_err(|error| runtime_error(error.to_string()))?, + }) + }, + ); + engine.register_fn( + "send_keys", + |_: &mut ActionApi, buffer_id: i64, notation: ImmutableString| -> RhaiResult { + Ok(Action::SendKeys { + buffer_id: Some(parse_buffer_id(buffer_id)?), + keys: parse_key_sequence(notation.as_str()) + .map_err(|error| runtime_error(error.to_string()))?, + }) + }, + ); + engine.register_fn( + "send_bytes", + |_: &mut ActionApi, buffer_id: i64, bytes: ImmutableString| -> RhaiResult { + Ok(Action::SendBytes { + buffer_id: Some(parse_buffer_id(buffer_id)?), + bytes: bytes.as_bytes().to_vec(), + }) + }, + ); + engine.register_fn( + "send_bytes", + |_: &mut ActionApi, buffer_id: i64, bytes: Array| -> RhaiResult { + Ok(Action::SendBytes { + buffer_id: Some(parse_buffer_id(buffer_id)?), + bytes: parse_bytes(bytes)?, + }) + }, + ); + engine.register_fn( + "send_bytes_current", + |_: &mut ActionApi, bytes: ImmutableString| Action::SendBytes { + buffer_id: None, + bytes: bytes.as_bytes().to_vec(), + }, + ); + engine.register_fn( + "send_bytes_current", + |_: &mut ActionApi, bytes: Array| -> RhaiResult { + Ok(Action::SendBytes { + buffer_id: None, + bytes: parse_bytes(bytes)?, + }) + }, + ); + + engine.register_fn("scroll_line_up", |_: &mut ActionApi| Action::ScrollLineUp); + engine.register_fn("scroll_line_down", |_: &mut ActionApi| { + Action::ScrollLineDown + }); + engine.register_fn("scroll_page_up", |_: &mut ActionApi| Action::ScrollPageUp); + engine.register_fn("scroll_page_down", |_: &mut ActionApi| { + Action::ScrollPageDown + }); + engine.register_fn("scroll_to_top", |_: &mut ActionApi| Action::ScrollToTop); + engine.register_fn("scroll_to_bottom", |_: &mut ActionApi| { + Action::ScrollToBottom + }); + engine.register_fn("follow_output", |_: &mut ActionApi| Action::FollowOutput); + engine.register_fn("enter_search_mode", |_: &mut ActionApi| { + Action::EnterSearchMode + }); + engine.register_fn("search_next", |_: &mut ActionApi| Action::SearchNext); + engine.register_fn("search_prev", |_: &mut ActionApi| Action::SearchPrev); + engine.register_fn("cancel_search", |_: &mut ActionApi| Action::CancelSearch); + engine.register_fn("enter_select_char", |_: &mut ActionApi| { + Action::EnterSelect { + kind: crate::state::SelectionKind::Character, + } + }); + engine.register_fn("enter_select_line", |_: &mut ActionApi| { + Action::EnterSelect { + kind: crate::state::SelectionKind::Line, + } + }); + engine.register_fn("enter_select_block", |_: &mut ActionApi| { + Action::EnterSelect { + kind: crate::state::SelectionKind::Block, + } + }); + engine.register_fn("select_move_left", |_: &mut ActionApi| Action::SelectMove { + direction: NavigationDirection::Left, + }); + engine.register_fn("select_move_right", |_: &mut ActionApi| { + Action::SelectMove { + direction: NavigationDirection::Right, + } + }); + engine.register_fn("select_move_up", |_: &mut ActionApi| Action::SelectMove { + direction: NavigationDirection::Up, + }); + engine.register_fn("select_move_down", |_: &mut ActionApi| Action::SelectMove { + direction: NavigationDirection::Down, + }); + + engine.register_fn("yank_selection", |_: &mut ActionApi| Action::CopySelection); + engine.register_fn("copy_selection", |_: &mut ActionApi| Action::CopySelection); + engine.register_fn("cancel_selection", |_: &mut ActionApi| { + Action::CancelSelection + }); + engine.register_fn( + "notify", + |_: &mut ActionApi, + level: ImmutableString, + message: ImmutableString| + -> RhaiResult { + Ok(Action::Notify { + level: parse_notify_level(level.as_str())?, + message: message.to_string(), + }) + }, + ); + engine.register_fn( + "run_named_action", + |_: &mut ActionApi, name: ImmutableString| Action::RunNamedAction { + name: name.to_string(), + }, + ); +} + +fn register_tree_api(engine: &mut Engine) { + engine.register_fn("buffer_current", |_: &mut TreeApi| TreeSpec::BufferCurrent); + engine.register_fn("current_buffer", |_: &mut TreeApi| TreeSpec::BufferCurrent); + engine.register_fn("current_node", |_: &mut TreeApi| TreeSpec::CurrentNode); + engine.register_fn("buffer_empty", |_: &mut TreeApi| TreeSpec::BufferEmpty); + engine.register_fn( + "buffer_attach", + |_: &mut TreeApi, buffer_id: i64| -> RhaiResult { + Ok(TreeSpec::BufferAttach { + buffer_id: parse_buffer_id(buffer_id)?, + }) + }, + ); + engine.register_fn( + "buffer_spawn", + |_: &mut TreeApi, command: Array| -> RhaiResult { + Ok(TreeSpec::BufferSpawn(BufferSpawnSpec { + title: None, + command: parse_string_array(Dynamic::from(command))?, + cwd: None, + env: Default::default(), + })) + }, + ); + engine.register_fn( + "buffer_spawn", + |_: &mut TreeApi, command: Array, options: Map| -> RhaiResult { + Ok(TreeSpec::BufferSpawn(parse_buffer_spawn(command, options)?)) + }, + ); + engine.register_fn( + "tab", + |_: &mut TreeApi, title: ImmutableString, tree: TreeSpec| TabSpec { + title: title.to_string(), + tree: Box::new(tree), + }, + ); + engine.register_fn( + "tabs", + |_: &mut TreeApi, tabs: Array| -> RhaiResult { build_tabs(tabs, 0) }, + ); + engine.register_fn( + "tabs_with_active", + |_: &mut TreeApi, tabs: Array, active: i64| -> RhaiResult { + build_tabs(tabs, parse_index(active, "active tab")?) + }, + ); + engine.register_fn( + "split_h", + |_: &mut TreeApi, children: Array| -> RhaiResult { + build_split(SplitDirection::Horizontal, children, Vec::new()) + }, + ); + engine.register_fn( + "split_v", + |_: &mut TreeApi, children: Array| -> RhaiResult { + build_split(SplitDirection::Vertical, children, Vec::new()) + }, + ); + engine.register_fn( + "split", + |_: &mut TreeApi, direction: ImmutableString, children: Array| -> RhaiResult { + build_split( + parse_split_direction(direction.as_str())?, + children, + Vec::new(), + ) + }, + ); + engine.register_fn( + "split", + |_: &mut TreeApi, + direction: ImmutableString, + children: Array, + sizes: Array| + -> RhaiResult { + build_split( + parse_split_direction(direction.as_str())?, + children, + parse_sizes(sizes)?, + ) + }, + ); +} + +fn register_mux_api(engine: &mut Engine) { + engine.register_fn("current_session", |mux: &mut MuxApi| -> Dynamic { + dynamic_option_custom(mux.context.current_session()) + }); + engine.register_fn("current_node", |mux: &mut MuxApi| -> Dynamic { + dynamic_option_custom(mux.context.current_node()) + }); + engine.register_fn("current_buffer", |mux: &mut MuxApi| -> Dynamic { + dynamic_option_custom(mux.context.current_buffer()) + }); + engine.register_fn("current_floating", |mux: &mut MuxApi| -> Dynamic { + dynamic_option_custom(mux.context.current_floating()) + }); + engine.register_fn("sessions", |mux: &mut MuxApi| -> Array { + mux.context + .sessions() + .into_iter() + .map(Dynamic::from) + .collect() + }); + engine.register_fn("visible_buffers", |mux: &mut MuxApi| -> Array { + mux.context + .visible_buffers() + .into_iter() + .map(Dynamic::from) + .collect() + }); + engine.register_fn("detached_buffers", |mux: &mut MuxApi| -> Array { + mux.context + .detached_buffers() + .into_iter() + .map(Dynamic::from) + .collect() + }); + engine.register_fn( + "find_buffer", + |mux: &mut MuxApi, buffer_id: i64| -> RhaiResult { + Ok(dynamic_option_custom( + mux.context.find_buffer(parse_buffer_id(buffer_id)?), + )) + }, + ); + engine.register_fn( + "find_node", + |mux: &mut MuxApi, node_id: i64| -> RhaiResult { + Ok(dynamic_option_custom( + mux.context.find_node(parse_node_id(node_id)?), + )) + }, + ); + engine.register_fn( + "find_floating", + |mux: &mut MuxApi, floating_id: i64| -> RhaiResult { + Ok(dynamic_option_custom( + mux.context.find_floating(parse_floating_id(floating_id)?), + )) + }, + ); +} + +fn register_system_api(engine: &mut Engine) { + engine.register_fn( + "env", + |_: &mut SystemApi, name: ImmutableString| -> Dynamic { + std::env::var(name.as_str()) + .ok() + .map(Dynamic::from) + .unwrap_or(Dynamic::UNIT) + }, + ); + engine.register_fn( + "which", + |_: &mut SystemApi, name: ImmutableString| -> Dynamic { + which(name.as_str()) + .map(|path| Dynamic::from(path.display().to_string())) + .unwrap_or(Dynamic::UNIT) + }, + ); + engine.register_fn("now", |_: &mut SystemApi| -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)) + .unwrap_or_default() + }); +} + +fn register_ui_api(engine: &mut Engine) { + engine.register_fn("segment", |_: &mut UiApi, text: ImmutableString| { + BarSegment { + text: text.to_string(), + style: StyleSpec::default(), + target: None, + } + }); + engine.register_fn( + "segment", + |_: &mut UiApi, text: ImmutableString, options: Map| -> RhaiResult { + let (style, target) = parse_segment_options(options)?; + Ok(BarSegment { + text: text.to_string(), + style, + target, + }) + }, + ); + engine.register_fn( + "bar", + |_: &mut UiApi, left: Array, center: Array, right: Array| -> RhaiResult { + Ok(BarSpec { + left: parse_bar_segments(left)?, + center: parse_bar_segments(center)?, + right: parse_bar_segments(right)?, + }) + }, + ); +} + +fn register_theme_runtime_api(engine: &mut Engine) { + engine.register_fn( + "color", + |theme: &mut ThemeRuntimeApi, name: ImmutableString| -> Dynamic { + theme + .theme + .palette + .get(name.as_str()) + .copied() + .map(Dynamic::from) + .unwrap_or(Dynamic::UNIT) + }, + ); +} + +fn build_split( + direction: SplitDirection, + children: Array, + sizes: Vec, +) -> RhaiResult { + let children = parse_tree_array(children)?; + if children.is_empty() { + return Err(runtime_error("split children cannot be empty")); + } + if !sizes.is_empty() { + if sizes.len() != children.len() { + return Err(runtime_error( + "split sizes must match the number of children", + )); + } + if sizes.contains(&0) { + return Err(runtime_error("split sizes must be greater than zero")); + } + } + Ok(TreeSpec::Split { + direction, + children, + sizes, + }) +} + +fn build_tabs(tabs: Array, active: usize) -> RhaiResult { + let tabs = parse_tabs(tabs)?; + TabsSpec::try_new(tabs, active) + .map(TreeSpec::Tabs) + .map_err(runtime_error) +} + +fn parse_tabs_tree(tree: TreeSpec) -> RhaiResult { + match tree { + TreeSpec::Tabs(tabs) => TabsSpec::try_new(tabs.tabs, tabs.active).map_err(runtime_error), + _ => Err(runtime_error("expected a tree.tabs(...) spec")), + } +} + +fn parse_tabs(tabs: Array) -> RhaiResult> { + let mut parsed = Vec::with_capacity(tabs.len()); + for tab in tabs { + let Some(tab) = tab.try_cast::() else { + return Err(runtime_error("expected TabSpec values")); + }; + parsed.push(tab); + } + Ok(parsed) +} + +fn parse_tree_array(children: Array) -> RhaiResult> { + let mut parsed = Vec::with_capacity(children.len()); + for child in children { + let Some(tree) = child.try_cast::() else { + return Err(runtime_error("expected TreeSpec values")); + }; + parsed.push(tree); + } + Ok(parsed) +} + +fn parse_action_array(actions: Array) -> RhaiResult> { + let mut parsed = Vec::with_capacity(actions.len()); + for action in actions { + let Some(action) = action.try_cast::() else { + return Err(runtime_error("expected Action values")); + }; + parsed.push(action); + } + Ok(parsed) +} + +fn parse_bar_segments(segments: Array) -> RhaiResult> { + let mut parsed = Vec::with_capacity(segments.len()); + for segment in segments { + let Some(segment) = segment.try_cast::() else { + return Err(runtime_error("ui.bar expects BarSegment values")); + }; + parsed.push(segment); + } + Ok(parsed) +} + +fn parse_buffer_spawn(command: Array, mut options: Map) -> RhaiResult { + Ok(BufferSpawnSpec { + title: parse_optional_string(options.remove("title"))?, + command: parse_string_array(Dynamic::from(command))?, + cwd: parse_optional_string(options.remove("cwd"))?, + env: parse_string_map(options.remove("env"))?, + }) +} + +fn parse_floating_spec(tree: TreeSpec, options: Map) -> RhaiResult { + let options = parse_floating_options(options)?; + Ok(FloatingSpec { + tree, + geometry: options.geometry, + title: options.title, + focus: options.focus, + close_on_empty: options.close_on_empty, + }) +} + +struct ParsedFloatingOptions { + geometry: FloatingGeometrySpec, + title: Option, + focus: bool, + close_on_empty: bool, +} + +fn parse_floating_options(mut options: Map) -> RhaiResult { + Ok(ParsedFloatingOptions { + geometry: FloatingGeometrySpec { + width: parse_floating_size(options.remove("width"))? + .unwrap_or(FloatingSize::Percent(50)), + height: parse_floating_size(options.remove("height"))? + .unwrap_or(FloatingSize::Percent(50)), + anchor: parse_floating_anchor(options.remove("anchor"))? + .unwrap_or(FloatingAnchor::Center), + offset_x: parse_i16_field(options.remove("x"), "x")?.unwrap_or(0), + offset_y: parse_i16_field(options.remove("y"), "y")?.unwrap_or(0), + }, + title: parse_optional_string(options.remove("title"))?, + focus: parse_bool_field(options.remove("focus"))?.unwrap_or(true), + close_on_empty: parse_bool_field(options.remove("close_on_empty"))?.unwrap_or(true), + }) +} + +fn parse_segment_options(mut options: Map) -> RhaiResult<(StyleSpec, Option)> { + Ok(( + StyleSpec { + fg: parse_optional_color(options.remove("fg"))?, + bg: parse_optional_color(options.remove("bg"))?, + bold: parse_bool_field(options.remove("bold"))?.unwrap_or(false), + italic: parse_bool_field(options.remove("italic"))?.unwrap_or(false), + underline: parse_bool_field(options.remove("underline"))?.unwrap_or(false), + dim: parse_bool_field(options.remove("dim"))?.unwrap_or(false), + }, + parse_bar_target(options.remove("target"))?, + )) +} + +fn parse_bar_target(value: Option) -> RhaiResult> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_unit() { + return Ok(None); + } + let Some(mut target) = value.try_cast::() else { + return Err(runtime_error("bar target must be a map")); + }; + let kind = parse_required_string(&mut target, "kind")?; + match kind.as_str() { + "tab" => Ok(Some(BarTarget::Tab { + tabs_node_id: parse_node_id(parse_required_i64(&mut target, "tabs_node_id")?)?, + index: parse_index(parse_required_i64(&mut target, "index")?, "target index")?, + })), + "floating" => Ok(Some(BarTarget::Floating { + floating_id: parse_floating_id(parse_required_i64(&mut target, "floating_id")?)?, + })), + "buffer" => Ok(Some(BarTarget::Buffer { + buffer_id: parse_buffer_id(parse_required_i64(&mut target, "buffer_id")?)?, + })), + _ => Err(runtime_error(format!("unknown bar target kind '{kind}'"))), + } +} + +fn parse_optional_color(value: Option) -> RhaiResult> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_unit() { + return Ok(None); + } + value + .try_cast::() + .map(Some) + .ok_or_else(|| runtime_error("expected a color value")) +} + +fn parse_sizes(values: Array) -> RhaiResult> { + let mut parsed = Vec::with_capacity(values.len()); + for value in values { + let Some(value) = value.try_cast::() else { + return Err(runtime_error("split sizes must be integers")); + }; + parsed.push(parse_amount(value, "split size")?); + } + Ok(parsed) +} + +fn parse_string_array(value: Dynamic) -> RhaiResult> { + let Some(array) = value.try_cast::() else { + return Err(runtime_error("expected an array of strings")); + }; + let mut parsed = Vec::with_capacity(array.len()); + for value in array { + let Some(value) = value.try_cast::() else { + return Err(runtime_error("expected an array of strings")); + }; + parsed.push(value.to_string()); + } + Ok(parsed) +} + +fn parse_string_map( + value: Option, +) -> RhaiResult> { + let Some(value) = value else { + return Ok(Default::default()); + }; + if value.is_unit() { + return Ok(Default::default()); + } + let Some(map) = value.try_cast::() else { + return Err(runtime_error("expected a string map")); + }; + let mut parsed = std::collections::BTreeMap::new(); + for (key, value) in map { + let Some(value) = value.try_cast::() else { + return Err(runtime_error("expected a string map")); + }; + parsed.insert(key.to_string(), value.to_string()); + } + Ok(parsed) +} + +fn parse_optional_string(value: Option) -> RhaiResult> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_unit() { + return Ok(None); + } + let Some(value) = value.try_cast::() else { + return Err(runtime_error("expected a string value")); + }; + Ok(Some(value.to_string())) +} + +fn parse_required_string(options: &mut Map, key: &str) -> RhaiResult { + parse_optional_string(options.remove(key))? + .ok_or_else(|| runtime_error(format!("missing '{key}' field"))) +} + +fn parse_required_i64(options: &mut Map, key: &str) -> RhaiResult { + let value = options + .remove(key) + .ok_or_else(|| runtime_error(format!("missing '{key}' field")))?; + value + .try_cast::() + .ok_or_else(|| runtime_error(format!("'{key}' must be an integer"))) +} + +fn parse_bool_field(value: Option) -> RhaiResult> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_unit() { + return Ok(None); + } + value + .try_cast::() + .map(Some) + .ok_or_else(|| runtime_error("expected a boolean value")) +} + +fn parse_i16_field(value: Option, label: &str) -> RhaiResult> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_unit() { + return Ok(None); + } + let Some(value) = value.try_cast::() else { + return Err(runtime_error(format!("'{label}' must be an integer"))); + }; + i16::try_from(value) + .map(Some) + .map_err(|_| runtime_error(format!("'{label}' is out of range"))) +} + +fn parse_floating_size(value: Option) -> RhaiResult> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_unit() { + return Ok(None); + } + if let Some(value) = value.clone().try_cast::() { + return Ok(Some(FloatingSize::Cells(parse_amount( + value, + "floating size", + )?))); + } + if let Some(value) = value.try_cast::() { + let value = value.to_string(); + if let Some(percent) = value.strip_suffix('%') { + let percent = percent + .parse::() + .map_err(|_| runtime_error("floating percentages must be between 0 and 100"))?; + if percent == 0 || percent > 100 { + return Err(runtime_error( + "floating percentages must be between 1 and 100", + )); + } + return Ok(Some(FloatingSize::Percent(percent))); + } + } + Err(runtime_error( + "floating width/height must be an integer cell count or percentage string like '50%'", + )) +} + +fn parse_floating_anchor(value: Option) -> RhaiResult> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_unit() { + return Ok(None); + } + let Some(value) = value.try_cast::() else { + return Err(runtime_error("floating anchor must be a string")); + }; + let anchor = match value.as_str() { + "center" => FloatingAnchor::Center, + "top_left" => FloatingAnchor::TopLeft, + "top_right" => FloatingAnchor::TopRight, + "bottom_left" => FloatingAnchor::BottomLeft, + "bottom_right" => FloatingAnchor::BottomRight, + other => return Err(runtime_error(format!("unknown floating anchor '{other}'"))), + }; + Ok(Some(anchor)) +} + +fn parse_bytes(bytes: Array) -> RhaiResult> { + let mut parsed = Vec::with_capacity(bytes.len()); + for byte in bytes { + let Some(value) = byte.try_cast::() else { + return Err(runtime_error("send_bytes expects an array of integers")); + }; + let value = u8::try_from(value) + .map_err(|_| runtime_error("send_bytes values must be between 0 and 255"))?; + parsed.push(value); + } + Ok(parsed) +} + +fn parse_count(value: i64, label: &str) -> RhaiResult { + if value < 0 { + return Err(runtime_error(format!("{label} must be zero or greater"))); + } + usize::try_from(value).map_err(|_| runtime_error(format!("{label} is too large"))) +} + +fn parse_amount(value: i64, label: &str) -> RhaiResult { + if value <= 0 { + return Err(runtime_error(format!("{label} must be greater than zero"))); + } + u16::try_from(value).map_err(|_| runtime_error(format!("{label} is too large"))) +} + +fn parse_index(value: i64, label: &str) -> RhaiResult { + if value < 0 { + return Err(runtime_error(format!("{label} must be zero or greater"))); + } + usize::try_from(value).map_err(|_| runtime_error(format!("{label} is too large"))) +} + +fn parse_buffer_id(value: i64) -> RhaiResult { + if value < 0 { + return Err(runtime_error("buffer id must be zero or greater")); + } + Ok(BufferId(value as u64)) +} + +fn parse_node_id(value: i64) -> RhaiResult { + if value < 0 { + return Err(runtime_error("node id must be zero or greater")); + } + Ok(NodeId(value as u64)) +} + +fn parse_floating_id(value: i64) -> RhaiResult { + if value < 0 { + return Err(runtime_error("floating id must be zero or greater")); + } + Ok(FloatingId(value as u64)) +} + +fn parse_notify_level(value: &str) -> RhaiResult { + match value { + "info" => Ok(NotifyLevel::Info), + "warn" => Ok(NotifyLevel::Warn), + "error" => Ok(NotifyLevel::Error), + _ => Err(runtime_error(format!("unknown notify level '{value}'"))), + } +} + +fn parse_split_direction(value: &str) -> RhaiResult { + match value.to_ascii_lowercase().as_str() { + "h" | "horizontal" => Ok(SplitDirection::Horizontal), + "v" | "vertical" => Ok(SplitDirection::Vertical), + _ => Err(runtime_error(format!("unknown split direction '{value}'"))), + } +} + +fn dynamic_option_custom(value: Option) -> Dynamic { + value.map(Dynamic::from).unwrap_or(Dynamic::UNIT) +} + +fn dynamic_option_string(value: Option) -> Dynamic { + value.map(Dynamic::from).unwrap_or(Dynamic::UNIT) +} + +fn dynamic_u64(value: u64) -> Dynamic { + Dynamic::from(i64::try_from(value).unwrap_or(i64::MAX)) +} + +fn dynamic_u32(value: u32) -> Dynamic { + Dynamic::from(i64::from(value)) +} + +fn dynamic_usize(value: usize) -> Dynamic { + Dynamic::from(i64::try_from(value).unwrap_or(i64::MAX)) +} + +fn rect_map(rect: Rect) -> Map { + Map::from_iter([ + ("x".into(), Dynamic::from(i64::from(rect.origin.x))), + ("y".into(), Dynamic::from(i64::from(rect.origin.y))), + ("width".into(), Dynamic::from(i64::from(rect.size.width))), + ("height".into(), Dynamic::from(i64::from(rect.size.height))), + ]) +} + +fn float_geometry_map(geometry: embers_core::FloatGeometry) -> Map { + Map::from_iter([ + ("x".into(), Dynamic::from(i64::from(geometry.x))), + ("y".into(), Dynamic::from(i64::from(geometry.y))), + ("width".into(), Dynamic::from(i64::from(geometry.width))), + ("height".into(), Dynamic::from(i64::from(geometry.height))), + ]) +} + +fn activity_name(activity: embers_core::ActivityState) -> String { + match activity { + embers_core::ActivityState::Idle => "idle", + embers_core::ActivityState::Activity => "activity", + embers_core::ActivityState::Bell => "bell", + } + .to_owned() +} + +fn node_kind_name(kind: embers_protocol::NodeRecordKind) -> String { + match kind { + embers_protocol::NodeRecordKind::BufferView => "buffer_view", + embers_protocol::NodeRecordKind::Split => "split", + embers_protocol::NodeRecordKind::Tabs => "tabs", + } + .to_owned() +} + +fn split_direction_name(direction: embers_core::SplitDirection) -> String { + match direction { + embers_core::SplitDirection::Horizontal => "horizontal", + embers_core::SplitDirection::Vertical => "vertical", + } + .to_owned() +} + +fn which(name: &str) -> Option { + let path = std::env::var_os("PATH")?; + for entry in std::env::split_paths(&path) { + let candidate = entry.join(name); + let Some(metadata) = candidate.metadata().ok() else { + continue; + }; + if !metadata.is_file() { + continue; + } + #[cfg(unix)] + if metadata.permissions().mode() & 0o111 == 0 { + continue; + } + #[cfg(not(unix))] + { + return Some(candidate); + } + #[cfg(unix)] + return Some(candidate); + } + None +} + +fn runtime_error(message: impl Into) -> Box { + EvalAltResult::ErrorRuntime(message.into().into(), rhai::Position::NONE).into() +} + +#[cfg(test)] +mod tests { + use super::{parse_notify_level, parse_split_direction}; + + #[test] + fn parse_levels_accepts_draft_names() { + assert!(parse_notify_level("info").is_ok()); + assert!(parse_notify_level("warn").is_ok()); + assert!(parse_notify_level("error").is_ok()); + } + + #[test] + fn parse_split_direction_accepts_words() { + assert!(parse_split_direction("horizontal").is_ok()); + assert!(parse_split_direction("vertical").is_ok()); + } +} diff --git a/crates/embers-client/src/scripting/types.rs b/crates/embers-client/src/scripting/types.rs new file mode 100644 index 0000000..97ac1f8 --- /dev/null +++ b/crates/embers-client/src/scripting/types.rs @@ -0,0 +1,203 @@ +use std::collections::BTreeMap; +use std::fmt; +use std::path::PathBuf; + +use rhai::AST; +use thiserror::Error; + +use crate::input::{BindingSpec, KeySequence, ModeSpec}; + +use super::model::Action; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct ScriptFunctionRef { + pub name: String, +} + +impl ScriptFunctionRef { + pub fn new(name: impl Into) -> Self { + Self { name: name.into() } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct RgbColor { + pub red: u8, + pub green: u8, + pub blue: u8, +} + +impl RgbColor { + pub fn parse(value: &str) -> Result { + let Some(hex) = value.strip_prefix('#') else { + return Err(PaletteError::InvalidColor { + value: value.to_owned(), + }); + }; + if hex.len() != 6 || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) { + return Err(PaletteError::InvalidColor { + value: value.to_owned(), + }); + } + + let red = u8::from_str_radix(&hex[0..2], 16).map_err(|_| PaletteError::InvalidColor { + value: value.to_owned(), + })?; + let green = u8::from_str_radix(&hex[2..4], 16).map_err(|_| PaletteError::InvalidColor { + value: value.to_owned(), + })?; + let blue = u8::from_str_radix(&hex[4..6], 16).map_err(|_| PaletteError::InvalidColor { + value: value.to_owned(), + })?; + + Ok(Self { red, green, blue }) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ThemeSpec { + pub palette: BTreeMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct StyleSpec { + pub fg: Option, + pub bg: Option, + pub bold: bool, + pub italic: bool, + pub underline: bool, + pub dim: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BarTarget { + Tab { + tabs_node_id: embers_core::NodeId, + index: usize, + }, + Floating { + floating_id: embers_core::FloatingId, + }, + Buffer { + buffer_id: embers_core::BufferId, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BarSegment { + pub text: String, + pub style: StyleSpec, + pub target: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BarSpec { + pub left: Vec, + pub center: Vec, + pub right: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ModeHooks { + pub on_enter: Option, + pub on_leave: Option, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct MouseSettings { + pub click_focus: bool, + pub click_forward: bool, + pub wheel_scroll: bool, + pub wheel_forward: bool, +} + +impl MouseSettings { + pub const fn capture_enabled(self) -> bool { + self.click_focus || self.click_forward || self.wheel_scroll || self.wheel_forward + } +} + +#[derive(Clone)] +pub struct LoadedConfig { + pub source_path: Option, + pub source_hash: u64, + pub ast: AST, + pub leader: KeySequence, + pub modes: BTreeMap, + pub mode_hooks: BTreeMap, + pub bindings: BTreeMap>>>, + pub named_actions: BTreeMap, + pub event_handlers: BTreeMap>, + pub tab_bar_formatter: Option, + pub mouse: MouseSettings, + pub theme: ThemeSpec, +} + +impl LoadedConfig { + pub fn has_action(&self, name: &str) -> bool { + self.named_actions.contains_key(name) + } + + pub fn has_event_handlers(&self, event: &str) -> bool { + self.event_handlers + .get(event) + .is_some_and(|handlers| !handlers.is_empty()) + } + + pub fn has_tab_bar_formatter(&self) -> bool { + self.tab_bar_formatter.is_some() + } +} + +impl fmt::Debug for LoadedConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LoadedConfig") + .field("source_path", &self.source_path) + .field("source_hash", &self.source_hash) + .field("ast", &"") + .field("leader", &self.leader) + .field("modes", &self.modes) + .field("mode_hooks", &self.mode_hooks) + .field("bindings", &self.bindings) + .field("named_actions", &self.named_actions) + .field("event_handlers", &self.event_handlers) + .field("tab_bar_formatter", &self.tab_bar_formatter) + .field("mouse", &self.mouse) + .field("theme", &self.theme) + .finish() + } +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum PaletteError { + #[error("palette color '{value}' must be in '#RRGGBB' form")] + InvalidColor { value: String }, +} + +#[cfg(test)] +mod tests { + use super::{PaletteError, RgbColor}; + + #[test] + fn parses_hex_colors() { + assert_eq!( + RgbColor::parse("#12abef").unwrap(), + RgbColor { + red: 0x12, + green: 0xab, + blue: 0xef, + } + ); + } + + #[test] + fn rejects_invalid_hex_colors() { + assert_eq!( + RgbColor::parse("red").unwrap_err(), + PaletteError::InvalidColor { + value: "red".to_owned(), + } + ); + } +} diff --git a/crates/embers-client/src/socket_transport.rs b/crates/embers-client/src/socket_transport.rs new file mode 100644 index 0000000..43b5601 --- /dev/null +++ b/crates/embers-client/src/socket_transport.rs @@ -0,0 +1,89 @@ +use std::collections::VecDeque; +use std::path::Path; + +use async_trait::async_trait; +use embers_core::{MuxError, Result}; +use embers_protocol::{ + ClientMessage, ProtocolClient, ProtocolError, ServerEnvelope, ServerEvent, ServerResponse, +}; +use tokio::sync::Mutex; + +use crate::transport::Transport; + +#[derive(Debug)] +pub struct SocketTransport { + client: Mutex, + queued_events: Mutex>, +} + +impl SocketTransport { + pub async fn connect(path: impl AsRef) -> Result { + let client = ProtocolClient::connect(path) + .await + .map_err(protocol_error_to_mux)?; + Ok(Self { + client: Mutex::new(client), + queued_events: Mutex::new(VecDeque::new()), + }) + } +} + +#[async_trait] +impl Transport for SocketTransport { + async fn request(&self, message: ClientMessage) -> Result { + let request_id = message.request_id(); + let mut drained_events = Vec::new(); + + let response = { + let mut client = self.client.lock().await; + client.send(&message).await.map_err(protocol_error_to_mux)?; + + loop { + match client.recv().await.map_err(protocol_error_to_mux)? { + Some(ServerEnvelope::Event(event)) => drained_events.push(event), + Some(ServerEnvelope::Response(response)) => { + if let Some(response_id) = response.request_id() + && response_id != request_id + { + break Err(MuxError::protocol(format!( + "mismatched response id: expected {request_id}, got {response_id}" + ))); + } + break Ok(response); + } + None => break Err(MuxError::transport("connection closed before response")), + } + } + }; + + if !drained_events.is_empty() { + self.queued_events.lock().await.extend(drained_events); + } + + response + } + + async fn next_event(&self) -> Result { + if let Some(event) = self.queued_events.lock().await.pop_front() { + return Ok(event); + } + + let mut client = self.client.lock().await; + match client.recv().await.map_err(protocol_error_to_mux)? { + Some(ServerEnvelope::Event(event)) => Ok(event), + Some(ServerEnvelope::Response(response)) => Err(MuxError::protocol(format!( + "received response without pending request: {response:?}" + ))), + None => Err(MuxError::transport( + "connection closed while waiting for an event", + )), + } + } +} + +fn protocol_error_to_mux(error: ProtocolError) -> MuxError { + match error { + ProtocolError::Io(error) => error.into(), + other => MuxError::transport(other.to_string()), + } +} diff --git a/crates/embers-client/src/state.rs b/crates/embers-client/src/state.rs new file mode 100644 index 0000000..0f419e0 --- /dev/null +++ b/crates/embers-client/src/state.rs @@ -0,0 +1,434 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use embers_core::{BufferId, NodeId, SessionId}; +use embers_protocol::NodeRecordKind; +use embers_protocol::{ + BufferRecord, ServerEvent, SessionRecord, SessionSnapshot, VisibleSnapshotResponse, +}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct SearchMatch { + pub line: u64, + pub start_column: u16, + pub end_column: u16, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SearchState { + pub query: String, + pub matches: Vec, + pub active_match_index: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SelectionKind { + Character, + Line, + Block, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct SelectionPoint { + pub line: u64, + pub column: u16, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SelectionState { + pub kind: SelectionKind, + pub anchor: SelectionPoint, + pub cursor: SelectionPoint, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BufferViewState { + pub buffer_id: BufferId, + pub follow_output: bool, + pub scroll_top_line: u64, + pub visible_line_count: u16, + pub total_line_count: u64, + pub alternate_screen: bool, + pub visible_lines: Vec, + pub search_state: Option, + pub selection_state: Option, +} + +impl Default for BufferViewState { + fn default() -> Self { + Self { + buffer_id: BufferId(0), + follow_output: true, + scroll_top_line: 0, + visible_line_count: 0, + total_line_count: 0, + alternate_screen: false, + visible_lines: Vec::new(), + search_state: None, + selection_state: None, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ClientState { + pub sessions: BTreeMap, + pub buffers: BTreeMap, + pub nodes: BTreeMap, + pub floating: BTreeMap, + pub snapshots: BTreeMap, + pub view_state: BTreeMap, + pub dirty_sessions: BTreeSet, + pub invalidated_buffers: BTreeSet, +} + +impl ClientState { + pub fn apply_session_snapshot(&mut self, snapshot: SessionSnapshot) { + let SessionSnapshot { + session, + nodes, + buffers, + floating, + } = snapshot; + let session_id = session.id; + let previous_node_ids = self.session_node_ids(session_id); + let previous_attached_buffers = self.attached_buffers_for_nodes(&previous_node_ids); + let current_node_ids = nodes.iter().map(|node| node.id).collect::>(); + let current_buffer_ids = buffers + .iter() + .map(|buffer| buffer.id) + .collect::>(); + let current_floating_ids = floating + .iter() + .map(|window| window.id) + .collect::>(); + + self.sessions.insert(session_id, session); + self.nodes.retain(|node_id, node| { + node.session_id != session_id || current_node_ids.contains(node_id) + }); + self.floating.retain(|floating_id, window| { + window.session_id != session_id || current_floating_ids.contains(floating_id) + }); + self.view_state.retain(|node_id, _| { + !previous_node_ids.contains(node_id) || current_node_ids.contains(node_id) + }); + + for node in nodes { + self.nodes.insert(node.id, node); + } + + for buffer in buffers { + self.buffers.insert(buffer.id, buffer); + } + + for window in floating { + self.floating.insert(window.id, window); + } + + for buffer_id in previous_attached_buffers.difference(¤t_buffer_ids) { + if let Some(buffer) = self.buffers.get_mut(buffer_id) + && buffer + .attachment_node_id + .is_some_and(|node_id| previous_node_ids.contains(&node_id)) + { + buffer.attachment_node_id = None; + } + } + + self.sync_view_states_for_nodes(¤t_node_ids); + self.dirty_sessions.remove(&session_id); + } + + pub fn apply_detached_buffers(&mut self, buffers: Vec) { + let current_detached = self + .buffers + .values() + .filter(|buffer| buffer.attachment_node_id.is_none()) + .map(|buffer| buffer.id) + .collect::>(); + let incoming_ids = buffers + .iter() + .map(|buffer| buffer.id) + .collect::>(); + + for buffer_id in current_detached.difference(&incoming_ids) { + self.buffers.remove(buffer_id); + self.snapshots.remove(buffer_id); + self.view_state + .retain(|_, state| state.buffer_id != *buffer_id); + self.invalidated_buffers.remove(buffer_id); + } + + for buffer in buffers { + self.buffers.insert(buffer.id, buffer); + } + } + + pub fn apply_buffer_snapshot(&mut self, snapshot: VisibleSnapshotResponse) { + if let Some(buffer) = self.buffers.get_mut(&snapshot.buffer_id) { + buffer.last_snapshot_seq = snapshot.sequence; + buffer.pty_size = snapshot.size; + if let Some(title) = &snapshot.title { + buffer.title = title.clone(); + } + if let Some(cwd) = &snapshot.cwd { + buffer.cwd = Some(cwd.clone()); + } + } + + let buffer_id = snapshot.buffer_id; + self.invalidated_buffers.remove(&snapshot.buffer_id); + self.snapshots.insert(snapshot.buffer_id, snapshot); + self.sync_view_states_for_buffer(buffer_id); + } + + pub fn apply_event(&mut self, event: &ServerEvent) { + match event { + ServerEvent::SessionCreated(event) => { + self.sessions + .insert(event.session.id, event.session.clone()); + self.dirty_sessions.insert(event.session.id); + } + ServerEvent::SessionClosed(event) => self.remove_session(event.session_id), + ServerEvent::BufferCreated(event) => { + self.buffers.insert(event.buffer.id, event.buffer.clone()); + } + ServerEvent::BufferDetached(event) => { + if let Some(buffer) = self.buffers.get_mut(&event.buffer_id) { + buffer.attachment_node_id = None; + } + } + ServerEvent::NodeChanged(event) => { + self.dirty_sessions.insert(event.session_id); + } + ServerEvent::FloatingChanged(event) => { + self.dirty_sessions.insert(event.session_id); + } + ServerEvent::FocusChanged(event) => { + if let Some(session) = self.sessions.get_mut(&event.session_id) { + session.focused_leaf_id = event.focused_leaf_id; + session.focused_floating_id = event.focused_floating_id; + for floating_id in &session.floating_ids { + if let Some(floating) = self.floating.get_mut(floating_id) { + floating.focused = Some(*floating_id) == event.focused_floating_id; + } + } + } + } + ServerEvent::RenderInvalidated(event) => { + self.invalidated_buffers.insert(event.buffer_id); + } + } + } + + pub fn remove_session(&mut self, session_id: SessionId) { + let node_ids = self.session_node_ids(session_id); + self.sessions.remove(&session_id); + self.nodes.retain(|_, node| node.session_id != session_id); + self.floating + .retain(|_, window| window.session_id != session_id); + self.view_state + .retain(|node_id, _| !node_ids.contains(node_id)); + self.detach_buffers_for_nodes(&node_ids); + self.dirty_sessions.remove(&session_id); + } + + pub fn view_state(&self, node_id: NodeId) -> Option<&BufferViewState> { + self.view_state.get(&node_id) + } + + pub fn view_state_mut(&mut self, node_id: NodeId) -> Option<&mut BufferViewState> { + self.view_state.get_mut(&node_id) + } + + pub fn set_view_follow_output(&mut self, node_id: NodeId, follow_output: bool) -> Option<()> { + let snapshot_lines = self + .view_state + .get(&node_id) + .and_then(|state| self.snapshots.get(&state.buffer_id)) + .map(|snapshot| snapshot.lines.clone()) + .unwrap_or_default(); + let state = self.view_state.get_mut(&node_id)?; + state.follow_output = follow_output; + if follow_output { + state.scroll_top_line = + bottom_top_line(state.total_line_count, state.visible_line_count); + if !snapshot_lines.is_empty() { + state.visible_lines = snapshot_lines; + } + } + Some(()) + } + + pub fn set_view_scroll_top(&mut self, node_id: NodeId, scroll_top_line: u64) -> Option { + let state = self.view_state.get_mut(&node_id)?; + let scroll_top_line = clamp_top_line( + scroll_top_line, + state.total_line_count, + state.visible_line_count, + ); + state.scroll_top_line = scroll_top_line; + state.follow_output = + scroll_top_line == bottom_top_line(state.total_line_count, state.visible_line_count); + Some(scroll_top_line) + } + + pub fn set_view_visible_lines( + &mut self, + node_id: NodeId, + scroll_top_line: u64, + lines: Vec, + ) -> Option<()> { + let state = self.view_state.get_mut(&node_id)?; + let scroll_top_line = clamp_top_line( + scroll_top_line, + state.total_line_count, + state.visible_line_count, + ); + state.scroll_top_line = scroll_top_line; + state.follow_output = + scroll_top_line == bottom_top_line(state.total_line_count, state.visible_line_count); + state.visible_lines = lines; + Some(()) + } + + fn sync_view_states_for_buffer(&mut self, buffer_id: BufferId) { + let node_ids = self + .nodes + .values() + .filter(|node| { + matches!(node.kind, NodeRecordKind::BufferView) + && node + .buffer_view + .as_ref() + .is_some_and(|view| view.buffer_id == buffer_id) + }) + .map(|node| node.id) + .collect::>(); + self.sync_view_states_for_nodes(&node_ids); + } + + fn sync_view_states_for_nodes(&mut self, node_ids: &BTreeSet) { + for node_id in node_ids { + let Some(node) = self.nodes.get(node_id) else { + continue; + }; + if node.kind != NodeRecordKind::BufferView { + continue; + } + let Some(buffer_view) = node.buffer_view.as_ref() else { + continue; + }; + let snapshot = self.snapshots.get(&buffer_view.buffer_id); + let visible_line_count = buffer_view.last_render_size.rows; + let total_line_count = snapshot + .map(|snapshot| snapshot.total_lines.max(u64::from(visible_line_count))) + .unwrap_or_else(|| u64::from(visible_line_count)); + let alternate_screen = snapshot.is_some_and(|snapshot| snapshot.alternate_screen); + let initial_top_line = snapshot + .map(|snapshot| snapshot.viewport_top_line) + .unwrap_or_else(|| bottom_top_line(total_line_count, visible_line_count)); + let snapshot_lines = snapshot + .map(|snapshot| snapshot.lines.clone()) + .unwrap_or_default(); + + match self.view_state.get_mut(node_id) { + Some(state) if state.buffer_id == buffer_view.buffer_id => { + state.visible_line_count = visible_line_count; + state.total_line_count = total_line_count; + state.alternate_screen = alternate_screen; + if alternate_screen { + state.visible_lines = snapshot_lines; + } else { + state.scroll_top_line = if state.follow_output { + bottom_top_line(total_line_count, visible_line_count) + } else { + clamp_top_line( + state.scroll_top_line, + total_line_count, + visible_line_count, + ) + }; + let live_top_line = snapshot + .map(|snapshot| snapshot.viewport_top_line) + .unwrap_or(state.scroll_top_line); + if state.follow_output + || state.scroll_top_line == live_top_line + || state.visible_lines.is_empty() + { + state.visible_lines = snapshot_lines; + } else { + state.visible_lines.clear(); + } + } + } + Some(state) => { + *state = BufferViewState { + buffer_id: buffer_view.buffer_id, + follow_output: buffer_view.follow_output, + scroll_top_line: initial_top_line, + visible_line_count, + total_line_count, + alternate_screen, + visible_lines: snapshot_lines, + search_state: None, + selection_state: None, + }; + } + None => { + self.view_state.insert( + *node_id, + BufferViewState { + buffer_id: buffer_view.buffer_id, + follow_output: buffer_view.follow_output, + scroll_top_line: initial_top_line, + visible_line_count, + total_line_count, + alternate_screen, + visible_lines: snapshot_lines, + search_state: None, + selection_state: None, + }, + ); + } + } + } + } + + fn session_node_ids(&self, session_id: SessionId) -> BTreeSet { + self.nodes + .values() + .filter(|node| node.session_id == session_id) + .map(|node| node.id) + .collect() + } + + fn attached_buffers_for_nodes(&self, node_ids: &BTreeSet) -> BTreeSet { + self.buffers + .values() + .filter_map(|buffer| { + let node_id = buffer.attachment_node_id?; + node_ids.contains(&node_id).then_some(buffer.id) + }) + .collect() + } + + fn detach_buffers_for_nodes(&mut self, node_ids: &BTreeSet) { + for buffer in self.buffers.values_mut() { + if buffer + .attachment_node_id + .is_some_and(|node_id| node_ids.contains(&node_id)) + { + buffer.attachment_node_id = None; + } + } + } +} + +fn bottom_top_line(total_line_count: u64, visible_line_count: u16) -> u64 { + total_line_count.saturating_sub(u64::from(visible_line_count)) +} + +fn clamp_top_line(scroll_top_line: u64, total_line_count: u64, visible_line_count: u16) -> u64 { + scroll_top_line.min(bottom_top_line(total_line_count, visible_line_count)) +} diff --git a/crates/mux-client/src/testing.rs b/crates/embers-client/src/testing.rs similarity index 78% rename from crates/mux-client/src/testing.rs rename to crates/embers-client/src/testing.rs index 6cf15bd..b08c307 100644 --- a/crates/mux-client/src/testing.rs +++ b/crates/embers-client/src/testing.rs @@ -2,8 +2,8 @@ use std::collections::VecDeque; use std::sync::{Arc, Mutex}; use async_trait::async_trait; -use mux_core::{MuxError, Result}; -use mux_protocol::{ClientMessage, ServerEvent, ServerResponse}; +use embers_core::{MuxError, Result}; +use embers_protocol::{ClientMessage, ServerEvent, ServerResponse}; use crate::transport::Transport; @@ -116,56 +116,12 @@ impl Transport for ScriptedTransport { } } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TestGrid { - width: u16, - height: u16, - cells: Vec, -} - -impl TestGrid { - pub fn new(width: u16, height: u16) -> Self { - let len = usize::from(width) * usize::from(height); - Self { - width, - height, - cells: vec![' '; len], - } - } - - pub fn put_str(&mut self, x: u16, y: u16, text: &str) { - if y >= self.height { - return; - } - - for (offset, ch) in text.chars().enumerate() { - let Some(x_pos) = x.checked_add(offset as u16) else { - break; - }; - if x_pos >= self.width { - break; - } - let idx = usize::from(y) * usize::from(self.width) + usize::from(x_pos); - self.cells[idx] = ch; - } - } - - pub fn lines(&self) -> Vec { - self.cells - .chunks(usize::from(self.width)) - .map(|row| row.iter().collect::()) - .collect() - } - - pub fn render(&self) -> String { - self.lines().join("\n") - } -} +pub type TestGrid = crate::grid::RenderGrid; #[cfg(test)] mod tests { - use mux_core::RequestId; - use mux_protocol::{ClientMessage, PingRequest, PingResponse, ServerResponse}; + use embers_core::RequestId; + use embers_protocol::{ClientMessage, PingRequest, PingResponse, ServerResponse}; use super::{FakeTransport, ScriptedTransport, TestGrid}; use crate::Transport; @@ -220,9 +176,9 @@ mod tests { #[test] fn test_grid_renders_rows() { let mut grid = TestGrid::new(6, 2); - grid.put_str(1, 0, "mux"); + grid.put_str(1, 0, "embers"); grid.put_str(0, 1, "ok"); - assert_eq!(grid.render(), " mux \nok "); + assert_eq!(grid.render(), " ember\nok "); } } diff --git a/crates/mux-client/src/transport.rs b/crates/embers-client/src/transport.rs similarity index 70% rename from crates/mux-client/src/transport.rs rename to crates/embers-client/src/transport.rs index 883e889..6e17f27 100644 --- a/crates/mux-client/src/transport.rs +++ b/crates/embers-client/src/transport.rs @@ -1,6 +1,6 @@ use async_trait::async_trait; -use mux_core::Result; -use mux_protocol::{ClientMessage, ServerEvent, ServerResponse}; +use embers_core::Result; +use embers_protocol::{ClientMessage, ServerEvent, ServerResponse}; #[async_trait] pub trait Transport: Send + Sync { diff --git a/crates/embers-client/tests/config_loading.rs b/crates/embers-client/tests/config_loading.rs new file mode 100644 index 0000000..71f56de --- /dev/null +++ b/crates/embers-client/tests/config_loading.rs @@ -0,0 +1,146 @@ +use std::fs; + +use embers_client::{ + BUILTIN_CONFIG_SOURCE, ConfigDiscoveryOptions, ConfigManager, ConfigOrigin, KeyToken, +}; +use tempfile::tempdir; + +#[test] +fn config_manager_loads_standard_config_file() { + let tempdir = tempdir().unwrap(); + let config_path = tempdir.path().join("config.rhai"); + fs::write(&config_path, "set_leader(\"\")").unwrap(); + let options = ConfigDiscoveryOptions::default().with_project_config_dir(tempdir.path()); + + let manager = ConfigManager::load(options).unwrap(); + + assert_eq!(manager.active_source().origin, ConfigOrigin::Standard); + assert_eq!( + manager.active_source().path, + Some(config_path.canonicalize().unwrap()) + ); + assert_eq!(manager.active_source().source, "set_leader(\"\")"); +} + +#[test] +fn explicit_override_wins_when_starting_manager() { + let tempdir = tempdir().unwrap(); + let explicit_path = tempdir.path().join("explicit.rhai"); + let standard_path = tempdir.path().join("config.rhai"); + fs::write(&explicit_path, "set_leader(\"\")").unwrap(); + fs::write(&standard_path, "set_leader(\"\")").unwrap(); + let options = ConfigDiscoveryOptions { + explicit_path: Some(explicit_path.clone()), + env_path: Some(standard_path.clone()), + standard_config_path: Some(standard_path), + }; + + let manager = ConfigManager::load(options).unwrap(); + + assert_eq!(manager.active_source().origin, ConfigOrigin::Explicit); + assert_eq!( + manager.active_source().path, + Some(explicit_path.canonicalize().unwrap()) + ); + assert_eq!(manager.active_source().source, "set_leader(\"\")"); +} + +#[test] +fn manager_uses_builtin_config_when_no_files_exist() { + let manager = ConfigManager::load(ConfigDiscoveryOptions { + explicit_path: None, + env_path: None, + standard_config_path: None, + }) + .unwrap(); + + assert_eq!(manager.active_source().origin, ConfigOrigin::BuiltIn); + assert_eq!(manager.active_source().source, BUILTIN_CONFIG_SOURCE); + assert_eq!(manager.active_source().display_path(), ""); + assert!( + manager.active_script().loaded_config().bindings["normal"] + .iter() + .any(|binding| binding.notation == "") + ); + assert!(manager.active_script().loaded_config().mouse.click_focus); + assert!(manager.active_script().loaded_config().mouse.wheel_scroll); +} + +#[test] +fn reload_keeps_previous_config_when_new_source_fails() { + let tempdir = tempdir().unwrap(); + let config_path = tempdir.path().join("config.rhai"); + fs::write(&config_path, r#"set_leader("")"#).unwrap(); + let options = ConfigDiscoveryOptions::default().with_project_config_dir(tempdir.path()); + + let mut manager = ConfigManager::load(options).unwrap(); + let previous_source = manager.active_source().clone(); + + fs::write(&config_path, "set_leader(").unwrap(); + let error = manager.reload().expect_err("reload must fail"); + + assert!(error.to_string().contains("config.rhai")); + assert_eq!(manager.active_source(), &previous_source); + assert_eq!( + manager.active_script().loaded_config().leader, + vec![KeyToken::Ctrl('a')] + ); +} + +#[test] +fn reload_swaps_in_new_compiled_config_on_success() { + let tempdir = tempdir().unwrap(); + let config_path = tempdir.path().join("config.rhai"); + fs::write(&config_path, r#"set_leader("")"#).unwrap(); + let options = ConfigDiscoveryOptions::default().with_project_config_dir(tempdir.path()); + + let mut manager = ConfigManager::load(options).unwrap(); + fs::write(&config_path, r#"set_leader("")"#).unwrap(); + + manager.reload().unwrap(); + + assert_eq!(manager.active_source().origin, ConfigOrigin::Standard); + assert_eq!( + manager.active_script().loaded_config().leader, + vec![KeyToken::Ctrl('b')] + ); +} + +#[test] +fn user_config_overlays_builtins_and_can_unbind_defaults() { + let tempdir = tempdir().unwrap(); + let config_path = tempdir.path().join("config.rhai"); + fs::write( + &config_path, + r#" + unbind("normal", ""); + mouse.set_wheel_scroll(false); + "#, + ) + .unwrap(); + + let manager = ConfigManager::load( + ConfigDiscoveryOptions::default().with_project_config_dir(tempdir.path()), + ) + .unwrap(); + + assert!( + manager + .active_source() + .source + .contains(r#"unbind("normal", "");"#) + ); + assert!( + manager + .active_source() + .source + .contains("mouse.set_wheel_scroll(false);") + ); + assert!( + !manager.active_script().loaded_config().bindings["normal"] + .iter() + .any(|binding| binding.notation == "") + ); + assert!(manager.active_script().loaded_config().mouse.click_focus); + assert!(!manager.active_script().loaded_config().mouse.wheel_scroll); +} diff --git a/crates/embers-client/tests/configured_client.rs b/crates/embers-client/tests/configured_client.rs new file mode 100644 index 0000000..fd2cedd --- /dev/null +++ b/crates/embers-client/tests/configured_client.rs @@ -0,0 +1,1309 @@ +mod support; + +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use embers_client::{ + ConfigDiscoveryOptions, ConfigManager, ConfiguredClient, FakeTransport, KeyEvent, MouseButton, + MouseEvent, MouseEventKind, MouseModifiers, MuxClient, PresentationModel, ScriptedTransport, +}; +use embers_core::{ActivityState, BufferId, NodeId, PtySize, RequestId, SessionId, Size}; +use embers_protocol::{ + BufferCreatedEvent, BufferRecord, BufferRecordState, BufferViewRecord, ClientMessage, + FocusChangedEvent, InputRequest, NodeRecord, NodeRecordKind, NodeRequest, OkResponse, + RenderInvalidatedEvent, ScrollbackSliceResponse, ServerEvent, ServerResponse, SessionRecord, + SessionRequest, SessionSnapshot, SessionSnapshotResponse, SnapshotResponse, + VisibleSnapshotResponse, +}; +use tempfile::tempdir; + +use support::{FOCUSED_LEAF_ID, LEFT_LEAF_ID, SESSION_ID, demo_state, root_focus_state}; + +const SECOND_SESSION_ID: SessionId = SessionId(2); +const SECOND_ROOT_ID: NodeId = NodeId(200); +const SECOND_BUFFER_ID: BufferId = BufferId(70); + +fn manager_from_source(source: &str) -> (ConfigManager, tempfile::TempDir) { + let tempdir = tempdir().unwrap(); + let config_path = tempdir.path().join("config.rhai"); + fs::write(&config_path, source).unwrap(); + ( + ConfigManager::load( + ConfigDiscoveryOptions::default().with_project_config_dir(tempdir.path()), + ) + .unwrap(), + tempdir, + ) +} + +fn session_snapshot_from_state( + state: &embers_client::ClientState, + session_id: SessionId, +) -> SessionSnapshot { + let session = state.sessions.get(&session_id).unwrap().clone(); + let nodes = state + .nodes + .values() + .filter(|node| node.session_id == session_id) + .cloned() + .collect::>(); + let node_ids = nodes + .iter() + .map(|node| node.id) + .collect::>(); + let buffers = state + .buffers + .values() + .filter(|buffer| { + buffer + .attachment_node_id + .is_some_and(|node_id| node_ids.contains(&node_id)) + }) + .cloned() + .collect::>(); + let floating = state + .floating + .values() + .filter(|floating| floating.session_id == session_id) + .cloned() + .collect::>(); + SessionSnapshot { + session, + nodes, + buffers, + floating, + } +} + +fn visible_snapshot_from_state( + state: &embers_client::ClientState, + buffer_id: BufferId, + request_id: RequestId, +) -> VisibleSnapshotResponse { + let mut snapshot = state.snapshots.get(&buffer_id).unwrap().clone(); + snapshot.request_id = request_id; + snapshot +} + +fn scrollback_slice_response( + buffer_id: BufferId, + request_id: RequestId, + start_line: u64, + total_lines: u64, + lines: &[&str], +) -> ScrollbackSliceResponse { + ScrollbackSliceResponse { + request_id, + buffer_id, + start_line, + total_lines, + lines: lines.iter().map(|line| (*line).to_owned()).collect(), + } +} + +fn snapshot_response( + buffer_id: BufferId, + request_id: RequestId, + lines: &[&str], +) -> SnapshotResponse { + SnapshotResponse { + request_id, + buffer_id, + sequence: 1, + size: embers_core::PtySize::new(80, 24), + lines: lines.iter().map(|line| (*line).to_owned()).collect(), + title: None, + cwd: None, + } +} + +fn second_session_state() -> embers_client::ClientState { + let mut state = demo_state(); + state.sessions.insert( + SECOND_SESSION_ID, + SessionRecord { + id: SECOND_SESSION_ID, + name: "other".to_owned(), + root_node_id: SECOND_ROOT_ID, + floating_ids: Vec::new(), + focused_leaf_id: Some(SECOND_ROOT_ID), + focused_floating_id: None, + }, + ); + state.nodes.insert( + SECOND_ROOT_ID, + NodeRecord { + id: SECOND_ROOT_ID, + session_id: SECOND_SESSION_ID, + parent_id: None, + kind: NodeRecordKind::BufferView, + buffer_view: Some(BufferViewRecord { + buffer_id: SECOND_BUFFER_ID, + focused: true, + zoomed: false, + follow_output: true, + last_render_size: PtySize::new(80, 20), + }), + split: None, + tabs: None, + }, + ); + state.buffers.insert( + SECOND_BUFFER_ID, + BufferRecord { + id: SECOND_BUFFER_ID, + title: "other pane".to_owned(), + command: vec!["/bin/sh".to_owned()], + cwd: None, + state: BufferRecordState::Running, + pid: None, + attachment_node_id: Some(SECOND_ROOT_ID), + pty_size: PtySize::new(80, 20), + activity: ActivityState::Idle, + last_snapshot_seq: 1, + exit_code: None, + env: BTreeMap::new(), + }, + ); + state.snapshots.insert( + SECOND_BUFFER_ID, + VisibleSnapshotResponse { + request_id: RequestId(0), + buffer_id: SECOND_BUFFER_ID, + sequence: 1, + size: PtySize::new(80, 20), + lines: vec!["other pane".to_owned()], + title: Some("other pane".to_owned()), + cwd: None, + viewport_top_line: 0, + total_lines: 1, + alternate_screen: false, + mouse_reporting: false, + focus_reporting: false, + bracketed_paste: false, + cursor: None, + }, + ); + state +} + +#[tokio::test] +async fn configured_keybinding_executes_live_focus_action() { + let transport = ScriptedTransport::default(); + transport.push_exchange( + ClientMessage::Node(NodeRequest::Focus { + request_id: RequestId(1), + session_id: SESSION_ID, + node_id: LEFT_LEAF_ID, + }), + ServerResponse::Ok(OkResponse { + request_id: RequestId(1), + }), + ); + let focused_state = root_focus_state(); + transport.push_exchange( + ClientMessage::Session(SessionRequest::Get { + request_id: RequestId(2), + session_id: SESSION_ID, + }), + ServerResponse::SessionSnapshot(SessionSnapshotResponse { + request_id: RequestId(2), + snapshot: session_snapshot_from_state(&focused_state, SESSION_ID), + }), + ); + + let mut client = MuxClient::new(transport.clone()); + *client.state_mut() = demo_state(); + let (config, _tempdir) = manager_from_source( + r#" + fn move_left(ctx) { action.focus_left() } + define_action("move-left", move_left); + bind("normal", "", "move-left"); + "#, + ); + let mut configured = ConfiguredClient::new(client, config); + + configured + .handle_key( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + KeyEvent::Ctrl('h'), + ) + .await + .unwrap(); + + assert_eq!( + configured + .client() + .state() + .sessions + .get(&SESSION_ID) + .and_then(|session| session.focused_leaf_id), + Some(LEFT_LEAF_ID) + ); + assert!(configured.notifications().is_empty()); + transport.assert_exhausted().unwrap(); +} + +#[tokio::test] +async fn configured_render_uses_scripted_tab_bars() { + let client = MuxClient::new(FakeTransport::default()); + let (config, _tempdir) = manager_from_source( + r##" + fn format_tabs(ctx) { + let tabs = ctx.tabs(); + let active = tabs[ctx.active_index()]; + if ctx.is_root() { + ui.bar([ui.segment("ROOT " + active.title())], [], []) + } else { + ui.bar([ui.segment("NESTED " + active.title())], [], []) + } + } + + tabbar.set_formatter(format_tabs); + "##, + ); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = demo_state(); + + let grid = configured + .render_session( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + ) + .await + .unwrap(); + let rendered = grid.render(); + + assert!(rendered.contains("ROOT workspace")); + assert!(rendered.contains("NESTED logs-long-title")); +} + +#[tokio::test] +async fn reload_updates_live_bindings() { + let tempdir = tempdir().unwrap(); + let config_path = tempdir.path().join("config.rhai"); + fs::write( + &config_path, + r#" + fn notify_left(ctx) { action.notify("info", "left") } + define_action("notify-left", notify_left); + bind("normal", "", "notify-left"); + "#, + ) + .unwrap(); + let config = ConfigManager::load( + ConfigDiscoveryOptions::default().with_project_config_dir(tempdir.path()), + ) + .unwrap(); + let client = MuxClient::new(FakeTransport::default()); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = demo_state(); + + fs::write( + &config_path, + r#" + fn notify_right(ctx) { action.notify("info", "right") } + define_action("notify-right", notify_right); + bind("normal", "", "notify-right"); + "#, + ) + .unwrap(); + configured.reload_config().unwrap(); + configured + .handle_key( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + KeyEvent::Ctrl('h'), + ) + .await + .unwrap(); + + assert_eq!(configured.notifications(), ["right"]); +} + +#[tokio::test] +async fn paste_events_wrap_bytes_for_bracketed_paste_buffers() { + let transport = FakeTransport::default(); + transport.push_response(ServerResponse::Ok(OkResponse { + request_id: RequestId(1), + })); + + let mut state = demo_state(); + state + .snapshots + .get_mut(&BufferId(4)) + .unwrap() + .bracketed_paste = true; + + transport.push_response(ServerResponse::VisibleSnapshot( + visible_snapshot_from_state(&state, BufferId(4), RequestId(2)), + )); + transport.push_response(ServerResponse::SessionSnapshot(SessionSnapshotResponse { + request_id: RequestId(3), + snapshot: session_snapshot_from_state(&state, SESSION_ID), + })); + + let client = MuxClient::new(transport.clone()); + let (config, _tempdir) = manager_from_source(""); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = state; + + configured + .handle_paste( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + b"hello world".to_vec(), + ) + .await + .unwrap(); + + assert_eq!( + transport.requests()[0], + ClientMessage::Input(InputRequest::Send { + request_id: RequestId(1), + buffer_id: BufferId(4), + bytes: b"\x1b[200~hello world\x1b[201~".to_vec(), + }) + ); +} + +#[tokio::test] +async fn focus_events_forward_when_program_requested_them() { + let transport = FakeTransport::default(); + transport.push_response(ServerResponse::Ok(OkResponse { + request_id: RequestId(1), + })); + + let mut state = demo_state(); + state + .snapshots + .get_mut(&BufferId(4)) + .unwrap() + .focus_reporting = true; + + transport.push_response(ServerResponse::VisibleSnapshot( + visible_snapshot_from_state(&state, BufferId(4), RequestId(2)), + )); + transport.push_response(ServerResponse::SessionSnapshot(SessionSnapshotResponse { + request_id: RequestId(3), + snapshot: session_snapshot_from_state(&state, SESSION_ID), + })); + + let client = MuxClient::new(transport.clone()); + let (config, _tempdir) = manager_from_source(""); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = state; + + configured + .handle_focus_event( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + true, + ) + .await + .unwrap(); + + assert_eq!( + transport.requests()[0], + ClientMessage::Input(InputRequest::Send { + request_id: RequestId(1), + buffer_id: BufferId(4), + bytes: b"\x1b[I".to_vec(), + }) + ); +} + +#[tokio::test] +async fn focus_events_are_ignored_when_program_did_not_request_them() { + let client = MuxClient::new(FakeTransport::default()); + let (config, _tempdir) = manager_from_source(""); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = demo_state(); + + configured + .handle_focus_event( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + true, + ) + .await + .unwrap(); + + assert!(configured.client().transport().requests().is_empty()); +} + +#[tokio::test] +async fn page_up_scrolls_locally_with_scrollback_slices() { + let transport = FakeTransport::default(); + transport.push_response(ServerResponse::ScrollbackSlice(scrollback_slice_response( + BufferId(4), + RequestId(1), + 12, + 60, + &["history line", "match line"], + ))); + + let mut state = demo_state(); + let snapshot = state.snapshots.get_mut(&BufferId(4)).unwrap(); + snapshot.total_lines = 60; + snapshot.viewport_top_line = 36; + snapshot.lines = vec!["tail one".to_owned(), "tail two".to_owned()]; + let view = state.view_state_mut(FOCUSED_LEAF_ID).unwrap(); + view.total_line_count = 60; + view.scroll_top_line = 36; + view.follow_output = true; + + let client = MuxClient::new(transport.clone()); + let (config, _tempdir) = manager_from_source(""); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = state; + + configured + .handle_key( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + KeyEvent::PageUp, + ) + .await + .unwrap(); + + let view = configured + .client() + .state() + .view_state(FOCUSED_LEAF_ID) + .expect("focused view state"); + assert_eq!(view.scroll_top_line, 12); + assert!(!view.follow_output); + assert_eq!(view.visible_lines[0], "history line"); + assert!(matches!( + transport.requests()[0], + ClientMessage::Buffer(embers_protocol::BufferRequest::ScrollbackSlice { + buffer_id: BufferId(4), + start_line: 12, + .. + }) + )); +} + +#[tokio::test] +async fn search_prompt_commits_matches_and_navigates_locally() { + let transport = FakeTransport::default(); + transport.push_response(ServerResponse::Snapshot(snapshot_response( + BufferId(4), + RequestId(1), + &["alpha", "needle here", "tail needle"], + ))); + + let client = MuxClient::new(transport); + let (config, _tempdir) = manager_from_source(""); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = demo_state(); + configured + .client_mut() + .state_mut() + .view_state_mut(FOCUSED_LEAF_ID) + .unwrap() + .follow_output = false; + + configured + .handle_key( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + KeyEvent::Char('/'), + ) + .await + .unwrap(); + for ch in "needle".chars() { + configured + .handle_key( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + KeyEvent::Char(ch), + ) + .await + .unwrap(); + } + configured + .handle_key( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + KeyEvent::Enter, + ) + .await + .unwrap(); + + let view = configured + .client() + .state() + .view_state(FOCUSED_LEAF_ID) + .expect("focused view state"); + let search = view.search_state.as_ref().expect("search state"); + assert_eq!(search.query, "needle"); + assert_eq!(search.matches.len(), 2); + assert_eq!(search.active_match_index, Some(0)); + + configured + .handle_key( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + KeyEvent::Char('n'), + ) + .await + .unwrap(); + assert_eq!( + configured + .client() + .state() + .view_state(FOCUSED_LEAF_ID) + .and_then(|view| view.search_state.as_ref()) + .and_then(|search| search.active_match_index), + Some(1) + ); + + let grid = configured + .render_session( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + ) + .await + .unwrap(); + assert!( + grid.ansi_lines() + .iter() + .any(|line| line.contains("\x1b[4m")) + ); +} + +#[tokio::test] +async fn pasted_text_updates_search_prompt_without_forwarding_input() { + let client = MuxClient::new(FakeTransport::default()); + let (config, _tempdir) = manager_from_source(""); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = demo_state(); + configured + .client_mut() + .state_mut() + .view_state_mut(FOCUSED_LEAF_ID) + .unwrap() + .follow_output = false; + + configured + .handle_key( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + KeyEvent::Char('/'), + ) + .await + .unwrap(); + configured + .handle_paste( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + b"needle".to_vec(), + ) + .await + .unwrap(); + + assert!(configured.client().transport().requests().is_empty()); + assert_eq!( + configured.status_line(SESSION_ID, Path::new("/tmp/embers.sock")), + "[demo] /needle" + ); +} + +#[tokio::test] +async fn select_mode_yanks_selection_to_osc52() { + let transport = FakeTransport::default(); + transport.push_response(ServerResponse::Snapshot(snapshot_response( + BufferId(4), + RequestId(1), + &["logs visible", "second row"], + ))); + + let client = MuxClient::new(transport); + let (config, _tempdir) = manager_from_source(""); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = demo_state(); + configured + .client_mut() + .state_mut() + .view_state_mut(FOCUSED_LEAF_ID) + .unwrap() + .follow_output = false; + + configured + .handle_key( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + KeyEvent::Char('v'), + ) + .await + .unwrap(); + configured + .handle_key( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + KeyEvent::Char('l'), + ) + .await + .unwrap(); + configured + .handle_key( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + KeyEvent::Char('y'), + ) + .await + .unwrap(); + + let output = configured.drain_terminal_output(); + assert_eq!(output.len(), 1); + let osc52 = String::from_utf8(output[0].clone()).unwrap(); + assert!(osc52.starts_with("\x1b]52;c;")); + assert!(osc52.contains("bG8=")); + assert!( + configured + .client() + .state() + .view_state(FOCUSED_LEAF_ID) + .and_then(|view| view.selection_state.as_ref()) + .is_none() + ); +} + +#[tokio::test] +async fn wheel_mouse_events_scroll_locally_or_forward_to_program() { + let mut initial_state = demo_state(); + let presentation = PresentationModel::project( + &initial_state, + SESSION_ID, + Size { + width: 80, + height: 20, + }, + ) + .unwrap(); + let focused = presentation.focused_leaf().unwrap().clone(); + + let local_transport = FakeTransport::default(); + local_transport.push_response(ServerResponse::ScrollbackSlice(scrollback_slice_response( + BufferId(4), + RequestId(1), + 33, + 60, + &["older output"], + ))); + initial_state + .snapshots + .get_mut(&BufferId(4)) + .unwrap() + .total_lines = 60; + initial_state + .snapshots + .get_mut(&BufferId(4)) + .unwrap() + .viewport_top_line = 36; + let view = initial_state.view_state_mut(FOCUSED_LEAF_ID).unwrap(); + view.total_line_count = 60; + view.scroll_top_line = 36; + view.follow_output = true; + let client = MuxClient::new(local_transport.clone()); + let (config, _tempdir) = manager_from_source(""); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = initial_state.clone(); + configured + .handle_mouse( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + MouseEvent { + row: (focused.rect.origin.y + 1) as u16, + column: focused.rect.origin.x as u16, + modifiers: MouseModifiers::default(), + kind: MouseEventKind::WheelUp, + }, + ) + .await + .unwrap(); + assert!(matches!( + local_transport.requests()[0], + ClientMessage::Buffer(embers_protocol::BufferRequest::ScrollbackSlice { + start_line: 33, + .. + }) + )); + + let forward_transport = FakeTransport::default(); + forward_transport.push_response(ServerResponse::Ok(OkResponse { + request_id: RequestId(1), + })); + forward_transport.push_response(ServerResponse::VisibleSnapshot( + visible_snapshot_from_state(&initial_state, BufferId(4), RequestId(2)), + )); + forward_transport.push_response(ServerResponse::SessionSnapshot(SessionSnapshotResponse { + request_id: RequestId(3), + snapshot: session_snapshot_from_state(&initial_state, SESSION_ID), + })); + let client = MuxClient::new(forward_transport.clone()); + let (config, _tempdir) = manager_from_source(""); + let mut configured = ConfiguredClient::new(client, config); + let mut state = initial_state; + state + .snapshots + .get_mut(&BufferId(4)) + .unwrap() + .mouse_reporting = true; + *configured.client_mut().state_mut() = state; + configured + .handle_mouse( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + MouseEvent { + row: (focused.rect.origin.y + 1) as u16, + column: focused.rect.origin.x as u16, + modifiers: MouseModifiers::default(), + kind: MouseEventKind::WheelUp, + }, + ) + .await + .unwrap(); + assert_eq!( + forward_transport.requests()[0], + ClientMessage::Input(InputRequest::Send { + request_id: RequestId(1), + buffer_id: BufferId(4), + bytes: b"\x1b[<64;1;1M".to_vec(), + }) + ); +} + +#[tokio::test] +async fn title_row_mouse_events_do_not_forward_to_programs() { + let mut state = demo_state(); + state + .snapshots + .get_mut(&BufferId(4)) + .unwrap() + .mouse_reporting = true; + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 80, + height: 20, + }, + ) + .unwrap(); + let focused = presentation.focused_leaf().unwrap().clone(); + + let transport = FakeTransport::default(); + let client = MuxClient::new(transport.clone()); + let (config, _tempdir) = manager_from_source(""); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = state; + configured + .handle_mouse( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + MouseEvent { + row: focused.rect.origin.y as u16, + column: focused.rect.origin.x as u16, + modifiers: MouseModifiers::default(), + kind: MouseEventKind::Press(MouseButton::Left), + }, + ) + .await + .unwrap(); + + assert!(transport.requests().is_empty()); +} + +#[tokio::test] +async fn content_row_mouse_events_forward_with_content_relative_coordinates() { + let mut state = demo_state(); + state + .snapshots + .get_mut(&BufferId(4)) + .unwrap() + .mouse_reporting = true; + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 80, + height: 20, + }, + ) + .unwrap(); + let focused = presentation.focused_leaf().unwrap().clone(); + + let transport = FakeTransport::default(); + transport.push_response(ServerResponse::Ok(OkResponse { + request_id: RequestId(1), + })); + transport.push_response(ServerResponse::VisibleSnapshot( + visible_snapshot_from_state(&state, BufferId(4), RequestId(2)), + )); + transport.push_response(ServerResponse::SessionSnapshot(SessionSnapshotResponse { + request_id: RequestId(3), + snapshot: session_snapshot_from_state(&state, SESSION_ID), + })); + let client = MuxClient::new(transport.clone()); + let (config, _tempdir) = manager_from_source(""); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = state; + configured + .handle_mouse( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + MouseEvent { + row: (focused.rect.origin.y + 1) as u16, + column: focused.rect.origin.x as u16, + modifiers: MouseModifiers::default(), + kind: MouseEventKind::Press(MouseButton::Left), + }, + ) + .await + .unwrap(); + + assert_eq!( + transport.requests()[0], + ClientMessage::Input(InputRequest::Send { + request_id: RequestId(1), + buffer_id: BufferId(4), + bytes: b"\x1b[<0;1;1M".to_vec(), + }) + ); +} + +#[tokio::test] +async fn render_invalidated_events_use_their_buffer_session_context() { + let state = second_session_state(); + let transport = FakeTransport::default(); + transport.push_event(ServerEvent::RenderInvalidated(RenderInvalidatedEvent { + buffer_id: SECOND_BUFFER_ID, + })); + transport.push_response(ServerResponse::VisibleSnapshot( + visible_snapshot_from_state(&state, SECOND_BUFFER_ID, RequestId(1)), + )); + let client = MuxClient::new(transport); + let (config, _tempdir) = manager_from_source( + r#" + fn on_render(ctx) { action.notify("info", ctx.current_session().name()) } + on("render_invalidated", on_render); + "#, + ); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = state; + configured + .render_session( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + ) + .await + .unwrap(); + + let event = configured.process_next_event().await.unwrap(); + + assert!(matches!(event, ServerEvent::RenderInvalidated(_))); + assert_eq!(configured.notifications(), ["other"]); +} + +#[tokio::test] +async fn detached_buffer_events_do_not_fall_back_to_the_active_session() { + let transport = FakeTransport::default(); + transport.push_event(ServerEvent::BufferCreated(BufferCreatedEvent { + buffer: BufferRecord { + id: BufferId(71), + title: "detached".to_owned(), + command: vec!["/bin/sh".to_owned()], + cwd: None, + state: BufferRecordState::Running, + pid: None, + attachment_node_id: None, + pty_size: PtySize::new(80, 20), + activity: ActivityState::Idle, + last_snapshot_seq: 0, + exit_code: None, + env: BTreeMap::new(), + }, + })); + let client = MuxClient::new(transport); + let (config, _tempdir) = manager_from_source( + r#" + fn on_buffer(ctx) { + if ctx.current_session() == () { + action.notify("info", "none") + } else { + action.notify("info", ctx.current_session().name()) + } + } + on("buffer_created", on_buffer); + "#, + ); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = demo_state(); + configured + .render_session( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + ) + .await + .unwrap(); + + let event = configured.process_next_event().await.unwrap(); + + assert!(matches!(event, ServerEvent::BufferCreated(_))); + assert_eq!(configured.notifications(), ["none"]); +} + +#[tokio::test] +async fn disabling_wheel_scroll_in_config_suppresses_local_mouse_scrolling() { + let state = demo_state(); + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 80, + height: 20, + }, + ) + .unwrap(); + let focused = presentation.focused_leaf().unwrap().clone(); + let client = MuxClient::new(FakeTransport::default()); + let (config, _tempdir) = manager_from_source("mouse.set_wheel_scroll(false);"); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = state; + + configured + .handle_mouse( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + MouseEvent { + row: (focused.rect.origin.y + 1) as u16, + column: focused.rect.origin.x as u16, + modifiers: MouseModifiers::default(), + kind: MouseEventKind::WheelUp, + }, + ) + .await + .unwrap(); + + assert!(configured.client().transport().requests().is_empty()); +} + +#[tokio::test] +async fn event_hook_executes_real_actions() { + let transport = ScriptedTransport::default(); + let focused_state = root_focus_state(); + transport.push_event(ServerEvent::FocusChanged(FocusChangedEvent { + session_id: SESSION_ID, + focused_leaf_id: Some(FOCUSED_LEAF_ID), + focused_floating_id: None, + })); + transport.push_exchange( + ClientMessage::Node(NodeRequest::Focus { + request_id: RequestId(1), + session_id: SESSION_ID, + node_id: LEFT_LEAF_ID, + }), + ServerResponse::Ok(OkResponse { + request_id: RequestId(1), + }), + ); + transport.push_exchange( + ClientMessage::Session(SessionRequest::Get { + request_id: RequestId(2), + session_id: SESSION_ID, + }), + ServerResponse::SessionSnapshot(SessionSnapshotResponse { + request_id: RequestId(2), + snapshot: session_snapshot_from_state(&focused_state, SESSION_ID), + }), + ); + + let mut client = MuxClient::new(transport.clone()); + *client.state_mut() = demo_state(); + let (config, _tempdir) = manager_from_source( + r#" + fn on_focus(ctx) { action.focus_left() } + on("focus_changed", on_focus); + "#, + ); + let mut configured = ConfiguredClient::new(client, config); + configured + .render_session( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + ) + .await + .unwrap(); + + configured.process_next_event().await.unwrap(); + + assert_eq!( + configured + .client() + .state() + .sessions + .get(&SESSION_ID) + .and_then(|session| session.focused_leaf_id), + Some(LEFT_LEAF_ID) + ); + assert!(configured.notifications().is_empty()); + transport.assert_exhausted().unwrap(); +} + +#[tokio::test] +async fn keybinding_runtime_errors_become_notifications() { + let client = MuxClient::new(FakeTransport::default()); + let (config, _tempdir) = manager_from_source( + r#" + fn broken(ctx) { + let xs = []; + xs[1] + } + + define_action("broken", broken); + bind("normal", "", "broken"); + "#, + ); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = demo_state(); + + configured + .handle_key( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + KeyEvent::Ctrl('h'), + ) + .await + .unwrap(); + + assert_eq!(configured.notifications().len(), 1); +} + +#[tokio::test] +async fn recursive_named_actions_stop_at_expansion_limit() { + let client = MuxClient::new(FakeTransport::default()); + let (config, _tempdir) = manager_from_source( + r#" + fn alpha(ctx) { action.run_named_action("beta") } + fn beta(ctx) { action.run_named_action("alpha") } + + define_action("alpha", alpha); + define_action("beta", beta); + bind("normal", "a", "alpha"); + "#, + ); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = demo_state(); + + configured + .handle_key( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + KeyEvent::Char('a'), + ) + .await + .unwrap(); + + assert_eq!( + configured.notifications(), + ["invalid input: action expansion limit reached"] + ); +} + +#[tokio::test] +async fn event_handler_runtime_errors_do_not_crash_client() { + let transport = FakeTransport::default(); + transport.push_event(ServerEvent::FocusChanged(FocusChangedEvent { + session_id: SESSION_ID, + focused_leaf_id: Some(FOCUSED_LEAF_ID), + focused_floating_id: None, + })); + let client = MuxClient::new(transport); + let (config, _tempdir) = manager_from_source( + r#" + fn broken(ctx) { + let xs = []; + xs[1] + } + + on("focus_changed", broken); + "#, + ); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = demo_state(); + + let event = configured.process_next_event().await.unwrap(); + + assert!(matches!(event, ServerEvent::FocusChanged(_))); + assert_eq!(configured.notifications().len(), 1); +} + +#[tokio::test] +async fn formatter_failures_fall_back_to_default_rendering() { + let client = MuxClient::new(FakeTransport::default()); + let (config, _tempdir) = manager_from_source( + r#" + fn broken_bar(ctx) { 1 } + tabbar.set_formatter(broken_bar); + "#, + ); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = demo_state(); + + let grid = configured + .render_session( + SESSION_ID, + Size { + width: 80, + height: 20, + }, + ) + .await + .unwrap(); + let rendered = grid.render(); + + assert!(rendered.contains("workspace")); + assert_eq!(configured.notifications().len(), 1); +} + +#[tokio::test] +async fn event_hooks_can_notify_without_an_active_view() { + let transport = FakeTransport::default(); + transport.push_event(ServerEvent::FocusChanged(FocusChangedEvent { + session_id: SESSION_ID, + focused_leaf_id: Some(FOCUSED_LEAF_ID), + focused_floating_id: None, + })); + let client = MuxClient::new(transport); + let (config, _tempdir) = manager_from_source( + r#" + fn on_focus(ctx) { action.notify("info", "focus hook") } + on("focus_changed", on_focus); + "#, + ); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = demo_state(); + + let event = configured.process_next_event().await.unwrap(); + + assert!(matches!(event, ServerEvent::FocusChanged(_))); + assert_eq!(configured.notifications(), ["focus hook"]); +} + +#[tokio::test] +async fn event_context_keeps_session_without_an_active_view() { + let transport = FakeTransport::default(); + transport.push_event(ServerEvent::FocusChanged(FocusChangedEvent { + session_id: SESSION_ID, + focused_leaf_id: Some(FOCUSED_LEAF_ID), + focused_floating_id: None, + })); + let client = MuxClient::new(transport); + let (config, _tempdir) = manager_from_source( + r#" + fn on_focus(ctx) { + let session = ctx.current_session(); + if session == () { + action.notify("error", "missing") + } else { + action.notify("info", session.name()) + } + } + + on("focus_changed", on_focus); + "#, + ); + let mut configured = ConfiguredClient::new(client, config); + *configured.client_mut().state_mut() = demo_state(); + + let event = configured.process_next_event().await.unwrap(); + + assert!(matches!(event, ServerEvent::FocusChanged(_))); + assert_eq!(configured.notifications(), ["demo"]); +} diff --git a/crates/embers-client/tests/context.rs b/crates/embers-client/tests/context.rs new file mode 100644 index 0000000..c0c1608 --- /dev/null +++ b/crates/embers-client/tests/context.rs @@ -0,0 +1,62 @@ +mod support; + +use embers_client::{Context, PresentationModel, TabBarContext}; +use embers_core::Size; + +use support::{NESTED_TABS_ID, ROOT_SPLIT_ID, SESSION_ID, demo_state}; + +const TEST_SIZE: Size = Size { + width: 40, + height: 14, +}; + +#[test] +fn visible_nodes_include_layout_ancestors_without_direct_geometry() { + let state = demo_state(); + let presentation = + PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); + let context = Context::from_state(&state, Some(&presentation)); + + assert!( + context + .find_node(ROOT_SPLIT_ID) + .expect("split exists") + .visible + ); +} + +#[test] +fn tab_bar_context_reports_recursive_buffer_counts() { + let state = demo_state(); + let presentation = + PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); + + let root_tabs = presentation + .root_tabs + .as_ref() + .expect("root tabs are visible"); + let root_context = TabBarContext::from_frame(root_tabs, "normal", TEST_SIZE.width); + assert_eq!( + root_context + .tabs + .iter() + .map(|tab| tab.buffer_count) + .collect::>(), + vec![1, 3] + ); + + let nested_tabs = presentation + .tab_bars + .iter() + .find(|tabs| tabs.node_id == NESTED_TABS_ID) + .expect("nested tabs are visible"); + let nested_context = TabBarContext::from_frame(nested_tabs, "normal", TEST_SIZE.width); + assert_eq!( + nested_context + .tabs + .iter() + .map(|tab| tab.buffer_count) + .collect::>(), + vec![1, 1] + ); +} diff --git a/crates/embers-client/tests/controller.rs b/crates/embers-client/tests/controller.rs new file mode 100644 index 0000000..2d3e515 --- /dev/null +++ b/crates/embers-client/tests/controller.rs @@ -0,0 +1,188 @@ +mod support; + +use embers_client::{Controller, KeyEvent, PresentationModel}; +use embers_core::{RequestId, Size}; +use embers_protocol::{ClientMessage, FloatingRequest, InputRequest, NodeRequest}; + +use support::{ + FLOATING_ID, FOCUSED_BUFFER_ID, LEFT_LEAF_ID, NESTED_TABS_ID, ROOT_TABS_ID, SESSION_ID, + demo_state, floating_focused_state, root_focus_state, root_split_state, +}; + +const TEST_SIZE: Size = Size { + width: 40, + height: 14, +}; + +#[test] +fn ctrl_h_focuses_neighboring_leaf() { + let state = demo_state(); + let presentation = + PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); + + let request = Controller + .map_key(&presentation, RequestId(7), KeyEvent::Ctrl('h')) + .expect("focus request"); + + assert_eq!( + request, + ClientMessage::Node(NodeRequest::Focus { + request_id: RequestId(7), + session_id: SESSION_ID, + node_id: LEFT_LEAF_ID, + }) + ); +} + +#[test] +fn alt_digit_targets_deepest_visible_tabs_group() { + let state = demo_state(); + let presentation = + PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); + + let request = Controller + .map_key(&presentation, RequestId(8), KeyEvent::Alt('1')) + .expect("tab request"); + + assert_eq!( + request, + ClientMessage::Node(NodeRequest::SelectTab { + request_id: RequestId(8), + tabs_node_id: NESTED_TABS_ID, + index: 0, + }) + ); +} + +#[test] +fn alt_digit_targets_root_tabs_when_focus_is_not_nested() { + let state = root_focus_state(); + let presentation = + PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); + + let request = Controller + .map_key(&presentation, RequestId(9), KeyEvent::Alt('2')) + .expect("root tab request"); + + assert_eq!( + request, + ClientMessage::Node(NodeRequest::SelectTab { + request_id: RequestId(9), + tabs_node_id: ROOT_TABS_ID, + index: 1, + }) + ); +} + +#[test] +fn escape_closes_focused_popup() { + let state = floating_focused_state(); + let presentation = + PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); + + let request = Controller + .map_key(&presentation, RequestId(10), KeyEvent::Escape) + .expect("popup close request"); + + assert_eq!( + request, + ClientMessage::Floating(FloatingRequest::Close { + request_id: RequestId(10), + floating_id: FLOATING_ID, + }) + ); +} + +#[test] +fn plain_input_routes_to_focused_buffer() { + let state = demo_state(); + let presentation = + PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); + + let request = Controller + .map_key(&presentation, RequestId(11), KeyEvent::Char('x')) + .expect("input request"); + + assert_eq!( + request, + ClientMessage::Input(InputRequest::Send { + request_id: RequestId(11), + buffer_id: FOCUSED_BUFFER_ID, + bytes: vec![b'x'], + }) + ); +} + +#[test] +fn alt_digit_is_ignored_without_focused_tabs_context() { + let state = root_split_state(); + let presentation = + PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); + let buffer_id = presentation.focused_buffer_id().expect("focused buffer"); + + assert_eq!( + Controller.map_key(&presentation, RequestId(12), KeyEvent::Alt('1')), + Some(ClientMessage::Input(InputRequest::Send { + request_id: RequestId(12), + buffer_id, + bytes: vec![0x1b, b'1'], + })) + ); +} + +#[test] +fn alt_digit_falls_back_to_esc_prefixed_bytes_when_out_of_range() { + let state = root_focus_state(); + let presentation = + PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); + let buffer_id = presentation.focused_buffer_id().expect("focused buffer"); + + assert_eq!( + Controller.map_key(&presentation, RequestId(15), KeyEvent::Alt('9')), + Some(ClientMessage::Input(InputRequest::Send { + request_id: RequestId(15), + buffer_id, + bytes: vec![0x1b, b'9'], + })) + ); +} + +#[test] +fn unbound_ctrl_key_is_forwarded_to_the_focused_buffer() { + let state = demo_state(); + let presentation = + PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); + + let request = Controller + .map_key(&presentation, RequestId(13), KeyEvent::Ctrl('z')) + .expect("input request"); + + assert_eq!( + request, + ClientMessage::Input(InputRequest::Send { + request_id: RequestId(13), + buffer_id: FOCUSED_BUFFER_ID, + bytes: vec![0x1a], + }) + ); +} + +#[test] +fn unbound_alt_key_is_forwarded_to_the_focused_buffer() { + let state = demo_state(); + let presentation = + PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); + + let request = Controller + .map_key(&presentation, RequestId(14), KeyEvent::Alt('x')) + .expect("input request"); + + assert_eq!( + request, + ClientMessage::Input(InputRequest::Send { + request_id: RequestId(14), + buffer_id: FOCUSED_BUFFER_ID, + bytes: vec![0x1b, b'x'], + }) + ); +} diff --git a/crates/embers-client/tests/e2e.rs b/crates/embers-client/tests/e2e.rs new file mode 100644 index 0000000..bbd2506 --- /dev/null +++ b/crates/embers-client/tests/e2e.rs @@ -0,0 +1,684 @@ +use std::process::Output; +use std::time::Duration; + +use embers_client::{MuxClient, PresentationModel, Renderer}; +use embers_core::{ + ActivityState, BufferId, FloatGeometry, NodeId, SessionId, Size, SplitDirection, new_request_id, +}; +use embers_protocol::{ + BufferRequest, BufferResponse, BuffersResponse, ClientMessage, FloatingRequest, NodeRequest, + ServerResponse, SessionRequest, SessionSnapshot, +}; +use embers_test_support::{TestConnection, TestServer, cargo_bin}; + +fn run_cli(server: &TestServer, args: &[&str]) -> Output { + let output = cargo_bin("embers") + .arg("--socket") + .arg(server.socket_path()) + .args(args) + .output() + .expect("cli command runs"); + assert!( + output.status.success(), + "cli failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output +} + +fn stdout(output: &Output) -> String { + String::from_utf8(output.stdout.clone()).expect("stdout is utf-8") +} + +async fn create_session(connection: &mut TestConnection, name: &str) -> SessionSnapshot { + let response = connection + .request(&ClientMessage::Session(SessionRequest::Create { + request_id: new_request_id(), + name: name.to_owned(), + })) + .await + .expect("create session succeeds"); + match response { + ServerResponse::SessionSnapshot(response) => response.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + } +} + +async fn create_buffer( + connection: &mut TestConnection, + title: &str, +) -> embers_protocol::BufferRecord { + let response = connection + .request(&ClientMessage::Buffer(BufferRequest::Create { + request_id: new_request_id(), + title: Some(title.to_owned()), + command: vec!["/bin/sh".to_owned()], + cwd: None, + env: Default::default(), + })) + .await + .expect("create buffer succeeds"); + match response { + ServerResponse::Buffer(BufferResponse { buffer, .. }) => buffer, + other => panic!("expected buffer response, got {other:?}"), + } +} + +async fn session_snapshot_by_name(connection: &mut TestConnection, name: &str) -> SessionSnapshot { + let response = connection + .request(&ClientMessage::Session(SessionRequest::List { + request_id: new_request_id(), + })) + .await + .expect("list sessions succeeds"); + let session_id = match response { + ServerResponse::Sessions(response) => { + response + .sessions + .into_iter() + .find(|session| session.name == name) + .expect("session exists") + .id + } + other => panic!("expected sessions response, got {other:?}"), + }; + connection + .session_snapshot(session_id) + .await + .expect("session snapshot succeeds") +} + +fn node(snapshot: &SessionSnapshot, node_id: NodeId) -> &embers_protocol::NodeRecord { + snapshot + .nodes + .iter() + .find(|node| node.id == node_id) + .unwrap_or_else(|| panic!("node {node_id} missing from snapshot")) +} + +fn buffer_for_leaf(snapshot: &SessionSnapshot, leaf_id: NodeId) -> BufferId { + node(snapshot, leaf_id) + .buffer_view + .as_ref() + .unwrap_or_else(|| panic!("node {leaf_id} is not a leaf")) + .buffer_id +} + +fn session_id_by_name(client: &MuxClient, name: &str) -> SessionId { + client + .state() + .sessions + .values() + .find(|session| session.name == name) + .unwrap_or_else(|| panic!("session {name} missing from client state")) + .id +} + +async fn refresh_all_snapshots(client: &mut MuxClient) { + let buffer_ids = client.state().buffers.keys().copied().collect::>(); + for buffer_id in buffer_ids { + client + .refresh_buffer_snapshot(buffer_id) + .await + .unwrap_or_else(|error| panic!("refreshing snapshot for {buffer_id} failed: {error}")); + } +} + +async fn render_session( + client: &mut MuxClient, + session_name: &str, +) -> String { + client.resync_all_sessions().await.expect("resync succeeds"); + refresh_all_snapshots(client).await; + let session_id = session_id_by_name(client, session_name); + let model = PresentationModel::project( + client.state(), + session_id, + Size { + width: 80, + height: 24, + }, + ) + .expect("projection succeeds"); + Renderer.render(client.state(), &model).render() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn basic_cli_workflow_renders_split_output() { + let server = TestServer::start().await.expect("server starts"); + + run_cli(&server, &["new-session", "alpha"]); + run_cli( + &server, + &[ + "new-window", + "-t", + "alpha", + "--title", + "work", + "--", + "/bin/sh", + ], + ); + let split = run_cli(&server, &["split-window", "--", "/bin/sh"]); + let pane_id = stdout(&split) + .trim() + .parse::() + .expect("split-window returns pane id"); + + run_cli( + &server, + &[ + "send-keys", + "-t", + &pane_id.to_string(), + "--enter", + "printf", + "e2e-basic\\n", + ], + ); + + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("protocol connection"); + let snapshot = session_snapshot_by_name(&mut connection, "alpha").await; + let buffer_id = buffer_for_leaf(&snapshot, NodeId(pane_id)); + connection + .wait_for_capture_contains(buffer_id, "e2e-basic", Duration::from_secs(3)) + .await + .expect("pane output arrives"); + + let mut client = MuxClient::connect(server.socket_path()) + .await + .expect("client connects"); + let render = render_session(&mut client, "alpha").await; + assert!(render.contains("e2e-basic")); + + server.shutdown().await.expect("server shuts down"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn nested_tabs_switch_visible_output() { + let server = TestServer::start().await.expect("server starts"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("protocol connection"); + + let session = create_session(&mut connection, "alpha").await; + let buffer_a = create_buffer(&mut connection, "root").await; + let session = match connection + .request(&ClientMessage::Session(SessionRequest::AddRootTab { + request_id: new_request_id(), + session_id: session.session.id, + title: "work".to_owned(), + buffer_id: Some(buffer_a.id), + child_node_id: None, + })) + .await + .expect("add root tab succeeds") + { + ServerResponse::SessionSnapshot(response) => response.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let root_leaf = session + .session + .focused_leaf_id + .expect("root leaf is focused"); + + let buffer_b = create_buffer(&mut connection, "nested-one").await; + let session = match connection + .request(&ClientMessage::Node(NodeRequest::Split { + request_id: new_request_id(), + leaf_node_id: root_leaf, + direction: SplitDirection::Vertical, + new_buffer_id: buffer_b.id, + })) + .await + .expect("split succeeds") + { + ServerResponse::SessionSnapshot(response) => response.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let right_leaf = session + .session + .focused_leaf_id + .expect("new split leaf is focused"); + + let session = match connection + .request(&ClientMessage::Node(NodeRequest::WrapInTabs { + request_id: new_request_id(), + node_id: right_leaf, + title: "one".to_owned(), + })) + .await + .expect("wrap in tabs succeeds") + { + ServerResponse::SessionSnapshot(response) => response.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let tabs_node_id = node(&session, right_leaf) + .parent_id + .expect("wrapped leaf has tabs parent"); + + let buffer_c = create_buffer(&mut connection, "nested-two").await; + let session = match connection + .request(&ClientMessage::Node(NodeRequest::AddTab { + request_id: new_request_id(), + tabs_node_id, + title: "two".to_owned(), + buffer_id: Some(buffer_c.id), + child_node_id: None, + index: 1, + })) + .await + .expect("add nested tab succeeds") + { + ServerResponse::SessionSnapshot(response) => response.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let active_index = node(&session, tabs_node_id) + .tabs + .as_ref() + .expect("tabs payload") + .active; + if active_index != 1 { + let _ = connection + .request(&ClientMessage::Node(NodeRequest::SelectTab { + request_id: new_request_id(), + tabs_node_id, + index: 1, + })) + .await + .expect("select nested tab succeeds"); + } + + let _ = connection + .request(&ClientMessage::Input(embers_protocol::InputRequest::Send { + request_id: new_request_id(), + buffer_id: buffer_c.id, + bytes: b"printf nested-two\\n\r".to_vec(), + })) + .await + .expect("send to nested tab succeeds"); + connection + .wait_for_capture_contains(buffer_c.id, "nested-two", Duration::from_secs(3)) + .await + .expect("second nested tab outputs"); + + let mut client = MuxClient::connect(server.socket_path()) + .await + .expect("client connects"); + let render = render_session(&mut client, "alpha").await; + assert!(render.contains("nested-two")); + + let _ = connection + .request(&ClientMessage::Node(NodeRequest::SelectTab { + request_id: new_request_id(), + tabs_node_id, + index: 0, + })) + .await + .expect("select first nested tab succeeds"); + let _ = connection + .request(&ClientMessage::Input(embers_protocol::InputRequest::Send { + request_id: new_request_id(), + buffer_id: buffer_b.id, + bytes: b"printf nested-one\\n\r".to_vec(), + })) + .await + .expect("send to first nested tab succeeds"); + connection + .wait_for_capture_contains(buffer_b.id, "nested-one", Duration::from_secs(3)) + .await + .expect("first nested tab outputs"); + + let render = render_session(&mut client, "alpha").await; + assert!(render.contains("nested-one")); + assert!(!render.contains("nested-two")); + + server.shutdown().await.expect("server shuts down"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn popup_close_preserves_underlying_buffer() { + let server = TestServer::start().await.expect("server starts"); + + run_cli(&server, &["new-session", "alpha"]); + run_cli( + &server, + &[ + "new-window", + "-t", + "alpha", + "--title", + "work", + "--", + "/bin/sh", + ], + ); + + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("protocol connection"); + let snapshot = session_snapshot_by_name(&mut connection, "alpha").await; + let base_leaf = snapshot + .session + .focused_leaf_id + .expect("focused leaf exists"); + let base_buffer = buffer_for_leaf(&snapshot, base_leaf); + + run_cli( + &server, + &["send-keys", "--enter", "printf", "popup-base\\n"], + ); + connection + .wait_for_capture_contains(base_buffer, "popup-base", Duration::from_secs(3)) + .await + .expect("base pane captures output"); + + let created = run_cli( + &server, + &[ + "display-popup", + "-t", + "alpha", + "--title", + "scratch", + "--x", + "2", + "--y", + "1", + "--width", + "20", + "--height", + "6", + "--", + "/bin/sh", + ], + ); + let popup_id = stdout(&created) + .trim() + .parse::() + .expect("display-popup returns popup id"); + + run_cli(&server, &["kill-popup", "-t", &popup_id.to_string()]); + run_cli( + &server, + &["send-keys", "--enter", "printf", "popup-after-close\\n"], + ); + + let snapshot = session_snapshot_by_name(&mut connection, "alpha").await; + assert!(snapshot.floating.is_empty()); + connection + .wait_for_capture_contains(base_buffer, "popup-after-close", Duration::from_secs(3)) + .await + .expect("base pane survives popup lifecycle"); + + server.shutdown().await.expect("server shuts down"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn move_and_detach_workflows_preserve_running_buffers() { + let server = TestServer::start().await.expect("server starts"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("protocol connection"); + + let session = create_session(&mut connection, "alpha").await; + let buffer_a = create_buffer(&mut connection, "main").await; + let session = match connection + .request(&ClientMessage::Session(SessionRequest::AddRootTab { + request_id: new_request_id(), + session_id: session.session.id, + title: "main".to_owned(), + buffer_id: Some(buffer_a.id), + child_node_id: None, + })) + .await + .expect("add root tab succeeds") + { + ServerResponse::SessionSnapshot(response) => response.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let main_leaf = session.session.focused_leaf_id.expect("main leaf exists"); + + let _ = connection + .request(&ClientMessage::Input(embers_protocol::InputRequest::Send { + request_id: new_request_id(), + buffer_id: buffer_a.id, + bytes: b"printf moved-buffer\\n\r".to_vec(), + })) + .await + .expect("send input succeeds"); + connection + .wait_for_capture_contains(buffer_a.id, "moved-buffer", Duration::from_secs(3)) + .await + .expect("buffer output arrives"); + + let _ = connection + .request(&ClientMessage::Buffer(BufferRequest::Detach { + request_id: new_request_id(), + buffer_id: buffer_a.id, + })) + .await + .expect("detach succeeds"); + + let popup = match connection + .request(&ClientMessage::Floating(FloatingRequest::Create { + request_id: new_request_id(), + session_id: session.session.id, + root_node_id: None, + buffer_id: Some(buffer_a.id), + geometry: FloatGeometry::new(4, 2, 24, 8), + title: Some("moved".to_owned()), + focus: true, + close_on_empty: true, + })) + .await + .expect("create floating from detached buffer succeeds") + { + ServerResponse::Floating(response) => response.floating, + other => panic!("expected floating response, got {other:?}"), + }; + let _ = connection + .request(&ClientMessage::Input(embers_protocol::InputRequest::Send { + request_id: new_request_id(), + buffer_id: buffer_a.id, + bytes: b"printf moved-buffer-floating\\n\r".to_vec(), + })) + .await + .expect("send floating marker succeeds"); + connection + .wait_for_capture_contains(buffer_a.id, "moved-buffer-floating", Duration::from_secs(3)) + .await + .expect("buffer survives floating move"); + + let _ = connection + .request(&ClientMessage::Floating(FloatingRequest::Close { + request_id: new_request_id(), + floating_id: popup.id, + })) + .await + .expect("close floating succeeds"); + + let buffer_b = create_buffer(&mut connection, "target").await; + let session = match connection + .request(&ClientMessage::Session(SessionRequest::AddRootTab { + request_id: new_request_id(), + session_id: session.session.id, + title: "target".to_owned(), + buffer_id: Some(buffer_b.id), + child_node_id: None, + })) + .await + .expect("add target window succeeds") + { + ServerResponse::SessionSnapshot(response) => response.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let target_leaf = session.session.focused_leaf_id.expect("target leaf exists"); + + let _ = connection + .request(&ClientMessage::Node(NodeRequest::MoveBufferToNode { + request_id: new_request_id(), + buffer_id: buffer_a.id, + target_leaf_node_id: target_leaf, + })) + .await + .expect("reattach detached buffer succeeds"); + let _ = connection + .request(&ClientMessage::Input(embers_protocol::InputRequest::Send { + request_id: new_request_id(), + buffer_id: buffer_a.id, + bytes: b"printf moved-buffer-reattach\\n\r".to_vec(), + })) + .await + .expect("send reattach marker succeeds"); + connection + .wait_for_capture_contains(buffer_a.id, "moved-buffer-reattach", Duration::from_secs(3)) + .await + .expect("buffer survives reattach"); + + let detached = match connection + .request(&ClientMessage::Buffer(BufferRequest::List { + request_id: new_request_id(), + session_id: None, + attached_only: false, + detached_only: true, + })) + .await + .expect("list detached buffers succeeds") + { + ServerResponse::Buffers(BuffersResponse { buffers, .. }) => buffers, + other => panic!("expected buffers response, got {other:?}"), + }; + assert!(detached.iter().any(|buffer| buffer.id == buffer_b.id)); + assert_ne!(main_leaf, target_leaf); + + server.shutdown().await.expect("server shuts down"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn hidden_activity_is_visible_and_reconnect_rehydrates_state() { + let server = TestServer::start().await.expect("server starts"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("protocol connection"); + + let session = create_session(&mut connection, "alpha").await; + let buffer_a = create_buffer(&mut connection, "main").await; + let session = match connection + .request(&ClientMessage::Session(SessionRequest::AddRootTab { + request_id: new_request_id(), + session_id: session.session.id, + title: "main".to_owned(), + buffer_id: Some(buffer_a.id), + child_node_id: None, + })) + .await + .expect("add root tab succeeds") + { + ServerResponse::SessionSnapshot(response) => response.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let main_leaf = session.session.focused_leaf_id.expect("main leaf exists"); + + let session = match connection + .request(&ClientMessage::Node(NodeRequest::WrapInTabs { + request_id: new_request_id(), + node_id: main_leaf, + title: "main".to_owned(), + })) + .await + .expect("wrap main leaf in tabs succeeds") + { + ServerResponse::SessionSnapshot(response) => response.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let nested_tabs_id = node(&session, main_leaf) + .parent_id + .expect("wrapped main leaf has tabs parent"); + + let buffer_b = create_buffer(&mut connection, "hidden").await; + let _ = connection + .request(&ClientMessage::Node(NodeRequest::AddTab { + request_id: new_request_id(), + tabs_node_id: nested_tabs_id, + title: "bg".to_owned(), + buffer_id: Some(buffer_b.id), + child_node_id: None, + index: 1, + })) + .await + .expect("add hidden tab succeeds"); + let _ = connection + .request(&ClientMessage::Node(NodeRequest::SelectTab { + request_id: new_request_id(), + tabs_node_id: nested_tabs_id, + index: 0, + })) + .await + .expect("select visible tab succeeds"); + + let _ = connection + .request(&ClientMessage::Input(embers_protocol::InputRequest::Send { + request_id: new_request_id(), + buffer_id: buffer_b.id, + bytes: b"printf hidden-activity\\n\r".to_vec(), + })) + .await + .expect("send to hidden buffer succeeds"); + connection + .wait_for_capture_contains(buffer_b.id, "hidden-activity", Duration::from_secs(3)) + .await + .expect("hidden buffer captures output"); + + let mut first_client = MuxClient::connect(server.socket_path()) + .await + .expect("first client connects"); + first_client + .resync_all_sessions() + .await + .expect("first client resyncs"); + refresh_all_snapshots(&mut first_client).await; + let session_id = session_id_by_name(&first_client, "alpha"); + let model = PresentationModel::project( + first_client.state(), + session_id, + Size { + width: 80, + height: 24, + }, + ) + .expect("projection succeeds"); + let tabs = model + .tab_bars + .iter() + .find(|tabs| tabs.node_id == nested_tabs_id) + .expect("nested tabs frame exists"); + assert!( + tabs.tabs + .iter() + .any(|tab| tab.title == "bg" && tab.activity != ActivityState::Idle) + ); + + drop(first_client); + + let _ = connection + .request(&ClientMessage::Node(NodeRequest::SelectTab { + request_id: new_request_id(), + tabs_node_id: nested_tabs_id, + index: 1, + })) + .await + .expect("select hidden tab succeeds"); + + let mut second_client = MuxClient::connect(server.socket_path()) + .await + .expect("second client connects"); + let render = render_session(&mut second_client, "alpha").await; + assert!(render.contains("hidden-activity")); + + server.shutdown().await.expect("server shuts down"); +} diff --git a/crates/embers-client/tests/fixtures/repository_config.rhai b/crates/embers-client/tests/fixtures/repository_config.rhai new file mode 100644 index 0000000..541b5ec --- /dev/null +++ b/crates/embers-client/tests/fixtures/repository_config.rhai @@ -0,0 +1,361 @@ +// Embers config derived from tmux settings in: +// project/nix-config/home/base/shell/default.nix +// +// Notes: +// - This mirrors the tmux leader, split/navigation keys, popup helpers, bell +// handling, history helpers, and a Catppuccin Frappe-inspired tab bar. +// - History helpers use Embers' buffer snapshot/history APIs rather than tmux's +// capture-pane commands. +// - Tab bars are node-scoped, so one formatter is shared by root and nested tabs. + +set_leader(""); + +fn current_cwd(ctx) { + let buffer = ctx.current_buffer(); + if buffer == () { + () + } else { + buffer.cwd() + } +} + +fn shell_tree(ctx, tree_api, title) { + tree_api.buffer_spawn( + ["/usr/bin/env", "zsh"], + #{ + title: title, + cwd: current_cwd(ctx) + } + ) +} + +fn command_tree(ctx, tree_api, title, command) { + tree_api.buffer_spawn( + command, + #{ + title: title, + cwd: current_cwd(ctx) + } + ) +} + +fn history_tree(ctx, tree_api, title, visible_only) { + let buffer = ctx.current_buffer(); + if buffer == () { + return shell_tree(ctx, tree_api, title); + } + + let history = if visible_only { + buffer.snapshot_text(200) + } else { + buffer.history_text() + }; + + tree_api.buffer_spawn( + ["/bin/sh", "-lc", "printf '%s' \"$EMBERS_HISTORY\" | less -R"], + #{ + title: title, + cwd: current_cwd(ctx), + env: #{ + EMBERS_HISTORY: history + } + } + ) +} + +fn popup(action_api, tree_spec, title, x, y, width, height) { + action_api.open_floating( + tree_spec, + #{ + title: title, + x: x, + y: y, + width: width, + height: height + } + ) +} + +fn is_nvim_buffer(ctx) { + let buffer = ctx.current_buffer(); + let node = ctx.current_node(); + if buffer == () || node == () { + return false; + } + + let name = buffer.process_name(); + if name == () { + return false; + } + + buffer.is_visible() + && !buffer.is_detached() + && node.kind() == "buffer_view" + && (name == "nvim" || name == "vim" || name == "nvr") +} + +fn smart_nav_left(ctx) { + if is_nvim_buffer(ctx) { + action.send_bytes_current([8]) + } else { + action.focus_left() + } +} + +fn smart_nav_down(ctx) { + if is_nvim_buffer(ctx) { + action.send_bytes_current([10]) + } else { + action.focus_down() + } +} + +fn smart_nav_up(ctx) { + if is_nvim_buffer(ctx) { + action.send_bytes_current([11]) + } else { + action.focus_up() + } +} + +fn smart_nav_right(ctx) { + if is_nvim_buffer(ctx) { + action.send_bytes_current([12]) + } else { + action.focus_right() + } +} + +fn smart_resize_left(ctx) { + if is_nvim_buffer(ctx) { + action.send_bytes_current([27, 104]) + } else { + action.resize_left(3) + } +} + +fn smart_resize_down(ctx) { + if is_nvim_buffer(ctx) { + action.send_bytes_current([27, 106]) + } else { + action.resize_down(3) + } +} + +fn smart_resize_up(ctx) { + if is_nvim_buffer(ctx) { + action.send_bytes_current([27, 107]) + } else { + action.resize_up(3) + } +} + +fn smart_resize_right(ctx) { + if is_nvim_buffer(ctx) { + action.send_bytes_current([27, 108]) + } else { + action.resize_right(3) + } +} + +fn split_below(ctx) { + // tmux split-window -v creates a top/bottom split. + action.split_with("horizontal", shell_tree(ctx, tree, "shell")) +} + +fn split_right(ctx) { + // tmux split-window -h creates a left/right split. + action.split_with("vertical", shell_tree(ctx, tree, "shell")) +} + +fn new_shell_tab(ctx) { + action.insert_tab_after_current("shell", shell_tree(ctx, tree, "shell")) +} + +fn new_yazi_tab(ctx) { + action.insert_tab_after_current( + "yazi", + command_tree(ctx, tree, "yazi", ["/usr/bin/env", "yazi"]) + ) +} + +fn shell_popup(ctx) { + popup(action, shell_tree(ctx, tree, "shell"), "shell", 8, 2, 100, 28) +} + +fn lazygit_popup(ctx) { + popup( + action, + command_tree(ctx, tree, "lazygit", ["/usr/bin/env", "lazygit"]), + "lazygit", + 4, + 1, + 110, + 32 + ) +} + +fn git_log_popup(ctx) { + popup( + action, + command_tree(ctx, tree, "git-log", ["/bin/sh", "-lc", "git log | nvim -R -"]), + "git log", + 4, + 1, + 110, + 32 + ) +} + +fn visible_history_tab(ctx) { + action.insert_tab_after_current( + "visible-history", + history_tree(ctx, tree, "visible-history", true) + ) +} + +fn full_history_tab(ctx) { + action.insert_tab_after_current( + "full-history", + history_tree(ctx, tree, "full-history", false) + ) +} + +fn scratchpad(ctx) { + popup( + action, + tree.tabs_with_active([ + tree.tab("shell", shell_tree(ctx, tree, "scratch")), + tree.tab( + "tools", + tree.split_v([ + command_tree(ctx, tree, "yazi", ["/usr/bin/env", "yazi"]), + shell_tree(ctx, tree, "tools") + ]) + ) + ], 0), + "scratchpad", + 10, + 3, + 100, + 28 + ) +} + +fn format_tabs(ctx) { + let left = []; + let active_bg = theme.color("active_bg"); + let active_fg = theme.color("active_fg"); + let inactive_bg = theme.color("inactive_bg"); + let inactive_fg = theme.color("inactive_fg"); + let urgent_fg = theme.color("urgent_fg"); + + for tab in ctx.tabs() { + let title = " " + tab.title() + " "; + if tab.is_active() { + left.push(ui.segment("î‚¶", #{ fg: active_bg })); + left.push(ui.segment(title, #{ + fg: active_fg, + bg: active_bg + })); + left.push(ui.segment("î‚´", #{ fg: active_bg })); + } else { + left.push(ui.segment("î‚¶", #{ fg: inactive_bg })); + left.push(ui.segment(title, #{ + fg: inactive_fg, + bg: inactive_bg + })); + left.push(ui.segment("î‚´", #{ fg: inactive_bg })); + } + + if tab.has_urgent() { + left.push(ui.segment(" ! ", #{ fg: urgent_fg })); + } + + left.push(ui.segment(" ")); + } + + ui.bar(left, [], []) +} + +fn bell_handler(ctx) { + let event = ctx.event(); + if event == () || event.name() != "buffer_bell" { + return; + } + + let buffer_id = event.buffer_id(); + if buffer_id == () { + return; + } + + let buffer = ctx.find_buffer(buffer_id); + if buffer == () || buffer.is_visible() { + return; + } + + action.move_buffer_to_floating( + buffer.id(), + #{ + title: buffer.title(), + x: 4, + y: 1, + width: 110, + height: 32, + focus: true + } + ) +} + +theme.set_palette(#{ + active_bg: "#ca9ee6", + active_fg: "#303446", + inactive_bg: "#51576d", + inactive_fg: "#c6d0f5", + urgent_fg: "#e78284" +}); + +tabbar.set_formatter(format_tabs); + +define_action("smart-nav-left", smart_nav_left); +define_action("smart-nav-down", smart_nav_down); +define_action("smart-nav-up", smart_nav_up); +define_action("smart-nav-right", smart_nav_right); +define_action("smart-resize-left", smart_resize_left); +define_action("smart-resize-down", smart_resize_down); +define_action("smart-resize-up", smart_resize_up); +define_action("smart-resize-right", smart_resize_right); +define_action("split-below", split_below); +define_action("split-right", split_right); +define_action("new-shell-tab", new_shell_tab); +define_action("new-yazi-tab", new_yazi_tab); +define_action("shell-popup", shell_popup); +define_action("lazygit-popup", lazygit_popup); +define_action("git-log-popup", git_log_popup); +define_action("visible-history-tab", visible_history_tab); +define_action("full-history-tab", full_history_tab); +define_action("scratchpad", scratchpad); + +bind("normal", "", "smart-nav-left"); +bind("normal", "", "smart-nav-down"); +bind("normal", "", "smart-nav-up"); +bind("normal", "", "smart-nav-right"); +bind("normal", "", "smart-resize-left"); +bind("normal", "", "smart-resize-down"); +bind("normal", "", "smart-resize-up"); +bind("normal", "", "smart-resize-right"); +bind("copy", "", "smart-nav-left"); +bind("copy", "", "smart-nav-down"); +bind("copy", "", "smart-nav-up"); +bind("copy", "", "smart-nav-right"); +bind("normal", "c", "new-shell-tab"); +bind("normal", "s", "split-below"); +bind("normal", "v", "split-right"); +bind("normal", "d", "shell-popup"); +bind("normal", "g", "lazygit-popup"); +bind("normal", "G", "git-log-popup"); +bind("normal", "h", "visible-history-tab"); +bind("normal", "H", "full-history-tab"); +bind("normal", "o", "scratchpad"); + +on("buffer_bell", bell_handler); diff --git a/crates/embers-client/tests/presentation.rs b/crates/embers-client/tests/presentation.rs new file mode 100644 index 0000000..11b54f2 --- /dev/null +++ b/crates/embers-client/tests/presentation.rs @@ -0,0 +1,189 @@ +mod support; + +use embers_client::PresentationModel; +use embers_core::{FloatGeometry, Size, SplitDirection}; + +use support::{ + FLOATING_BOTTOM_LEAF_ID, FLOATING_ID, FLOATING_TOP_LEAF_ID, FOCUSED_LEAF_ID, LEFT_LEAF_ID, + NESTED_TABS_ID, ROOT_BUFFER_LEAF_ID, ROOT_ONLY_SPLIT_ID, ROOT_SPLIT_LEFT_LEAF_ID, + ROOT_SPLIT_RIGHT_LEAF_ID, ROOT_TABS_ID, SESSION_ID, demo_state, root_buffer_state, + root_split_state, +}; + +#[test] +fn projects_nested_tabs_in_split_and_tracks_focus_path() { + let state = demo_state(); + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 40, + height: 14, + }, + ) + .expect("projection succeeds"); + + let root_tabs = presentation + .root_tabs + .as_ref() + .expect("root tabs are visible"); + assert_eq!(root_tabs.node_id, ROOT_TABS_ID); + assert_eq!(root_tabs.tabs.len(), 2); + assert_eq!( + presentation.focused_leaf().expect("focused leaf").tabs_path, + vec![ROOT_TABS_ID, NESTED_TABS_ID] + ); + assert_eq!( + presentation.focused_leaf().expect("focused leaf").node_id, + FOCUSED_LEAF_ID + ); + + let left_leaf = presentation + .leaves + .iter() + .find(|leaf| leaf.node_id == LEFT_LEAF_ID) + .expect("left leaf is visible"); + let right_leaf = presentation + .leaves + .iter() + .find(|leaf| leaf.node_id == FOCUSED_LEAF_ID) + .expect("right leaf is visible"); + + assert_eq!(left_leaf.rect.origin.x, 0); + assert!(right_leaf.rect.origin.x > left_leaf.rect.origin.x); +} + +#[test] +fn projects_split_in_floating_window() { + let state = demo_state(); + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 40, + height: 14, + }, + ) + .expect("projection succeeds"); + + let floating = presentation + .floating + .iter() + .find(|window| window.floating_id == FLOATING_ID) + .expect("floating window exists"); + assert_eq!(floating.rect.size.width, 20); + assert_eq!(floating.rect.size.height, 7); + + let floating_leaves = presentation + .leaves + .iter() + .filter(|leaf| leaf.floating_id == Some(FLOATING_ID)) + .collect::>(); + assert_eq!(floating_leaves.len(), 2); + assert!( + floating_leaves + .iter() + .any(|leaf| leaf.node_id == FLOATING_TOP_LEAF_ID) + ); + assert!( + floating_leaves + .iter() + .any(|leaf| leaf.node_id == FLOATING_BOTTOM_LEAF_ID) + ); + + let floating_divider = presentation + .dividers + .iter() + .find(|divider| divider.floating_id == Some(FLOATING_ID)) + .expect("floating split divider exists"); + assert_eq!(floating_divider.direction, SplitDirection::Horizontal); +} + +#[test] +fn floating_windows_can_start_on_the_top_row() { + let mut state = demo_state(); + state + .floating + .get_mut(&FLOATING_ID) + .expect("floating window exists") + .geometry = FloatGeometry::new(0, 0, 20, 7); + + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 40, + height: 14, + }, + ) + .expect("projection succeeds"); + + let floating = presentation + .floating + .iter() + .find(|window| window.floating_id == FLOATING_ID) + .expect("floating window exists"); + assert_eq!(floating.rect.origin.y, 0); + assert_eq!(floating.rect.size.height, 7); +} + +#[test] +fn projects_root_buffer_without_tabs_frame() { + let state = root_buffer_state(); + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 40, + height: 14, + }, + ) + .expect("projection succeeds"); + + assert!(presentation.root_tabs.is_none()); + assert!(presentation.tab_bars.is_empty()); + assert_eq!(presentation.leaves.len(), 1); + assert_eq!(presentation.leaves[0].node_id, ROOT_BUFFER_LEAF_ID); + assert!(presentation.leaves[0].tabs_path.is_empty()); +} + +#[test] +fn projects_root_split_without_tabs_frame() { + let state = root_split_state(); + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 40, + height: 14, + }, + ) + .expect("projection succeeds"); + + assert!(presentation.root_tabs.is_none()); + assert!(presentation.tab_bars.is_empty()); + assert_eq!(presentation.leaves.len(), 2); + assert_eq!(presentation.dividers.len(), 1); + assert_eq!(presentation.dividers[0].direction, SplitDirection::Vertical); + assert_eq!(presentation.session_id, SESSION_ID); + assert_eq!( + presentation.focused_leaf().expect("focused leaf").node_id, + ROOT_SPLIT_RIGHT_LEAF_ID + ); + assert!( + presentation + .leaves + .iter() + .any(|leaf| leaf.node_id == ROOT_SPLIT_LEFT_LEAF_ID) + ); + assert_eq!( + presentation.focus_target(embers_client::NavigationDirection::Left), + Some(ROOT_SPLIT_LEFT_LEAF_ID) + ); + assert_eq!( + presentation.focused_leaf().expect("focused leaf").tabs_path, + Vec::::new() + ); + assert_eq!(presentation.dividers[0].floating_id, None); + assert_ne!(ROOT_ONLY_SPLIT_ID, ROOT_SPLIT_LEFT_LEAF_ID); +} diff --git a/crates/embers-client/tests/reducer.rs b/crates/embers-client/tests/reducer.rs new file mode 100644 index 0000000..cb826e3 --- /dev/null +++ b/crates/embers-client/tests/reducer.rs @@ -0,0 +1,503 @@ +use embers_client::{ + ClientState, MuxClient, ScriptedTransport, SearchState, SelectionKind, SelectionPoint, + SelectionState, +}; +use embers_core::{ + ActivityState, BufferId, FloatGeometry, NodeId, PtySize, RequestId, SessionId, SplitDirection, +}; +use embers_protocol::{ + BufferDetachedEvent, BufferRecord, BufferRecordState, BufferViewRecord, BuffersResponse, + ClientMessage, FloatingChangedEvent, FloatingRecord, FocusChangedEvent, NodeChangedEvent, + NodeRecord, NodeRecordKind, RenderInvalidatedEvent, ServerEvent, ServerResponse, SessionRecord, + SessionRequest, SessionSnapshot, SessionSnapshotResponse, SplitRecord, TabRecord, TabsRecord, + VisibleSnapshotResponse, +}; + +fn buffer(id: u64, attachment_node_id: Option, title: &str) -> BufferRecord { + BufferRecord { + id: BufferId(id), + title: title.to_owned(), + command: vec!["/bin/sh".to_owned()], + cwd: Some("/tmp".to_owned()), + pid: None, + env: Default::default(), + state: BufferRecordState::Running, + attachment_node_id: attachment_node_id.map(NodeId), + pty_size: PtySize::new(80, 24), + activity: ActivityState::Idle, + last_snapshot_seq: 0, + exit_code: None, + } +} + +fn buffer_view_node( + id: u64, + session_id: u64, + parent_id: Option, + buffer_id: u64, +) -> NodeRecord { + NodeRecord { + id: NodeId(id), + session_id: SessionId(session_id), + parent_id: parent_id.map(NodeId), + kind: NodeRecordKind::BufferView, + buffer_view: Some(BufferViewRecord { + buffer_id: BufferId(buffer_id), + focused: false, + zoomed: false, + follow_output: true, + last_render_size: PtySize::new(80, 24), + }), + split: None, + tabs: None, + } +} + +fn session_snapshot(root_active: u32, nested_active: u32) -> SessionSnapshot { + let session_id = SessionId(1); + SessionSnapshot { + session: SessionRecord { + id: session_id, + name: "main".to_owned(), + root_node_id: NodeId(10), + floating_ids: vec![embers_core::FloatingId(90)], + focused_leaf_id: Some(NodeId(11)), + focused_floating_id: None, + }, + nodes: vec![ + NodeRecord { + id: NodeId(10), + session_id, + parent_id: None, + kind: NodeRecordKind::Tabs, + buffer_view: None, + split: None, + tabs: Some(TabsRecord { + active: root_active, + tabs: vec![ + TabRecord { + title: "shell".to_owned(), + child_id: NodeId(11), + }, + TabRecord { + title: "hidden".to_owned(), + child_id: NodeId(20), + }, + ], + }), + }, + buffer_view_node(11, 1, Some(10), 1), + NodeRecord { + id: NodeId(20), + session_id, + parent_id: Some(NodeId(10)), + kind: NodeRecordKind::Tabs, + buffer_view: None, + split: None, + tabs: Some(TabsRecord { + active: nested_active, + tabs: vec![ + TabRecord { + title: "build".to_owned(), + child_id: NodeId(21), + }, + TabRecord { + title: "logs".to_owned(), + child_id: NodeId(22), + }, + ], + }), + }, + buffer_view_node(21, 1, Some(20), 2), + NodeRecord { + id: NodeId(22), + session_id, + parent_id: Some(NodeId(20)), + kind: NodeRecordKind::Split, + buffer_view: None, + split: Some(SplitRecord { + direction: SplitDirection::Vertical, + child_ids: vec![NodeId(23), NodeId(24)], + sizes: vec![2, 1], + }), + tabs: None, + }, + buffer_view_node(23, 1, Some(22), 3), + buffer_view_node(24, 1, Some(22), 4), + buffer_view_node(30, 1, None, 5), + ], + buffers: vec![ + buffer(1, Some(11), "shell"), + buffer(2, Some(21), "build"), + buffer(3, Some(23), "logs-a"), + buffer(4, Some(24), "logs-b"), + buffer(5, Some(30), "popup"), + ], + floating: vec![FloatingRecord { + id: embers_core::FloatingId(90), + session_id, + root_node_id: NodeId(30), + title: Some("popup".to_owned()), + geometry: FloatGeometry::new(4, 3, 30, 12), + focused: false, + visible: true, + close_on_empty: true, + }], + } +} + +fn visible_snapshot( + buffer_id: u64, + total_lines: u64, + viewport_top_line: u64, + alternate_screen: bool, +) -> VisibleSnapshotResponse { + VisibleSnapshotResponse { + request_id: RequestId(0), + buffer_id: BufferId(buffer_id), + sequence: 1, + size: PtySize::new(80, 24), + lines: vec!["line-a".to_owned(), "line-b".to_owned()], + title: None, + cwd: None, + viewport_top_line, + total_lines, + alternate_screen, + mouse_reporting: false, + focus_reporting: false, + bracketed_paste: false, + cursor: None, + } +} + +#[test] +fn initial_session_snapshot_apply_populates_cache() { + let snapshot = session_snapshot(0, 0); + let mut state = ClientState::default(); + + state.apply_session_snapshot(snapshot); + + assert_eq!(state.sessions.len(), 1); + assert_eq!(state.nodes.len(), 8); + assert_eq!(state.buffers.len(), 5); + assert_eq!(state.floating.len(), 1); + assert_eq!( + state + .nodes + .get(&NodeId(20)) + .and_then(|node| node.tabs.as_ref()) + .map(|tabs| tabs.active), + Some(0) + ); + assert_eq!( + state + .buffers + .get(&BufferId(5)) + .and_then(|buffer| buffer.attachment_node_id), + Some(NodeId(30)) + ); +} + +#[test] +fn buffer_detach_focus_and_invalidation_events_update_cache() { + let mut state = ClientState::default(); + state.apply_session_snapshot(session_snapshot(0, 0)); + + state.apply_event(&ServerEvent::BufferDetached(BufferDetachedEvent { + buffer_id: BufferId(1), + })); + state.apply_event(&ServerEvent::FocusChanged(FocusChangedEvent { + session_id: SessionId(1), + focused_leaf_id: Some(NodeId(24)), + focused_floating_id: Some(embers_core::FloatingId(90)), + })); + state.apply_event(&ServerEvent::RenderInvalidated(RenderInvalidatedEvent { + buffer_id: BufferId(5), + })); + + assert_eq!( + state + .buffers + .get(&BufferId(1)) + .and_then(|buffer| buffer.attachment_node_id), + None + ); + assert_eq!( + state + .sessions + .get(&SessionId(1)) + .and_then(|session| session.focused_leaf_id), + Some(NodeId(24)) + ); + assert_eq!( + state + .floating + .get(&embers_core::FloatingId(90)) + .map(|floating| floating.focused), + Some(true) + ); + assert!(state.invalidated_buffers.contains(&BufferId(5))); +} + +#[test] +fn node_and_floating_events_mark_session_dirty() { + let mut state = ClientState::default(); + state.apply_session_snapshot(session_snapshot(0, 0)); + + state.apply_event(&ServerEvent::NodeChanged(NodeChangedEvent { + session_id: SessionId(1), + })); + state.apply_event(&ServerEvent::FloatingChanged(FloatingChangedEvent { + session_id: SessionId(1), + floating_id: Some(embers_core::FloatingId(90)), + })); + + assert_eq!( + state.dirty_sessions.iter().copied().collect::>(), + vec![SessionId(1)] + ); +} + +#[test] +fn hidden_nested_subtree_state_updates_on_snapshot_refresh() { + let mut state = ClientState::default(); + state.apply_session_snapshot(session_snapshot(0, 0)); + state.apply_session_snapshot(session_snapshot(0, 1)); + + assert_eq!( + state + .nodes + .get(&NodeId(20)) + .and_then(|node| node.tabs.as_ref()) + .map(|tabs| tabs.active), + Some(1) + ); +} + +#[test] +fn session_snapshot_initializes_view_state_for_each_buffer_view() { + let mut state = ClientState::default(); + state.apply_session_snapshot(session_snapshot(0, 0)); + + assert_eq!(state.view_state.len(), 5); + let root = state.view_state(NodeId(11)).expect("root leaf view state"); + assert_eq!(root.buffer_id, BufferId(1)); + assert!(root.follow_output); + assert_eq!(root.scroll_top_line, 0); + assert_eq!(root.visible_line_count, 24); + assert_eq!(root.total_line_count, 24); +} + +#[test] +fn visible_snapshot_updates_following_views_to_live_bottom() { + let mut state = ClientState::default(); + state.apply_session_snapshot(session_snapshot(0, 0)); + + state.apply_buffer_snapshot(visible_snapshot(1, 40, 16, false)); + + let root = state.view_state(NodeId(11)).expect("root leaf view state"); + assert_eq!(root.total_line_count, 40); + assert_eq!(root.scroll_top_line, 16); + assert_eq!(root.visible_line_count, 24); + assert!(!root.alternate_screen); +} + +#[test] +fn scrolled_view_preserves_position_and_alternate_screen_keeps_state() { + let mut state = ClientState::default(); + state.apply_session_snapshot(session_snapshot(0, 0)); + let view = state + .view_state + .get_mut(&NodeId(11)) + .expect("root leaf view state"); + view.follow_output = false; + view.scroll_top_line = 5; + + state.apply_buffer_snapshot(visible_snapshot(1, 50, 26, true)); + let root = state.view_state(NodeId(11)).expect("root leaf view state"); + assert_eq!(root.scroll_top_line, 5); + assert!(root.alternate_screen); + assert_eq!(root.total_line_count, 50); + + state.apply_buffer_snapshot(visible_snapshot(1, 60, 36, false)); + let root = state.view_state(NodeId(11)).expect("root leaf view state"); + assert_eq!(root.scroll_top_line, 5); + assert!(!root.alternate_screen); + assert_eq!(root.total_line_count, 60); +} + +#[test] +fn rebinding_view_to_a_new_buffer_resets_search_and_selection_state() { + let mut state = ClientState::default(); + state.apply_session_snapshot(session_snapshot(0, 0)); + state.apply_buffer_snapshot(visible_snapshot(1, 40, 16, false)); + + let view = state + .view_state + .get_mut(&NodeId(11)) + .expect("root leaf view state"); + view.search_state = Some(SearchState { + query: "line".to_owned(), + matches: vec![embers_client::SearchMatch { + line: 16, + start_column: 0, + end_column: 4, + }], + active_match_index: Some(0), + }); + view.selection_state = Some(SelectionState { + kind: SelectionKind::Character, + anchor: SelectionPoint { + line: 16, + column: 0, + }, + cursor: SelectionPoint { + line: 17, + column: 2, + }, + }); + + let mut rebound = session_snapshot(0, 0); + let rebound_node = rebound + .nodes + .iter_mut() + .find(|node| node.id == NodeId(11)) + .expect("rebound node"); + rebound_node + .buffer_view + .as_mut() + .expect("buffer view") + .buffer_id = BufferId(99); + rebound.buffers.retain(|buffer| buffer.id != BufferId(1)); + rebound.buffers.push(buffer(99, Some(11), "replacement")); + state.apply_session_snapshot(rebound); + + let view = state.view_state(NodeId(11)).expect("root leaf view state"); + assert_eq!(view.buffer_id, BufferId(99)); + assert!(view.search_state.is_none()); + assert!(view.selection_state.is_none()); +} + +#[tokio::test] +async fn process_next_event_resyncs_session_after_mutation() { + let transport = ScriptedTransport::default(); + transport.push_event(ServerEvent::NodeChanged(NodeChangedEvent { + session_id: SessionId(1), + })); + transport.push_exchange( + ClientMessage::Session(SessionRequest::Get { + request_id: RequestId(1), + session_id: SessionId(1), + }), + ServerResponse::SessionSnapshot(SessionSnapshotResponse { + request_id: RequestId(1), + snapshot: session_snapshot(1, 1), + }), + ); + transport.push_exchange( + ClientMessage::Buffer(embers_protocol::BufferRequest::List { + request_id: RequestId(2), + session_id: None, + attached_only: false, + detached_only: true, + }), + ServerResponse::Buffers(BuffersResponse { + request_id: RequestId(2), + buffers: vec![], + }), + ); + + let mut client = MuxClient::new(transport.clone()); + let event = client + .process_next_event() + .await + .expect("event is processed"); + + assert_eq!( + event, + ServerEvent::NodeChanged(NodeChangedEvent { + session_id: SessionId(1), + }) + ); + assert_eq!( + client + .state() + .nodes + .get(&NodeId(10)) + .and_then(|node| node.tabs.as_ref()) + .map(|tabs| tabs.active), + Some(1) + ); + transport.assert_exhausted().expect("all requests consumed"); +} + +#[tokio::test] +async fn reconnect_resync_rebuilds_sessions_and_detached_buffers() { + let transport = ScriptedTransport::default(); + transport.push_exchange( + ClientMessage::Session(SessionRequest::List { + request_id: RequestId(1), + }), + ServerResponse::Sessions(embers_protocol::SessionsResponse { + request_id: RequestId(1), + sessions: vec![SessionRecord { + id: SessionId(1), + name: "main".to_owned(), + root_node_id: NodeId(10), + floating_ids: vec![], + focused_leaf_id: Some(NodeId(11)), + focused_floating_id: None, + }], + }), + ); + transport.push_exchange( + ClientMessage::Session(SessionRequest::Get { + request_id: RequestId(2), + session_id: SessionId(1), + }), + ServerResponse::SessionSnapshot(SessionSnapshotResponse { + request_id: RequestId(2), + snapshot: session_snapshot(0, 0), + }), + ); + transport.push_exchange( + ClientMessage::Buffer(embers_protocol::BufferRequest::List { + request_id: RequestId(3), + session_id: None, + attached_only: false, + detached_only: true, + }), + ServerResponse::Buffers(BuffersResponse { + request_id: RequestId(3), + buffers: vec![buffer(9, None, "detached")], + }), + ); + + let mut client = MuxClient::new(transport.clone()); + client.state_mut().sessions.insert( + SessionId(99), + SessionRecord { + id: SessionId(99), + name: "stale".to_owned(), + root_node_id: NodeId(999), + floating_ids: vec![], + focused_leaf_id: None, + focused_floating_id: None, + }, + ); + client + .state_mut() + .buffers + .insert(BufferId(7), buffer(7, None, "old-detached")); + + client + .resync_all_sessions() + .await + .expect("full resync succeeds"); + + assert!(client.state().sessions.contains_key(&SessionId(1))); + assert!(!client.state().sessions.contains_key(&SessionId(99))); + assert!(client.state().buffers.contains_key(&BufferId(9))); + assert!(!client.state().buffers.contains_key(&BufferId(7))); + transport.assert_exhausted().expect("all requests consumed"); +} diff --git a/crates/embers-client/tests/renderer.rs b/crates/embers-client/tests/renderer.rs new file mode 100644 index 0000000..4ec696f --- /dev/null +++ b/crates/embers-client/tests/renderer.rs @@ -0,0 +1,176 @@ +mod support; + +use embers_client::{ + PresentationModel, Renderer, SearchMatch, SearchState, SelectionKind, SelectionPoint, + SelectionState, +}; +use embers_core::{CursorPosition, CursorShape, CursorState, Size}; + +use support::{FOCUSED_BUFFER_ID, FOCUSED_LEAF_ID, SESSION_ID, demo_state}; + +#[test] +fn renders_nested_tabs_splits_and_floating_overlay() { + let state = demo_state(); + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 40, + height: 14, + }, + ) + .expect("projection succeeds"); + let renderer = Renderer; + + let grid = renderer.render(&state, &presentation); + + assert_eq!( + grid.render(), + concat!( + " shell [!workspace] \n", + " + editor | !build [ logs-long-tit~]\n", + "left pane |> logs-long-title \n", + "line two |logs visible \n", + "line three |second row \n", + " |+-popup------------+ \n", + " || popup-top | \n", + " ||popup top | \n", + " ||------------------| \n", + " || popup-bottom | \n", + " ||popup bottom | \n", + " |+------------------+ \n", + " | \n", + " | " + ) + ); +} + +#[test] +fn truncates_titles_in_narrow_viewports() { + let state = demo_state(); + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 18, + height: 8, + }, + ) + .expect("projection succeeds"); + let renderer = Renderer; + + let grid = renderer.render(&state, &presentation); + + assert_eq!(grid.lines()[0], " shell [!works~]"); + assert!(grid.lines()[1].contains("!build")); +} + +#[test] +fn renderer_emits_styles_and_tracks_cursor_position() { + let mut state = demo_state(); + state + .snapshots + .get_mut(&FOCUSED_BUFFER_ID) + .expect("focused pane snapshot") + .cursor = Some(CursorState { + position: CursorPosition { row: 1, col: 3 }, + shape: CursorShape::Beam, + }); + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 40, + height: 14, + }, + ) + .expect("projection succeeds"); + let renderer = Renderer; + let focused_leaf = presentation.focused_leaf().expect("focused leaf"); + + let grid = renderer.render(&state, &presentation); + + assert_eq!( + grid.cursor(), + Some(embers_client::GridCursor { + x: focused_leaf.rect.origin.x as u16 + 3, + // +1 for the tab/header row above the leaf and +1 for the cursor's row within it. + y: focused_leaf.rect.origin.y as u16 + 1 + 1, + shape: CursorShape::Beam, + }) + ); + assert!(grid.ansi_lines()[0].contains("\x1b[7m")); +} + +#[test] +fn renderer_shows_scroll_indicator_and_search_highlights() { + let mut state = demo_state(); + let view = state.view_state_mut(FOCUSED_LEAF_ID).unwrap(); + view.follow_output = false; + view.scroll_top_line = 12; + view.total_line_count = 60; + view.visible_lines = vec!["needle here".to_owned(), "plain".to_owned()]; + view.search_state = Some(SearchState { + query: "needle".to_owned(), + matches: vec![SearchMatch { + line: 12, + start_column: 0, + end_column: 6, + }], + active_match_index: Some(0), + }); + + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 40, + height: 14, + }, + ) + .unwrap(); + let renderer = Renderer; + let grid = renderer.render(&state, &presentation); + let ansi = grid.ansi_lines(); + + assert!(ansi.iter().any(|line| line.contains("13/60"))); + assert!(ansi.iter().any(|line| line.contains("\x1b[4m\x1b[7m"))); +} + +#[test] +fn renderer_draws_selection_overlay_and_hides_program_cursor_when_selecting() { + let mut state = demo_state(); + state + .snapshots + .get_mut(&FOCUSED_BUFFER_ID) + .expect("focused pane snapshot") + .cursor = Some(CursorState { + position: CursorPosition { row: 0, col: 0 }, + shape: CursorShape::Beam, + }); + let view = state.view_state_mut(FOCUSED_LEAF_ID).unwrap(); + view.selection_state = Some(SelectionState { + kind: SelectionKind::Character, + anchor: SelectionPoint { line: 0, column: 0 }, + cursor: SelectionPoint { line: 0, column: 1 }, + }); + + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 40, + height: 14, + }, + ) + .unwrap(); + let renderer = Renderer; + let grid = renderer.render(&state, &presentation); + + assert!(grid.cursor().is_none()); + assert!( + grid.ansi_lines() + .iter() + .any(|line| line.contains("\x1b[7mlo")) + ); +} diff --git a/crates/embers-client/tests/repo_config.rs b/crates/embers-client/tests/repo_config.rs new file mode 100644 index 0000000..464c369 --- /dev/null +++ b/crates/embers-client/tests/repo_config.rs @@ -0,0 +1,238 @@ +mod support; + +use std::path::PathBuf; + +use embers_client::{ + Action, BufferSpawnSpec, Context, EventInfo, FloatingAnchor, FloatingGeometrySpec, + FloatingSize, PresentationModel, ScriptEngine, TreeSpec, + config::{ConfigOrigin, LoadedConfigSource}, +}; +use embers_core::{BufferId, Size, SplitDirection}; + +use support::{SESSION_ID, demo_state}; + +#[test] +fn repository_config_loads_with_current_public_api() { + let engine = repository_config_engine(); + assert!(engine.loaded_config().has_tab_bar_formatter()); +} + +#[test] +fn repository_config_smart_nav_uses_buffer_input_for_nvim() { + let engine = repository_config_engine(); + let mut state = demo_state(); + state.buffers.get_mut(&BufferId(4)).unwrap().command = vec!["/usr/bin/nvim".to_owned()]; + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 80, + height: 24, + }, + ) + .unwrap(); + + assert_eq!( + engine + .run_named_action( + "smart-nav-left", + Context::from_state(&state, Some(&presentation)), + ) + .unwrap(), + vec![Action::SendBytes { + buffer_id: None, + bytes: vec![8], + }] + ); +} + +#[test] +fn repository_config_bell_handler_moves_hidden_buffer_to_floating() { + let engine = repository_config_engine(); + + assert_eq!( + engine + .dispatch_event( + "buffer_bell", + demo_context().with_event(EventInfo { + name: "buffer_bell".to_owned(), + session_id: Some(SESSION_ID), + buffer_id: Some(BufferId(3)), + node_id: None, + floating_id: None, + }), + ) + .unwrap(), + vec![Action::MoveBufferToFloating { + buffer_id: BufferId(3), + geometry: FloatingGeometrySpec { + width: FloatingSize::Cells(110), + height: FloatingSize::Cells(32), + anchor: FloatingAnchor::Center, + offset_x: 4, + offset_y: 1, + }, + title: Some("build".to_owned()), + focus: true, + }] + ); +} + +#[test] +fn repository_config_history_helper_spawns_buffer_with_history_env() { + let engine = repository_config_engine(); + let actions = engine + .run_named_action("full-history-tab", demo_context()) + .unwrap(); + + let [ + Action::InsertTabAfter { + tabs_node_id: None, + title: Some(title), + child: + TreeSpec::BufferSpawn(BufferSpawnSpec { + title: Some(buffer_title), + command, + cwd, + env, + }), + }, + ] = actions.as_slice() + else { + panic!("unexpected history action: {actions:?}"); + }; + + assert_eq!(title, "full-history"); + assert_eq!(buffer_title, "full-history"); + assert_eq!( + command, + &vec![ + "/bin/sh".to_owned(), + "-lc".to_owned(), + "printf '%s' \"$EMBERS_HISTORY\" | less -R".to_owned(), + ] + ); + assert_eq!(cwd.as_deref(), Some("/tmp")); + assert!(env["EMBERS_HISTORY"].contains("logs visible")); + assert!(env["EMBERS_HISTORY"].contains("third row")); +} + +#[test] +fn repository_config_split_and_tab_actions_build_expected_shell_trees() { + let engine = repository_config_engine(); + + assert_eq!( + engine + .run_named_action("split-below", demo_context()) + .unwrap(), + vec![Action::SplitCurrent { + direction: SplitDirection::Horizontal, + new_child: TreeSpec::BufferSpawn(BufferSpawnSpec { + title: Some("shell".to_owned()), + command: vec!["/usr/bin/env".to_owned(), "zsh".to_owned()], + cwd: Some("/tmp".to_owned()), + env: Default::default(), + }), + }] + ); + + assert_eq!( + engine + .run_named_action("new-shell-tab", demo_context()) + .unwrap(), + vec![Action::InsertTabAfter { + tabs_node_id: None, + title: Some("shell".to_owned()), + child: TreeSpec::BufferSpawn(BufferSpawnSpec { + title: Some("shell".to_owned()), + command: vec!["/usr/bin/env".to_owned(), "zsh".to_owned()], + cwd: Some("/tmp".to_owned()), + env: Default::default(), + }), + }] + ); +} + +#[test] +fn repository_config_popup_and_scratchpad_actions_build_floating_layouts() { + let engine = repository_config_engine(); + + assert_eq!( + engine + .run_named_action("shell-popup", demo_context()) + .unwrap(), + vec![Action::OpenFloating { + spec: embers_client::FloatingSpec { + tree: TreeSpec::BufferSpawn(BufferSpawnSpec { + title: Some("shell".to_owned()), + command: vec!["/usr/bin/env".to_owned(), "zsh".to_owned()], + cwd: Some("/tmp".to_owned()), + env: Default::default(), + }), + geometry: FloatingGeometrySpec { + width: FloatingSize::Cells(100), + height: FloatingSize::Cells(28), + anchor: FloatingAnchor::Center, + offset_x: 8, + offset_y: 2, + }, + title: Some("shell".to_owned()), + focus: true, + close_on_empty: true, + }, + }] + ); + + let actions = engine + .run_named_action("scratchpad", demo_context()) + .unwrap(); + let [Action::OpenFloating { spec }] = actions.as_slice() else { + panic!("unexpected scratchpad action: {actions:?}"); + }; + + assert_eq!(spec.title.as_deref(), Some("scratchpad")); + assert_eq!( + spec.geometry, + FloatingGeometrySpec { + width: FloatingSize::Cells(100), + height: FloatingSize::Cells(28), + anchor: FloatingAnchor::Center, + offset_x: 10, + offset_y: 3, + } + ); + + let TreeSpec::Tabs(tabs) = &spec.tree else { + panic!("scratchpad should build tabs, got {:?}", spec.tree); + }; + assert_eq!(tabs.active, 0); + assert_eq!(tabs.tabs.len(), 2); + assert_eq!(tabs.tabs[0].title, "shell"); + assert_eq!(tabs.tabs[1].title, "tools"); +} + +fn repository_config_engine() -> ScriptEngine { + ScriptEngine::load(&LoadedConfigSource { + origin: ConfigOrigin::Explicit, + path: Some( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/repository_config.rhai"), + ), + source: include_str!("fixtures/repository_config.rhai").to_owned(), + source_hash: 0, + }) + .unwrap() +} + +fn demo_context() -> Context { + let state = demo_state(); + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 80, + height: 24, + }, + ) + .unwrap(); + Context::from_state(&state, Some(&presentation)) +} diff --git a/crates/embers-client/tests/script_actions.rs b/crates/embers-client/tests/script_actions.rs new file mode 100644 index 0000000..ca64aab --- /dev/null +++ b/crates/embers-client/tests/script_actions.rs @@ -0,0 +1,599 @@ +mod support; + +use std::collections::BTreeMap; + +use embers_client::{ + Action, BufferSpawnSpec, Context, EventInfo, FloatingAnchor, FloatingGeometrySpec, + FloatingSize, FloatingSpec, KeyToken, NavigationDirection, NotifyLevel, PresentationModel, + ScriptEngine, SelectionKind, TabSpec, TabsSpec, TreeSpec, + config::{ConfigOrigin, LoadedConfigSource}, +}; +use embers_core::{BufferId, FloatingId, NodeId, Size, SplitDirection}; + +use support::{SESSION_ID, demo_state}; + +#[test] +fn action_helpers_roundtrip_to_typed_actions() { + let engine = load_engine( + r#" + fn enter_copy_action(ctx) { action.enter_mode("copy") } + fn focus_left_action(ctx) { action.focus_left() } + fn resize_right_action(ctx) { action.resize_right(2) } + fn select_tab_action(ctx) { action.select_current_tabs(2) } + fn split_tree_action(ctx) { action.split_with("horizontal", tree.buffer_current()) } + fn replace_current_action(ctx) { action.replace_current_with(tree.buffer_attach(9)) } + fn replace_node_action(ctx) { action.replace_node(7, tree.buffer_current()) } + fn insert_tab_action(ctx) { + action.insert_tab_after_current("logs", tree.buffer_current()) + } + fn open_popup_action(ctx) { + action.open_floating( + tree.buffer_current(), + #{ x: 1, y: 2, width: 30, height: 10, title: "popup" } + ) + } + fn detach_buffer_action(ctx) { action.detach_buffer() } + fn kill_buffer_action(ctx) { action.kill_buffer() } + fn send_keys_action(ctx) { action.send_keys_current("abc") } + fn send_bytes_action(ctx) { action.send_bytes_current([65, 66]) } + fn scroll_page_action(ctx) { action.scroll_page_up() } + fn search_action(ctx) { action.enter_search_mode() } + fn search_next_action(ctx) { action.search_next() } + fn select_char_action(ctx) { action.enter_select_char() } + fn select_move_action(ctx) { action.select_move_left() } + fn yank_action(ctx) { action.yank_selection() } + fn notify_user_action(ctx) { action.notify("info", "hello") } + + define_action("enter-copy", enter_copy_action); + define_action("focus-left", focus_left_action); + define_action("resize-right", resize_right_action); + define_action("select-tab", select_tab_action); + define_action("split-tree", split_tree_action); + define_action("replace-current", replace_current_action); + define_action("replace-node", replace_node_action); + define_action("insert-tab", insert_tab_action); + define_action("open-popup", open_popup_action); + define_action("detach-buffer", detach_buffer_action); + define_action("kill-buffer", kill_buffer_action); + define_action("send-keys", send_keys_action); + define_action("send-bytes", send_bytes_action); + define_action("scroll-page", scroll_page_action); + define_action("search", search_action); + define_action("search-next", search_next_action); + define_action("select-char", select_char_action); + define_action("select-move", select_move_action); + define_action("yank", yank_action); + define_action("notify-user", notify_user_action); + "#, + ); + let context = demo_context(); + + assert_eq!( + engine + .run_named_action("enter-copy", context.clone()) + .unwrap(), + vec![Action::EnterMode { + mode: "copy".to_owned(), + }] + ); + assert_eq!( + engine + .run_named_action("focus-left", context.clone()) + .unwrap(), + vec![Action::FocusDirection { + direction: NavigationDirection::Left, + }] + ); + assert_eq!( + engine + .run_named_action("resize-right", context.clone()) + .unwrap(), + vec![Action::ResizeDirection { + direction: NavigationDirection::Right, + amount: 2, + }] + ); + assert_eq!( + engine + .run_named_action("select-tab", context.clone()) + .unwrap(), + vec![Action::SelectTab { + tabs_node_id: None, + index: 2, + }] + ); + assert_eq!( + engine + .run_named_action("split-tree", context.clone()) + .unwrap(), + vec![Action::SplitCurrent { + direction: SplitDirection::Horizontal, + new_child: TreeSpec::BufferCurrent, + }] + ); + assert_eq!( + engine + .run_named_action("replace-current", context.clone()) + .unwrap(), + vec![Action::ReplaceNode { + node_id: None, + tree: TreeSpec::BufferAttach { + buffer_id: BufferId(9), + }, + }] + ); + assert_eq!( + engine + .run_named_action("replace-node", context.clone()) + .unwrap(), + vec![Action::ReplaceNode { + node_id: Some(NodeId(7)), + tree: TreeSpec::BufferCurrent, + }] + ); + assert_eq!( + engine + .run_named_action("insert-tab", context.clone()) + .unwrap(), + vec![Action::InsertTabAfter { + tabs_node_id: None, + title: Some("logs".to_owned()), + child: TreeSpec::BufferCurrent, + }] + ); + assert_eq!( + engine + .run_named_action("open-popup", context.clone()) + .unwrap(), + vec![Action::OpenFloating { + spec: FloatingSpec { + tree: TreeSpec::BufferCurrent, + geometry: FloatingGeometrySpec { + width: FloatingSize::Cells(30), + height: FloatingSize::Cells(10), + anchor: FloatingAnchor::Center, + offset_x: 1, + offset_y: 2, + }, + title: Some("popup".to_owned()), + focus: true, + close_on_empty: true, + }, + }] + ); + assert_eq!( + engine + .run_named_action("detach-buffer", context.clone()) + .unwrap(), + vec![Action::DetachBuffer { buffer_id: None }] + ); + assert_eq!( + engine + .run_named_action("kill-buffer", context.clone()) + .unwrap(), + vec![Action::KillBuffer { buffer_id: None }] + ); + assert_eq!( + engine + .run_named_action("send-keys", context.clone()) + .unwrap(), + vec![Action::SendKeys { + buffer_id: None, + keys: vec![ + KeyToken::Char('a'), + KeyToken::Char('b'), + KeyToken::Char('c'), + ], + }] + ); + assert_eq!( + engine + .run_named_action("send-bytes", context.clone()) + .unwrap(), + vec![Action::SendBytes { + buffer_id: None, + bytes: vec![65, 66], + }] + ); + assert_eq!( + engine + .run_named_action("scroll-page", context.clone()) + .unwrap(), + vec![Action::ScrollPageUp] + ); + assert_eq!( + engine.run_named_action("search", context.clone()).unwrap(), + vec![Action::EnterSearchMode] + ); + assert_eq!( + engine + .run_named_action("search-next", context.clone()) + .unwrap(), + vec![Action::SearchNext] + ); + assert_eq!( + engine + .run_named_action("select-char", context.clone()) + .unwrap(), + vec![Action::EnterSelect { + kind: SelectionKind::Character, + }] + ); + assert_eq!( + engine + .run_named_action("select-move", context.clone()) + .unwrap(), + vec![Action::SelectMove { + direction: NavigationDirection::Left, + }] + ); + assert_eq!( + engine.run_named_action("yank", context.clone()).unwrap(), + vec![Action::CopySelection] + ); + assert_eq!( + engine.run_named_action("notify-user", context).unwrap(), + vec![Action::Notify { + level: NotifyLevel::Info, + message: "hello".to_owned(), + }] + ); +} + +#[test] +fn unsupported_live_executor_actions_fail_when_actions_are_materialized() { + let engine = ScriptEngine::load(&LoadedConfigSource { + origin: ConfigOrigin::BuiltIn, + path: Some("unsupported-actions.rhai".into()), + source: r#" + fn wrap_split_action(ctx) { + action.wrap_current_in_split("vertical", tree.buffer_current()) + } + fn wrap_tabs_action(ctx) { + action.wrap_current_in_tabs(tree.tabs_with_active([ + tree.tab("main", tree.current_node()), + tree.tab("scratch", tree.buffer_empty()) + ], 1)) + } + fn replace_popup_action(ctx) { + action.replace_floating_root(tree.buffer_current()) + } + + define_action("wrap-split", wrap_split_action); + define_action("wrap-tabs", wrap_tabs_action); + define_action("replace-popup", replace_popup_action); + "# + .trim() + .to_owned(), + source_hash: 0, + }) + .unwrap(); + + let error = engine + .run_named_action("wrap-split", demo_context()) + .expect_err("unsupported live actions should fail before execution"); + + let message = error.to_string(); + assert!(message.contains("not supported by the live executor")); + assert!(message.contains("WrapNodeInSplit")); +} + +#[test] +fn unsupported_live_executor_actions_are_rejected_inside_chains() { + let engine = load_engine( + r#" + fn nested(ctx) { + action.chain([ + action.noop(), + action.wrap_current_in_split("vertical", tree.buffer_current()) + ]) + } + + define_action("nested", nested); + "#, + ); + + let error = engine + .run_named_action("nested", demo_context()) + .expect_err("nested unsupported live actions should fail before execution"); + + assert!(error.to_string().contains("WrapNodeInSplit")); +} + +#[test] +fn action_arrays_preserve_order_and_unit_is_noop() { + let engine = load_engine( + r#" + fn chained(ctx) { + [action.focus_left(), action.focus_right(), action.focus_up()] + } + fn noop(ctx) { () } + define_action("chained", chained); + define_action("noop", noop); + "#, + ); + let context = demo_context(); + + assert_eq!( + engine.run_named_action("chained", context.clone()).unwrap(), + vec![ + Action::FocusDirection { + direction: NavigationDirection::Left, + }, + Action::FocusDirection { + direction: NavigationDirection::Right, + }, + Action::FocusDirection { + direction: NavigationDirection::Up, + }, + ] + ); + assert!(engine.run_named_action("noop", context).unwrap().is_empty()); +} + +#[test] +fn invalid_action_shapes_fail_cleanly() { + let engine = load_engine( + r#" + fn bad_bytes(ctx) { action.send_bytes_current(["x"]) } + define_action("bad-bytes", bad_bytes); + "#, + ); + + let error = engine + .run_named_action("bad-bytes", demo_context()) + .expect_err("invalid action arguments should fail"); + + assert!( + error + .to_string() + .contains("send_bytes expects an array of integers") + ); +} + +#[test] +fn query_api_supports_smart_nav_style_scripts() { + let engine = load_engine( + r#" + fn smart_nav_left(ctx) { + let buffer = ctx.current_buffer(); + let node = ctx.current_node(); + if buffer.is_visible() + && !buffer.is_detached() + && buffer.process_name() == "sh" + && buffer.command()[0] == "/bin/sh" + && node.kind() == "buffer_view" + { + action.send_keys_current("h") + } else { + action.focus_left() + } + } + define_action("smart-nav-left", smart_nav_left); + "#, + ); + + assert_eq!( + engine + .run_named_action("smart-nav-left", demo_context()) + .unwrap(), + vec![Action::SendKeys { + buffer_id: None, + keys: vec![KeyToken::Char('h')], + }] + ); +} + +#[test] +fn event_handlers_can_inspect_visibility_and_session_relationships() { + let engine = load_engine( + r#" + fn bell_handler(ctx) { + let session = ctx.current_session(); + let event = ctx.event(); + if session.name() == "demo" + && session.floating().len > 0 + && event.name() == "buffer_bell" + { + action.notify("info", "floating-visible") + } else { + () + } + } + on("buffer_bell", bell_handler); + "#, + ); + + assert_eq!( + engine + .dispatch_event( + "buffer_bell", + demo_context().with_event(EventInfo { + name: "buffer_bell".to_owned(), + session_id: Some(SESSION_ID), + buffer_id: Some(BufferId(4)), + node_id: None, + floating_id: Some(FloatingId(90)), + }), + ) + .unwrap(), + vec![Action::Notify { + level: NotifyLevel::Info, + message: "floating-visible".to_owned(), + }] + ); +} + +#[test] +fn missing_optional_values_surface_as_unit() { + let engine = load_engine( + r#" + fn check_missing(ctx) { + if ctx.current_buffer() == () && ctx.current_node() == () { + action.notify("warn", "missing") + } else { + action.focus_left() + } + } + define_action("check-missing", check_missing); + "#, + ); + + assert_eq!( + engine + .run_named_action("check-missing", Context::default()) + .unwrap(), + vec![Action::Notify { + level: NotifyLevel::Warn, + message: "missing".to_owned(), + }] + ); +} + +#[test] +fn tree_builders_roundtrip_nested_specs() { + let engine = load_engine( + r#" + fn build_tree(ctx) { + action.replace_current_with( + tree.tabs_with_active([ + tree.tab("main", tree.split("horizontal", [ + tree.buffer_current(), + tree.buffer_attach(9) + ], [1, 2])), + tree.tab("scratch", tree.buffer_spawn( + ["/bin/sh"], + #{ title: "scratch", cwd: "/tmp" } + )) + ], 1) + ) + } + define_action("build-tree", build_tree); + "#, + ); + + assert_eq!( + engine + .run_named_action("build-tree", demo_context()) + .unwrap(), + vec![Action::ReplaceNode { + node_id: None, + tree: TreeSpec::Tabs(TabsSpec { + tabs: vec![ + TabSpec { + title: "main".to_owned(), + tree: Box::new(TreeSpec::Split { + direction: SplitDirection::Horizontal, + children: vec![ + TreeSpec::BufferCurrent, + TreeSpec::BufferAttach { + buffer_id: BufferId(9), + }, + ], + sizes: vec![1, 2], + }), + }, + TabSpec { + title: "scratch".to_owned(), + tree: Box::new(TreeSpec::BufferSpawn(BufferSpawnSpec { + title: Some("scratch".to_owned()), + command: vec!["/bin/sh".to_owned()], + cwd: Some("/tmp".to_owned()), + env: BTreeMap::new(), + })), + }, + ], + active: 1, + }), + }] + ); +} + +#[test] +fn invalid_tree_specs_are_rejected() { + let empty_split = load_engine( + r#" + fn bad_split(ctx) { action.replace_current_with(tree.split_h([])) } + define_action("bad-split", bad_split); + "#, + ); + let empty_tabs = load_engine( + r#" + fn bad_tabs(ctx) { action.replace_current_with(tree.tabs([])) } + define_action("bad-tabs", bad_tabs); + "#, + ); + let bad_active = load_engine( + r#" + fn bad_active(ctx) { + action.replace_current_with( + tree.tabs_with_active([tree.tab("main", tree.buffer_current())], 2) + ) + } + define_action("bad-active", bad_active); + "#, + ); + let bad_sizes = load_engine( + r#" + fn bad_sizes(ctx) { + action.replace_current_with( + tree.split("horizontal", [tree.buffer_current()], [0]) + ) + } + define_action("bad-sizes", bad_sizes); + "#, + ); + + assert!( + empty_split + .run_named_action("bad-split", demo_context()) + .unwrap_err() + .to_string() + .contains("split children cannot be empty") + ); + assert!( + empty_tabs + .run_named_action("bad-tabs", demo_context()) + .unwrap_err() + .to_string() + .contains("tabs cannot be empty") + ); + assert!( + bad_active + .run_named_action("bad-active", demo_context()) + .unwrap_err() + .to_string() + .contains("active tab index is out of bounds") + ); + assert!( + bad_sizes + .run_named_action("bad-sizes", demo_context()) + .unwrap_err() + .to_string() + .contains("split size must be greater than zero") + ); +} + +fn demo_context() -> Context { + let state = demo_state(); + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 80, + height: 24, + }, + ) + .unwrap(); + Context::from_state(&state, Some(&presentation)) +} + +fn load_engine(source: &str) -> ScriptEngine { + ScriptEngine::load(&LoadedConfigSource { + origin: ConfigOrigin::BuiltIn, + path: Some("script-actions.rhai".into()), + source: source.trim().to_owned(), + source_hash: 0, + }) + .unwrap() +} diff --git a/crates/embers-client/tests/script_engine.rs b/crates/embers-client/tests/script_engine.rs new file mode 100644 index 0000000..210c477 --- /dev/null +++ b/crates/embers-client/tests/script_engine.rs @@ -0,0 +1,252 @@ +mod support; + +use std::path::Path; + +use embers_client::input::KeyParseError; +use embers_client::{ + Action, InputResolution, KeyToken, PresentationModel, ScriptEngine, ScriptHarness, + TabBarContext, + config::{ConfigOrigin, LoadedConfigSource}, +}; +use embers_core::Size; + +use support::{SESSION_ID, demo_state}; + +#[test] +fn loaded_config_debug_snapshot_is_stable() { + let source = LoadedConfigSource { + origin: ConfigOrigin::BuiltIn, + path: Some("snapshot-config.rhai".into()), + source: r##" + fn split_workspace(ctx) { () } + fn on_created(ctx) { () } + fn format_tabs(ctx) { ui.bar([], [], []) } + + set_leader(""); + define_mode("locked"); + define_action("workspace-split", split_workspace); + bind("normal", "ws", "workspace-split"); + on("session_created", on_created); + tabbar.set_formatter(format_tabs); + theme.set_palette(#{ active: "#00ff00", inactive: "#333333" }); + "## + .trim() + .to_owned(), + source_hash: 0, + }; + + let engine = ScriptEngine::load(&source).unwrap(); + let loaded = engine.loaded_config(); + let debug_output = format!("{loaded:#?}"); + + assert_eq!( + loaded.source_path.as_deref(), + Some(Path::new("snapshot-config.rhai")) + ); + assert_eq!(loaded.source_hash, 0); + assert_eq!(loaded.leader, vec![KeyToken::Ctrl('a')]); + assert!(loaded.modes.contains_key("locked")); + assert_eq!(loaded.bindings["normal"][0].notation, "ws"); + assert_eq!( + loaded.named_actions["workspace-split"].name, + "split_workspace" + ); + assert_eq!( + loaded.event_handlers["session_created"][0].name, + "on_created" + ); + assert_eq!( + loaded + .tab_bar_formatter + .as_ref() + .map(|formatter| formatter.name.as_str()), + Some("format_tabs") + ); + assert_eq!(loaded.theme.palette["active"].green, 255); + assert!(debug_output.contains("source_path: Some(")); + assert!(debug_output.contains("ast: \"\"")); +} + +#[test] +fn harness_resolves_leader_binding_to_exact_match() { + let mut harness = ScriptHarness::load( + r#" + fn split_workspace(ctx) { () } + define_action("workspace-split", split_workspace); + set_leader(""); + bind("normal", "ws", "workspace-split"); + "#, + ) + .unwrap(); + + assert_eq!( + harness.resolve_notation("normal", "w").unwrap(), + InputResolution::PrefixMatch + ); + assert_eq!( + harness.resolve_notation("normal", "s").unwrap(), + InputResolution::ExactMatch(embers_client::BindingMatch { + mode: "normal".to_owned(), + sequence: vec![ + KeyToken::Ctrl('a'), + KeyToken::Char('w'), + KeyToken::Char('s'), + ], + target: vec![Action::RunNamedAction { + name: "workspace-split".to_owned(), + }], + }) + ); +} + +#[test] +fn same_sequence_can_resolve_differently_by_mode() { + let mut harness = ScriptHarness::load( + r#" + fn normal_action(ctx) { () } + fn copy_action(ctx) { () } + define_action("normal-a", normal_action); + define_action("copy-a", copy_action); + bind("normal", "a", "normal-a"); + bind("copy", "a", "copy-a"); + "#, + ) + .unwrap(); + + assert_eq!( + harness.resolve_notation("normal", "a").unwrap(), + InputResolution::ExactMatch(embers_client::BindingMatch { + mode: "normal".to_owned(), + sequence: vec![KeyToken::Char('a')], + target: vec![Action::RunNamedAction { + name: "normal-a".to_owned(), + }], + }) + ); + assert_eq!( + harness.resolve_notation("copy", "a").unwrap(), + InputResolution::ExactMatch(embers_client::BindingMatch { + mode: "copy".to_owned(), + sequence: vec![KeyToken::Char('a')], + target: vec![Action::RunNamedAction { + name: "copy-a".to_owned(), + }], + }) + ); +} + +#[test] +fn define_mode_rejects_unknown_options() { + let source = LoadedConfigSource { + origin: ConfigOrigin::BuiltIn, + path: Some("bad-mode-options.rhai".into()), + source: r#" + fn enter(ctx) { () } + define_mode("locked", #{ + on_enter: enter, + on_entter: enter + }); + "# + .trim() + .to_owned(), + source_hash: 0, + }; + + let error = match ScriptEngine::load(&source) { + Ok(_) => panic!("unknown mode options should fail"), + Err(error) => error, + }; + + assert!( + error + .to_string() + .contains("unknown mode option(s): on_entter") + ); +} + +#[test] +fn formatter_functions_build_bar_specs_from_runtime_context() { + let source = LoadedConfigSource { + origin: ConfigOrigin::BuiltIn, + path: Some("formatters.rhai".into()), + source: r##" + fn format_tabs(ctx) { + let tabs = ctx.tabs(); + let active = tabs[ctx.active_index()]; + if ctx.is_root() { + ui.bar([ + ui.segment("ROOT ", #{ + fg: theme.color("active"), + bg: theme.color("inactive") + }), + ui.segment(active.title()) + ], [], []) + } else { + ui.bar([ + ui.segment("NESTED "), + ui.segment(active.title(), #{ fg: theme.color("active") }) + ], [], []) + } + } + + tabbar.set_formatter(format_tabs); + theme.set_palette(#{ active: "#00ff00", inactive: "#102030" }); + "## + .trim() + .to_owned(), + source_hash: 0, + }; + let engine = ScriptEngine::load(&source).unwrap(); + let state = demo_state(); + let presentation = PresentationModel::project( + &state, + SESSION_ID, + Size { + width: 80, + height: 20, + }, + ) + .unwrap(); + + let root = engine + .format_tab_bar(TabBarContext::from_frame( + presentation.root_tabs.as_ref().unwrap(), + "normal", + 80, + )) + .unwrap() + .unwrap(); + let nested = engine + .format_tab_bar(TabBarContext::from_frame( + presentation.focused_tabs().unwrap(), + "normal", + 80, + )) + .unwrap() + .unwrap(); + + assert_eq!(root.left.len(), 2); + assert!(root.center.is_empty()); + assert!(root.right.is_empty()); + assert_eq!(root.left[0].text, "ROOT "); + assert_eq!(root.left[1].text, "workspace"); + assert_eq!(root.left[0].style.fg.unwrap().green, 255); + assert_eq!(root.left[0].style.bg.unwrap().blue, 48); + + assert_eq!(nested.left.len(), 2); + assert!(nested.center.is_empty()); + assert!(nested.right.is_empty()); + assert_eq!(nested.left[0].text, "NESTED "); + assert_eq!(nested.left[1].text, "logs-long-title"); + assert_eq!(nested.left[1].style.fg.unwrap().green, 255); +} + +#[test] +fn harness_rejects_empty_notation_without_panicking() { + let mut harness = ScriptHarness::load("").unwrap(); + + assert_eq!( + harness.resolve_notation("normal", "").unwrap_err(), + KeyParseError::EmptySequence + ); +} diff --git a/crates/embers-client/tests/socket_transport.rs b/crates/embers-client/tests/socket_transport.rs new file mode 100644 index 0000000..acd7987 --- /dev/null +++ b/crates/embers-client/tests/socket_transport.rs @@ -0,0 +1,55 @@ +use embers_client::{SocketTransport, Transport}; +use embers_core::RequestId; +use embers_protocol::{ + ClientMessage, PingRequest, ServerEvent, ServerResponse, SessionRequest, SubscribeRequest, +}; +use embers_test_support::{TestConnection, TestServer}; + +#[tokio::test] +async fn socket_transport_sends_requests_and_receives_events() { + let server = TestServer::start().await.expect("server starts"); + let transport = SocketTransport::connect(server.socket_path()) + .await + .expect("transport connects"); + + let pong = transport + .request(ClientMessage::Ping(PingRequest { + request_id: RequestId(1), + payload: "phase10".to_owned(), + })) + .await + .expect("ping succeeds"); + assert_eq!( + pong, + ServerResponse::Pong(embers_protocol::PingResponse { + request_id: RequestId(1), + payload: "phase10".to_owned(), + }) + ); + + let subscribe = transport + .request(ClientMessage::Subscribe(SubscribeRequest { + request_id: RequestId(2), + session_id: None, + })) + .await + .expect("subscribe succeeds"); + assert!(matches!(subscribe, ServerResponse::SubscriptionAck(_))); + + let mut actor = TestConnection::connect(server.socket_path()) + .await + .expect("actor connects"); + let created = actor + .request(&ClientMessage::Session(SessionRequest::Create { + request_id: RequestId(3), + name: "alpha".to_owned(), + })) + .await + .expect("session creation succeeds"); + assert!(matches!(created, ServerResponse::SessionSnapshot(_))); + + let event = transport.next_event().await.expect("event arrives"); + assert!(matches!(event, ServerEvent::SessionCreated(_))); + + server.shutdown().await.expect("server shuts down"); +} diff --git a/crates/embers-client/tests/support/mod.rs b/crates/embers-client/tests/support/mod.rs new file mode 100644 index 0000000..444d685 --- /dev/null +++ b/crates/embers-client/tests/support/mod.rs @@ -0,0 +1,346 @@ +#![allow(dead_code)] + +use embers_client::ClientState; +use embers_core::{ + ActivityState, BufferId, FloatGeometry, FloatingId, NodeId, PtySize, SessionId, SplitDirection, +}; +use embers_protocol::{ + BufferRecord, BufferRecordState, BufferViewRecord, FloatingRecord, NodeRecord, NodeRecordKind, + SessionRecord, SessionSnapshot, SplitRecord, TabRecord, TabsRecord, VisibleSnapshotResponse, +}; + +pub const SESSION_ID: SessionId = SessionId(1); +pub const ROOT_TABS_ID: NodeId = NodeId(10); +pub const HIDDEN_ROOT_LEAF_ID: NodeId = NodeId(11); +pub const ROOT_SPLIT_ID: NodeId = NodeId(20); +pub const LEFT_LEAF_ID: NodeId = NodeId(21); +pub const NESTED_TABS_ID: NodeId = NodeId(30); +pub const HIDDEN_NESTED_LEAF_ID: NodeId = NodeId(31); +pub const FOCUSED_LEAF_ID: NodeId = NodeId(32); +pub const FOCUSED_BUFFER_ID: BufferId = BufferId(4); +pub const FLOATING_ID: FloatingId = FloatingId(90); +pub const FLOATING_SPLIT_ID: NodeId = NodeId(40); +pub const FLOATING_TOP_LEAF_ID: NodeId = NodeId(41); +pub const FLOATING_BOTTOM_LEAF_ID: NodeId = NodeId(42); +pub const ROOT_BUFFER_LEAF_ID: NodeId = NodeId(50); +pub const ROOT_ONLY_SPLIT_ID: NodeId = NodeId(60); +pub const ROOT_SPLIT_LEFT_LEAF_ID: NodeId = NodeId(61); +pub const ROOT_SPLIT_RIGHT_LEAF_ID: NodeId = NodeId(62); + +pub fn demo_state() -> ClientState { + let mut state = ClientState::default(); + state.apply_session_snapshot(demo_snapshot(None)); + for snapshot in demo_snapshots() { + state.apply_buffer_snapshot(snapshot); + } + state +} + +pub fn floating_focused_state() -> ClientState { + let mut state = ClientState::default(); + state.apply_session_snapshot(demo_snapshot(Some((FLOATING_ID, FLOATING_TOP_LEAF_ID)))); + for snapshot in demo_snapshots() { + state.apply_buffer_snapshot(snapshot); + } + state +} + +pub fn root_focus_state() -> ClientState { + let mut state = ClientState::default(); + state.apply_session_snapshot(demo_snapshot(None)); + for snapshot in demo_snapshots() { + state.apply_buffer_snapshot(snapshot); + } + if let Some(session) = state.sessions.get_mut(&SESSION_ID) { + session.focused_floating_id = None; + session.focused_leaf_id = Some(LEFT_LEAF_ID); + } + state +} + +pub fn root_buffer_state() -> ClientState { + let mut state = ClientState::default(); + state.apply_session_snapshot(root_buffer_snapshot()); + state.apply_buffer_snapshot(snapshot(7, ["root buffer", "extra line"])); + state +} + +pub fn root_split_state() -> ClientState { + let mut state = ClientState::default(); + state.apply_session_snapshot(root_split_snapshot()); + state.apply_buffer_snapshot(snapshot(7, ["left root pane"])); + state.apply_buffer_snapshot(snapshot(8, ["right root pane"])); + state +} + +fn demo_snapshot(focused_floating: Option<(FloatingId, NodeId)>) -> SessionSnapshot { + let (focused_floating_id, focused_leaf_id) = focused_floating + .map(|(floating_id, leaf_id)| (Some(floating_id), Some(leaf_id))) + .unwrap_or((None, Some(FOCUSED_LEAF_ID))); + + SessionSnapshot { + session: SessionRecord { + id: SESSION_ID, + name: "demo".to_owned(), + root_node_id: ROOT_TABS_ID, + floating_ids: vec![FLOATING_ID], + focused_leaf_id, + focused_floating_id, + }, + nodes: vec![ + NodeRecord { + id: ROOT_TABS_ID, + session_id: SESSION_ID, + parent_id: None, + kind: NodeRecordKind::Tabs, + buffer_view: None, + split: None, + tabs: Some(TabsRecord { + active: 1, + tabs: vec![ + TabRecord { + title: "shell".to_owned(), + child_id: HIDDEN_ROOT_LEAF_ID, + }, + TabRecord { + title: "workspace".to_owned(), + child_id: ROOT_SPLIT_ID, + }, + ], + }), + }, + buffer_view_node(HIDDEN_ROOT_LEAF_ID, Some(ROOT_TABS_ID), BufferId(1)), + NodeRecord { + id: ROOT_SPLIT_ID, + session_id: SESSION_ID, + parent_id: Some(ROOT_TABS_ID), + kind: NodeRecordKind::Split, + buffer_view: None, + split: Some(SplitRecord { + direction: SplitDirection::Vertical, + child_ids: vec![LEFT_LEAF_ID, NESTED_TABS_ID], + sizes: vec![1, 2], + }), + tabs: None, + }, + buffer_view_node(LEFT_LEAF_ID, Some(ROOT_SPLIT_ID), BufferId(2)), + NodeRecord { + id: NESTED_TABS_ID, + session_id: SESSION_ID, + parent_id: Some(ROOT_SPLIT_ID), + kind: NodeRecordKind::Tabs, + buffer_view: None, + split: None, + tabs: Some(TabsRecord { + active: 1, + tabs: vec![ + TabRecord { + title: "build".to_owned(), + child_id: HIDDEN_NESTED_LEAF_ID, + }, + TabRecord { + title: "logs-long-title".to_owned(), + child_id: FOCUSED_LEAF_ID, + }, + ], + }), + }, + buffer_view_node(HIDDEN_NESTED_LEAF_ID, Some(NESTED_TABS_ID), BufferId(3)), + buffer_view_node(FOCUSED_LEAF_ID, Some(NESTED_TABS_ID), FOCUSED_BUFFER_ID), + NodeRecord { + id: FLOATING_SPLIT_ID, + session_id: SESSION_ID, + parent_id: None, + kind: NodeRecordKind::Split, + buffer_view: None, + split: Some(SplitRecord { + direction: SplitDirection::Horizontal, + child_ids: vec![FLOATING_TOP_LEAF_ID, FLOATING_BOTTOM_LEAF_ID], + sizes: vec![1, 1], + }), + tabs: None, + }, + buffer_view_node(FLOATING_TOP_LEAF_ID, Some(FLOATING_SPLIT_ID), BufferId(5)), + buffer_view_node( + FLOATING_BOTTOM_LEAF_ID, + Some(FLOATING_SPLIT_ID), + BufferId(6), + ), + ], + buffers: vec![ + buffer(1, Some(HIDDEN_ROOT_LEAF_ID), "shell", ActivityState::Idle), + buffer(2, Some(LEFT_LEAF_ID), "editor", ActivityState::Activity), + buffer(3, Some(HIDDEN_NESTED_LEAF_ID), "build", ActivityState::Bell), + buffer( + 4, + Some(FOCUSED_LEAF_ID), + "logs-long-title", + ActivityState::Idle, + ), + buffer( + 5, + Some(FLOATING_TOP_LEAF_ID), + "popup-top", + ActivityState::Idle, + ), + buffer( + 6, + Some(FLOATING_BOTTOM_LEAF_ID), + "popup-bottom", + ActivityState::Idle, + ), + ], + floating: vec![FloatingRecord { + id: FLOATING_ID, + session_id: SESSION_ID, + root_node_id: FLOATING_SPLIT_ID, + title: Some("popup".to_owned()), + geometry: FloatGeometry::new(14, 5, 20, 7), + focused: focused_floating_id == Some(FLOATING_ID), + visible: true, + close_on_empty: true, + }], + } +} + +fn root_buffer_snapshot() -> SessionSnapshot { + SessionSnapshot { + session: SessionRecord { + id: SESSION_ID, + name: "root-buffer".to_owned(), + root_node_id: ROOT_BUFFER_LEAF_ID, + floating_ids: Vec::new(), + focused_leaf_id: Some(ROOT_BUFFER_LEAF_ID), + focused_floating_id: None, + }, + nodes: vec![buffer_view_node(ROOT_BUFFER_LEAF_ID, None, BufferId(7))], + buffers: vec![buffer( + 7, + Some(ROOT_BUFFER_LEAF_ID), + "root-buffer", + ActivityState::Idle, + )], + floating: Vec::new(), + } +} + +fn root_split_snapshot() -> SessionSnapshot { + SessionSnapshot { + session: SessionRecord { + id: SESSION_ID, + name: "root-split".to_owned(), + root_node_id: ROOT_ONLY_SPLIT_ID, + floating_ids: Vec::new(), + focused_leaf_id: Some(ROOT_SPLIT_RIGHT_LEAF_ID), + focused_floating_id: None, + }, + nodes: vec![ + NodeRecord { + id: ROOT_ONLY_SPLIT_ID, + session_id: SESSION_ID, + parent_id: None, + kind: NodeRecordKind::Split, + buffer_view: None, + split: Some(SplitRecord { + direction: SplitDirection::Vertical, + child_ids: vec![ROOT_SPLIT_LEFT_LEAF_ID, ROOT_SPLIT_RIGHT_LEAF_ID], + sizes: vec![1, 1], + }), + tabs: None, + }, + buffer_view_node( + ROOT_SPLIT_LEFT_LEAF_ID, + Some(ROOT_ONLY_SPLIT_ID), + BufferId(7), + ), + buffer_view_node( + ROOT_SPLIT_RIGHT_LEAF_ID, + Some(ROOT_ONLY_SPLIT_ID), + BufferId(8), + ), + ], + buffers: vec![ + buffer( + 7, + Some(ROOT_SPLIT_LEFT_LEAF_ID), + "root-left", + ActivityState::Idle, + ), + buffer( + 8, + Some(ROOT_SPLIT_RIGHT_LEAF_ID), + "root-right", + ActivityState::Activity, + ), + ], + floating: Vec::new(), + } +} + +fn buffer_view_node(id: NodeId, parent_id: Option, buffer_id: BufferId) -> NodeRecord { + NodeRecord { + id, + session_id: SESSION_ID, + parent_id, + kind: NodeRecordKind::BufferView, + buffer_view: Some(BufferViewRecord { + buffer_id, + focused: false, + zoomed: false, + follow_output: true, + last_render_size: PtySize::new(80, 24), + }), + split: None, + tabs: None, + } +} + +fn buffer( + id: u64, + attachment_node_id: Option, + title: &str, + activity: ActivityState, +) -> BufferRecord { + BufferRecord { + id: BufferId(id), + title: title.to_owned(), + command: vec!["/bin/sh".to_owned()], + cwd: Some("/tmp".to_owned()), + pid: None, + env: Default::default(), + state: BufferRecordState::Running, + attachment_node_id, + pty_size: PtySize::new(80, 24), + activity, + last_snapshot_seq: 0, + exit_code: None, + } +} + +fn demo_snapshots() -> Vec { + vec![ + snapshot(2, ["left pane", "line two", "line three"]), + snapshot(4, ["logs visible", "second row", "third row"]), + snapshot(5, ["popup top"]), + snapshot(6, ["popup bottom"]), + ] +} + +fn snapshot(buffer_id: u64, lines: [&str; N]) -> VisibleSnapshotResponse { + VisibleSnapshotResponse { + request_id: embers_core::RequestId(0), + buffer_id: BufferId(buffer_id), + sequence: 1, + size: PtySize::new(80, 24), + lines: lines.into_iter().map(str::to_owned).collect(), + title: None, + cwd: None, + viewport_top_line: 0, + total_lines: 24, + alternate_screen: false, + mouse_reporting: false, + focus_reporting: false, + bracketed_paste: false, + cursor: None, + } +} diff --git a/crates/mux-core/Cargo.toml b/crates/embers-core/Cargo.toml similarity index 91% rename from crates/mux-core/Cargo.toml rename to crates/embers-core/Cargo.toml index f079e9c..705813f 100644 --- a/crates/mux-core/Cargo.toml +++ b/crates/embers-core/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "mux-core" +name = "embers-core" edition.workspace = true license.workspace = true rust-version.workspace = true diff --git a/crates/mux-core/src/diagnostics.rs b/crates/embers-core/src/diagnostics.rs similarity index 100% rename from crates/mux-core/src/diagnostics.rs rename to crates/embers-core/src/diagnostics.rs diff --git a/crates/mux-core/src/error.rs b/crates/embers-core/src/error.rs similarity index 96% rename from crates/mux-core/src/error.rs rename to crates/embers-core/src/error.rs index bb33975..778c0aa 100644 --- a/crates/mux-core/src/error.rs +++ b/crates/embers-core/src/error.rs @@ -88,6 +88,10 @@ impl MuxError { Self::InvalidInput(message.into()) } + pub fn not_found(message: impl Into) -> Self { + Self::NotFound(message.into()) + } + pub fn conflict(message: impl Into) -> Self { Self::Conflict(message.into()) } diff --git a/crates/mux-core/src/geometry.rs b/crates/embers-core/src/geometry.rs similarity index 100% rename from crates/mux-core/src/geometry.rs rename to crates/embers-core/src/geometry.rs diff --git a/crates/mux-core/src/ids.rs b/crates/embers-core/src/ids.rs similarity index 100% rename from crates/mux-core/src/ids.rs rename to crates/embers-core/src/ids.rs diff --git a/crates/mux-core/src/lib.rs b/crates/embers-core/src/lib.rs similarity index 82% rename from crates/mux-core/src/lib.rs rename to crates/embers-core/src/lib.rs index dc1b776..1e28088 100644 --- a/crates/mux-core/src/lib.rs +++ b/crates/embers-core/src/lib.rs @@ -13,4 +13,6 @@ pub use error::{ErrorCode, MuxError, Result, WireError}; pub use geometry::{FloatGeometry, Point, PtySize, Rect, Size, SplitDirection}; pub use ids::{BufferId, ClientId, FloatingId, IdAllocator, NodeId, RequestId, SessionId}; pub use metadata::{ActivityState, EntityMetadata, Timestamp}; -pub use snapshot::{CursorPosition, SnapshotLine, TerminalSnapshot}; +pub use snapshot::{ + CursorPosition, CursorShape, CursorState, SnapshotLine, TerminalModes, TerminalSnapshot, +}; diff --git a/crates/mux-core/src/metadata.rs b/crates/embers-core/src/metadata.rs similarity index 100% rename from crates/mux-core/src/metadata.rs rename to crates/embers-core/src/metadata.rs diff --git a/crates/mux-core/src/snapshot.rs b/crates/embers-core/src/snapshot.rs similarity index 68% rename from crates/mux-core/src/snapshot.rs rename to crates/embers-core/src/snapshot.rs index 3ddb1b8..345667f 100644 --- a/crates/mux-core/src/snapshot.rs +++ b/crates/embers-core/src/snapshot.rs @@ -8,6 +8,28 @@ pub struct CursorPosition { pub col: u16, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CursorShape { + #[default] + Block, + Underline, + Beam, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CursorState { + pub position: CursorPosition, + pub shape: CursorShape, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TerminalModes { + pub alternate_screen: bool, + pub mouse_reporting: bool, + pub focus_reporting: bool, + pub bracketed_paste: bool, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct SnapshotLine { pub text: String, @@ -25,10 +47,13 @@ impl From<&str> for SnapshotLine { pub struct TerminalSnapshot { pub sequence: u64, pub size: PtySize, - pub cursor: Option, + pub cursor: Option, pub lines: Vec, pub title: Option, pub cwd: Option, + pub viewport_top_line: u64, + pub total_lines: u64, + pub modes: TerminalModes, } impl TerminalSnapshot { @@ -47,6 +72,9 @@ impl TerminalSnapshot { .collect(), title: None, cwd: None, + viewport_top_line: 0, + total_lines: u64::from(size.rows), + modes: TerminalModes::default(), } } diff --git a/crates/mux-protocol/Cargo.toml b/crates/embers-protocol/Cargo.toml similarity index 80% rename from crates/mux-protocol/Cargo.toml rename to crates/embers-protocol/Cargo.toml index 53886b3..3f7cdd2 100644 --- a/crates/mux-protocol/Cargo.toml +++ b/crates/embers-protocol/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "mux-protocol" +name = "embers-protocol" build = "build.rs" edition.workspace = true license.workspace = true @@ -8,7 +8,7 @@ version.workspace = true [dependencies] flatbuffers.workspace = true -mux-core = { path = "../mux-core" } +embers-core = { path = "../embers-core" } thiserror.workspace = true tokio.workspace = true diff --git a/crates/mux-protocol/build.rs b/crates/embers-protocol/build.rs similarity index 89% rename from crates/mux-protocol/build.rs rename to crates/embers-protocol/build.rs index 231de86..b297fed 100644 --- a/crates/mux-protocol/build.rs +++ b/crates/embers-protocol/build.rs @@ -15,7 +15,7 @@ fn main() { .arg(&out_dir) .arg(&schema) .status() - .expect("flatc must be installed to build mux-protocol"); + .expect("flatc must be installed to build embers-protocol"); assert!(status.success(), "flatc failed for {}", schema.display()); } diff --git a/crates/embers-protocol/schema/embers.fbs b/crates/embers-protocol/schema/embers.fbs new file mode 100644 index 0000000..f902ef7 --- /dev/null +++ b/crates/embers-protocol/schema/embers.fbs @@ -0,0 +1,446 @@ +namespace embers.protocol; + +enum MessageKind : ubyte { + None = 0, + PingRequest = 1, + SessionRequest = 2, + BufferRequest = 3, + NodeRequest = 4, + FloatingRequest = 5, + InputRequest = 6, + SubscribeRequest = 7, + UnsubscribeRequest = 8, + + PingResponse = 20, + OkResponse = 21, + ErrorResponse = 22, + SessionsResponse = 23, + SessionSnapshotResponse = 24, + BuffersResponse = 25, + BufferResponse = 26, + FloatingListResponse = 27, + FloatingResponse = 28, + SubscriptionAckResponse = 29, + SnapshotResponse = 30, + VisibleSnapshotResponse = 31, + ScrollbackSliceResponse = 32, + + SessionCreatedEvent = 40, + SessionClosedEvent = 41, + BufferCreatedEvent = 42, + BufferDetachedEvent = 43, + NodeChangedEvent = 44, + FloatingChangedEvent = 45, + FocusChangedEvent = 46, + RenderInvalidatedEvent = 47, +} + +enum ErrorCodeWire : ubyte { + Unknown = 0, + InvalidRequest = 1, + ProtocolViolation = 2, + Transport = 3, + NotFound = 4, + Conflict = 5, + Unsupported = 6, + Timeout = 7, + Internal = 8, +} + +enum SessionOp : ubyte { + Create = 0, + List = 1, + Get = 2, + Close = 3, + AddRootTab = 4, + SelectRootTab = 5, + RenameRootTab = 6, + CloseRootTab = 7, +} + +enum BufferOp : ubyte { + Create = 0, + List = 1, + Get = 2, + Detach = 3, + Kill = 4, + Capture = 5, + CaptureVisible = 6, + ScrollbackSlice = 7, +} + +enum NodeOp : ubyte { + GetTree = 0, + Split = 1, + WrapInTabs = 2, + AddTab = 3, + SelectTab = 4, + Focus = 5, + Close = 6, + MoveBufferToNode = 7, + Resize = 8, + CreateSplit = 9, + CreateTabs = 10, + ReplaceNode = 11, + WrapInSplit = 12, +} + +enum FloatingOp : ubyte { + Create = 0, + Close = 1, + Move = 2, + Focus = 3, +} + +enum InputOp : ubyte { + Send = 0, + Resize = 1, +} + +enum SplitDirectionWire : ubyte { + Horizontal = 0, + Vertical = 1, +} + +enum ActivityStateWire : ubyte { + Idle = 0, + Activity = 1, + Bell = 2, +} + +enum BufferStateWire : ubyte { + Created = 0, + Running = 1, + Exited = 2, +} + +enum NodeRecordKindWire : ubyte { + BufferView = 0, + Split = 1, + Tabs = 2, +} + +enum CursorShapeWire : ubyte { + Block = 0, + Underline = 1, + Beam = 2, +} + +table PingRequest { + payload:string; +} + +table SessionRequest { + op:SessionOp = Create; + session_id:ulong = 0; + buffer_id:ulong = 0; + child_node_id:ulong = 0; + name:string; + title:string; + force:bool = false; + index:uint = 0; +} + +table BufferRequest { + op:BufferOp = Create; + buffer_id:ulong = 0; + session_id:ulong = 0; + attached_only:bool = false; + detached_only:bool = false; + force:bool = false; + start_line:ulong = 0; + line_count:uint = 0; + title:string; + command:[string]; + cwd:string; + env_keys:[string]; + env_values:[string]; +} + +table NodeRequest { + op:NodeOp = GetTree; + session_id:ulong = 0; + node_id:ulong = 0; + leaf_node_id:ulong = 0; + tabs_node_id:ulong = 0; + child_node_id:ulong = 0; + target_leaf_node_id:ulong = 0; + buffer_id:ulong = 0; + new_buffer_id:ulong = 0; + title:string; + index:uint = 0; + active:uint = 0; + direction:SplitDirectionWire = Horizontal; + sizes:[ushort]; + child_node_ids:[ulong]; + titles:[string]; + insert_before:bool = false; +} + +table FloatingRequest { + op:FloatingOp = Create; + floating_id:ulong = 0; + session_id:ulong = 0; + root_node_id:ulong = 0; + buffer_id:ulong = 0; + title:string; + x:ushort = 0; + y:ushort = 0; + width:ushort = 0; + height:ushort = 0; + focus:bool = true; + close_on_empty:bool = true; +} + +table InputRequest { + op:InputOp = Send; + buffer_id:ulong = 0; + bytes:[ubyte]; + cols:ushort = 0; + rows:ushort = 0; +} + +table SubscribeRequest { + session_id:ulong = 0; +} + +table UnsubscribeRequest { + subscription_id:ulong; +} + +table PingResponse { + payload:string; +} + +table OkResponse {} + +table ErrorResponse { + code:ErrorCodeWire = Unknown; + message:string; +} + +table SessionRecord { + id:ulong; + name:string; + root_node_id:ulong; + floating_ids:[ulong]; + focused_leaf_id:ulong = 0; + focused_floating_id:ulong = 0; +} + +table BufferRecord { + id:ulong; + title:string; + command:[string]; + cwd:string; + state:BufferStateWire = Created; + pid:uint = 0; + has_pid:bool = false; + attachment_node_id:ulong = 0; + pty_cols:ushort = 0; + pty_rows:ushort = 0; + activity:ActivityStateWire = Idle; + last_snapshot_seq:ulong = 0; + exit_code:int = 0; + has_exit_code:bool = false; + env_keys:[string]; + env_values:[string]; +} + +table BufferViewRecord { + buffer_id:ulong; + focused:bool = false; + zoomed:bool = false; + follow_output:bool = false; + last_render_cols:ushort = 0; + last_render_rows:ushort = 0; +} + +table SplitRecord { + direction:SplitDirectionWire = Horizontal; + child_ids:[ulong]; + sizes:[ushort]; +} + +table TabRecord { + title:string; + child_id:ulong; +} + +table TabsRecord { + active:uint = 0; + tabs:[TabRecord]; +} + +table NodeRecord { + id:ulong; + session_id:ulong; + parent_id:ulong = 0; + kind:NodeRecordKindWire = BufferView; + buffer_view:BufferViewRecord; + split:SplitRecord; + tabs:TabsRecord; +} + +table FloatingRecord { + id:ulong; + session_id:ulong; + root_node_id:ulong; + title:string; + x:ushort = 0; + y:ushort = 0; + width:ushort = 0; + height:ushort = 0; + focused:bool = false; + visible:bool = true; + close_on_empty:bool = true; +} + +table SessionSnapshot { + session:SessionRecord; + nodes:[NodeRecord]; + buffers:[BufferRecord]; + floating:[FloatingRecord]; +} + +table SessionsResponse { + sessions:[SessionRecord]; +} + +table SessionSnapshotResponse { + snapshot:SessionSnapshot; +} + +table BuffersResponse { + buffers:[BufferRecord]; +} + +table BufferResponse { + buffer:BufferRecord; +} + +table FloatingListResponse { + floating:[FloatingRecord]; +} + +table FloatingResponse { + floating:FloatingRecord; +} + +table SubscriptionAckResponse { + subscription_id:ulong; +} + +table CursorState { + row:ushort = 0; + col:ushort = 0; + shape:CursorShapeWire = Block; +} + +table SnapshotResponse { + buffer_id:ulong; + sequence:ulong = 0; + cols:ushort = 0; + rows:ushort = 0; + lines:[string]; + title:string; + cwd:string; +} + +table VisibleSnapshotResponse { + buffer_id:ulong; + sequence:ulong = 0; + cols:ushort = 0; + rows:ushort = 0; + lines:[string]; + title:string; + cwd:string; + viewport_top_line:ulong = 0; + total_lines:ulong = 0; + alternate_screen:bool = false; + mouse_reporting:bool = false; + focus_reporting:bool = false; + bracketed_paste:bool = false; + cursor:CursorState; +} + +table ScrollbackSliceResponse { + buffer_id:ulong; + start_line:ulong = 0; + total_lines:ulong = 0; + lines:[string]; +} + +table SessionCreatedEvent { + session:SessionRecord; +} + +table SessionClosedEvent { + session_id:ulong; +} + +table BufferCreatedEvent { + buffer:BufferRecord; +} + +table BufferDetachedEvent { + buffer_id:ulong; +} + +table NodeChangedEvent { + session_id:ulong; +} + +table FloatingChangedEvent { + session_id:ulong; + floating_id:ulong = 0; +} + +table FocusChangedEvent { + session_id:ulong; + focused_leaf_id:ulong = 0; + focused_floating_id:ulong = 0; +} + +table RenderInvalidatedEvent { + buffer_id:ulong; +} + +table Envelope { + request_id:ulong = 0; + kind:MessageKind = None; + ping_request:PingRequest; + session_request:SessionRequest; + buffer_request:BufferRequest; + node_request:NodeRequest; + floating_request:FloatingRequest; + input_request:InputRequest; + subscribe_request:SubscribeRequest; + unsubscribe_request:UnsubscribeRequest; + + ping_response:PingResponse; + ok_response:OkResponse; + error_response:ErrorResponse; + sessions_response:SessionsResponse; + session_snapshot_response:SessionSnapshotResponse; + buffers_response:BuffersResponse; + buffer_response:BufferResponse; + floating_list_response:FloatingListResponse; + floating_response:FloatingResponse; + subscription_ack_response:SubscriptionAckResponse; + snapshot_response:SnapshotResponse; + visible_snapshot_response:VisibleSnapshotResponse; + scrollback_slice_response:ScrollbackSliceResponse; + + session_created_event:SessionCreatedEvent; + session_closed_event:SessionClosedEvent; + buffer_created_event:BufferCreatedEvent; + buffer_detached_event:BufferDetachedEvent; + node_changed_event:NodeChangedEvent; + floating_changed_event:FloatingChangedEvent; + focus_changed_event:FocusChangedEvent; + render_invalidated_event:RenderInvalidatedEvent; +} + +root_type Envelope; +file_identifier "EMBR"; diff --git a/crates/embers-protocol/src/client.rs b/crates/embers-protocol/src/client.rs new file mode 100644 index 0000000..81ad56b --- /dev/null +++ b/crates/embers-protocol/src/client.rs @@ -0,0 +1,156 @@ +use std::path::Path; + +use embers_core::RequestId; +use tokio::net::UnixStream; + +use crate::codec::{ProtocolError, decode_server_envelope, encode_client_message}; +use crate::framing::{FrameType, RawFrame, read_frame, write_frame}; +use crate::types::{ClientMessage, ServerEnvelope, ServerResponse}; + +#[derive(Debug)] +pub struct ProtocolClient { + stream: UnixStream, +} + +impl ProtocolClient { + pub async fn connect(path: impl AsRef) -> Result { + let stream = UnixStream::connect(path).await?; + Ok(Self { stream }) + } + + pub async fn send(&mut self, message: &ClientMessage) -> Result<(), ProtocolError> { + let payload = encode_client_message(message)?; + let frame = RawFrame::new(FrameType::Request, message.request_id(), payload); + write_frame(&mut self.stream, &frame).await + } + + pub async fn recv(&mut self) -> Result, ProtocolError> { + let Some(frame) = read_frame(&mut self.stream).await? else { + return Ok(None); + }; + + let envelope = decode_server_envelope(&frame.payload)?; + + match (frame.frame_type, envelope) { + (FrameType::Response, ServerEnvelope::Response(response)) => { + let response_id = response.request_id().unwrap_or(RequestId(0)); + if response_id != frame.request_id { + return Err(ProtocolError::MismatchedRequestId { + expected: frame.request_id, + actual: response_id, + }); + } + Ok(Some(ServerEnvelope::Response(response))) + } + (FrameType::Event, ServerEnvelope::Event(event)) => { + if frame.request_id != RequestId(0) { + return Err(ProtocolError::MismatchedRequestId { + expected: RequestId(0), + actual: frame.request_id, + }); + } + Ok(Some(ServerEnvelope::Event(event))) + } + (FrameType::Response, ServerEnvelope::Event(_)) => { + Err(ProtocolError::UnexpectedFrameKind { + frame_type: FrameType::Response, + envelope_kind: "event", + }) + } + (FrameType::Event, ServerEnvelope::Response(_)) => { + Err(ProtocolError::UnexpectedFrameKind { + frame_type: FrameType::Event, + envelope_kind: "response", + }) + } + (FrameType::Request, _) => Err(ProtocolError::UnexpectedFrameType(FrameType::Request)), + } + } + + pub async fn request( + &mut self, + message: &ClientMessage, + ) -> Result { + let request_id = message.request_id(); + self.send(message).await?; + + loop { + match self.recv().await? { + Some(ServerEnvelope::Response(response)) => match response.request_id() { + Some(response_id) if response_id != request_id => { + return Err(ProtocolError::MismatchedRequestId { + expected: request_id, + actual: response_id, + }); + } + _ => { + return Ok(response); + } + }, + Some(ServerEnvelope::Event(_)) => continue, + None => { + return Err(ProtocolError::InvalidMessage( + "connection closed before response", + )); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::ProtocolClient; + use embers_core::{ErrorCode, RequestId, WireError}; + use tokio::net::UnixStream; + + use crate::codec::encode_server_envelope; + use crate::framing::{FrameType, RawFrame, read_frame, write_frame}; + use crate::types::{ClientMessage, ErrorResponse, PingRequest, ServerEnvelope, ServerResponse}; + + #[tokio::test] + async fn request_accepts_unscoped_error_response() { + let (mut server, client_stream) = UnixStream::pair().expect("create unix stream pair"); + let mut client = ProtocolClient { + stream: client_stream, + }; + + let request = ClientMessage::Ping(PingRequest { + request_id: RequestId(7), + payload: "phase2".to_owned(), + }); + + let server_task = tokio::spawn(async move { + let frame = read_frame(&mut server) + .await + .expect("read request frame") + .expect("request frame"); + assert_eq!(frame.frame_type, FrameType::Request); + assert_eq!(frame.request_id, RequestId(7)); + + let payload = encode_server_envelope(&ServerEnvelope::Response(ServerResponse::Error( + ErrorResponse { + request_id: None, + error: WireError::new(ErrorCode::ProtocolViolation, "bad request"), + }, + ))) + .expect("encode error response"); + let frame = RawFrame::new(FrameType::Response, RequestId(0), payload); + write_frame(&mut server, &frame) + .await + .expect("write response frame"); + }); + + let response = client.request(&request).await.expect("receive response"); + match response { + ServerResponse::Error(response) => { + assert_eq!(response.request_id, None); + assert_eq!(response.error.code, ErrorCode::ProtocolViolation); + assert_eq!(response.error.message, "bad request"); + } + other => panic!("expected error response, got {other:?}"), + } + + server_task.await.expect("server task joins"); + } +} diff --git a/crates/embers-protocol/src/codec.rs b/crates/embers-protocol/src/codec.rs new file mode 100644 index 0000000..2aef7f9 --- /dev/null +++ b/crates/embers-protocol/src/codec.rs @@ -0,0 +1,2664 @@ +use embers_core::{ + ActivityState, BufferId, CursorShape, CursorState, ErrorCode, FloatGeometry, FloatingId, + NodeId, PtySize, RequestId, SessionId, SplitDirection, WireError, +}; +use flatbuffers::FlatBufferBuilder; +use thiserror::Error; + +use crate::framing::FrameType; +use crate::generated::embers::protocol as fb; +use crate::types::*; + +#[derive(Debug, Error)] +pub enum ProtocolError { + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("flatbuffer decode error: {0}")] + InvalidFlatbuffer(#[from] flatbuffers::InvalidFlatbuffer), + #[error("invalid message: {0}")] + InvalidMessage(&'static str), + #[error("invalid message: {0}")] + InvalidMessageOwned(String), + #[error("frame exceeds max length: {0}")] + FrameTooLarge(usize), + #[error("invalid frame type: {0}")] + InvalidFrameType(u8), + #[error("unexpected frame type: {0:?}")] + UnexpectedFrameType(FrameType), + #[error("frame type {frame_type:?} cannot carry a {envelope_kind}")] + UnexpectedFrameKind { + frame_type: FrameType, + envelope_kind: &'static str, + }, + #[error("mismatched request id: expected {expected}, got {actual}")] + MismatchedRequestId { + expected: RequestId, + actual: RequestId, + }, +} + +fn required(value: Option, field: &'static str) -> Result { + value.ok_or(ProtocolError::InvalidMessage(field)) +} + +fn create_string_vector<'a>( + builder: &mut FlatBufferBuilder<'a>, + values: &[String], +) -> flatbuffers::WIPOffset>> { + let strings: Vec<_> = values + .iter() + .map(|value| builder.create_string(value)) + .collect(); + builder.create_vector(&strings) +} + +fn decode_string_map( + keys: Option>>, + values: Option>>, + field: &'static str, +) -> Result, ProtocolError> { + let Some(keys) = keys else { + return Ok(std::collections::BTreeMap::new()); + }; + let Some(values) = values else { + return Err(ProtocolError::InvalidMessageOwned(format!( + "{field} is missing matching values" + ))); + }; + if keys.len() != values.len() { + return Err(ProtocolError::InvalidMessageOwned(format!( + "{field} has mismatched key/value lengths" + ))); + } + Ok(keys + .iter() + .zip(values.iter()) + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect()) +} + +fn encode_cursor_state<'a>( + builder: &mut FlatBufferBuilder<'a>, + cursor: &CursorState, +) -> flatbuffers::WIPOffset> { + let shape = match cursor.shape { + CursorShape::Block => fb::CursorShapeWire::Block, + CursorShape::Underline => fb::CursorShapeWire::Underline, + CursorShape::Beam => fb::CursorShapeWire::Beam, + }; + fb::CursorState::create( + builder, + &fb::CursorStateArgs { + row: cursor.position.row, + col: cursor.position.col, + shape, + }, + ) +} + +fn decode_cursor_state(cursor: fb::CursorState<'_>) -> Result { + let shape = match cursor.shape() { + fb::CursorShapeWire::Block => CursorShape::Block, + fb::CursorShapeWire::Underline => CursorShape::Underline, + fb::CursorShapeWire::Beam => CursorShape::Beam, + _ => return Err(ProtocolError::InvalidMessage("unknown cursor shape")), + }; + Ok(CursorState { + position: embers_core::CursorPosition { + row: cursor.row(), + col: cursor.col(), + }, + shape, + }) +} + +// ==================== ENCODING ==================== + +pub fn encode_client_message(message: &ClientMessage) -> Result, ProtocolError> { + let mut builder = FlatBufferBuilder::new(); + + let envelope = match message { + ClientMessage::Ping(req) => encode_ping_request(&mut builder, req), + ClientMessage::Session(req) => encode_session_request(&mut builder, req), + ClientMessage::Buffer(req) => encode_buffer_request(&mut builder, req), + ClientMessage::Node(req) => encode_node_request(&mut builder, req), + ClientMessage::Floating(req) => encode_floating_request(&mut builder, req), + ClientMessage::Input(req) => encode_input_request(&mut builder, req), + ClientMessage::Subscribe(req) => encode_subscribe_request(&mut builder, req), + ClientMessage::Unsubscribe(req) => encode_unsubscribe_request(&mut builder, req), + }; + + builder.finish(envelope, Some("EMBR")); + Ok(builder.finished_data().to_vec()) +} + +fn encode_ping_request<'a>( + builder: &mut FlatBufferBuilder<'a>, + req: &PingRequest, +) -> flatbuffers::WIPOffset> { + let payload = builder.create_string(&req.payload); + let ping_request = fb::PingRequest::create( + builder, + &fb::PingRequestArgs { + payload: Some(payload), + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: req.request_id.into(), + kind: fb::MessageKind::PingRequest, + ping_request: Some(ping_request), + ..Default::default() + }, + ) +} + +fn encode_session_request<'a>( + builder: &mut FlatBufferBuilder<'a>, + req: &SessionRequest, +) -> flatbuffers::WIPOffset> { + let (op, session_id, buffer_id, child_node_id, name_str, title_str, force, index) = match req { + SessionRequest::Create { name, .. } => ( + fb::SessionOp::Create, + 0, + 0, + 0, + Some(name.as_str()), + None, + false, + 0, + ), + SessionRequest::List { .. } => (fb::SessionOp::List, 0, 0, 0, None, None, false, 0), + SessionRequest::Get { session_id, .. } => ( + fb::SessionOp::Get, + (*session_id).into(), + 0, + 0, + None, + None, + false, + 0, + ), + SessionRequest::Close { + session_id, force, .. + } => ( + fb::SessionOp::Close, + (*session_id).into(), + 0, + 0, + None, + None, + *force, + 0, + ), + SessionRequest::AddRootTab { + session_id, + title, + buffer_id, + child_node_id, + .. + } => ( + fb::SessionOp::AddRootTab, + (*session_id).into(), + buffer_id.map_or(0, u64::from), + child_node_id.map_or(0, u64::from), + None, + Some(title.as_str()), + false, + 0, + ), + SessionRequest::SelectRootTab { + session_id, index, .. + } => ( + fb::SessionOp::SelectRootTab, + (*session_id).into(), + 0, + 0, + None, + None, + false, + *index, + ), + SessionRequest::RenameRootTab { + session_id, + index, + title, + .. + } => ( + fb::SessionOp::RenameRootTab, + (*session_id).into(), + 0, + 0, + None, + Some(title.as_str()), + false, + *index, + ), + SessionRequest::CloseRootTab { + session_id, index, .. + } => ( + fb::SessionOp::CloseRootTab, + (*session_id).into(), + 0, + 0, + None, + None, + false, + *index, + ), + }; + + let name = name_str.map(|s| builder.create_string(s)); + let title = title_str.map(|s| builder.create_string(s)); + let session_req = fb::SessionRequest::create( + builder, + &fb::SessionRequestArgs { + op, + session_id, + buffer_id, + child_node_id, + name, + title, + force, + index, + }, + ); + + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: req.request_id().into(), + kind: fb::MessageKind::SessionRequest, + session_request: Some(session_req), + ..Default::default() + }, + ) +} + +fn encode_buffer_request<'a>( + builder: &mut FlatBufferBuilder<'a>, + req: &BufferRequest, +) -> flatbuffers::WIPOffset> { + let ( + op, + buffer_id, + session_id, + attached_only, + detached_only, + force, + start_line, + line_count, + title_str, + command_vec, + cwd_str, + env_entries, + ) = match req { + BufferRequest::Create { + title, + command, + cwd, + env, + .. + } => ( + fb::BufferOp::Create, + 0, + 0, + false, + false, + false, + 0, + 0, + title.as_deref(), + Some(command), + cwd.as_deref(), + Some(env), + ), + BufferRequest::List { + session_id, + attached_only, + detached_only, + .. + } => ( + fb::BufferOp::List, + 0, + session_id.map(|s| s.into()).unwrap_or(0), + *attached_only, + *detached_only, + false, + 0, + 0, + None, + None, + None, + None, + ), + BufferRequest::Get { buffer_id, .. } => ( + fb::BufferOp::Get, + (*buffer_id).into(), + 0, + false, + false, + false, + 0, + 0, + None, + None, + None, + None, + ), + BufferRequest::Detach { buffer_id, .. } => ( + fb::BufferOp::Detach, + (*buffer_id).into(), + 0, + false, + false, + false, + 0, + 0, + None, + None, + None, + None, + ), + BufferRequest::Kill { + buffer_id, force, .. + } => ( + fb::BufferOp::Kill, + (*buffer_id).into(), + 0, + false, + false, + *force, + 0, + 0, + None, + None, + None, + None, + ), + BufferRequest::Capture { buffer_id, .. } => ( + fb::BufferOp::Capture, + (*buffer_id).into(), + 0, + false, + false, + false, + 0, + 0, + None, + None, + None, + None, + ), + BufferRequest::CaptureVisible { buffer_id, .. } => ( + fb::BufferOp::CaptureVisible, + (*buffer_id).into(), + 0, + false, + false, + false, + 0, + 0, + None, + None, + None, + None, + ), + BufferRequest::ScrollbackSlice { + buffer_id, + start_line, + line_count, + .. + } => ( + fb::BufferOp::ScrollbackSlice, + (*buffer_id).into(), + 0, + false, + false, + false, + *start_line, + *line_count, + None, + None, + None, + None, + ), + }; + + let title = title_str.map(|s| builder.create_string(s)); + let cwd = cwd_str.map(|s| builder.create_string(s)); + let command = command_vec.map(|cmd_vec| { + let strings: Vec<_> = cmd_vec.iter().map(|s| builder.create_string(s)).collect(); + builder.create_vector(&strings) + }); + let env_keys = env_entries.map(|env| { + let keys = env.keys().cloned().collect::>(); + create_string_vector(builder, &keys) + }); + let env_values = env_entries.map(|env| { + let values = env.values().cloned().collect::>(); + create_string_vector(builder, &values) + }); + + let buffer_req = fb::BufferRequest::create( + builder, + &fb::BufferRequestArgs { + op, + buffer_id, + session_id, + attached_only, + detached_only, + force, + start_line, + line_count, + title, + command, + cwd, + env_keys, + env_values, + }, + ); + + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: req.request_id().into(), + kind: fb::MessageKind::BufferRequest, + buffer_request: Some(buffer_req), + ..Default::default() + }, + ) +} + +fn encode_node_request<'a>( + builder: &mut FlatBufferBuilder<'a>, + req: &NodeRequest, +) -> flatbuffers::WIPOffset> { + type EncodedNodeRequest<'a> = ( + fb::NodeOp, + u64, + u64, + u64, + u64, + u64, + u64, + u64, + u64, + Option<&'a str>, + u32, + u32, + fb::SplitDirectionWire, + Option<&'a Vec>, + Option>, + Option>, + bool, + ); + + let ( + op, + session_id, + node_id, + leaf_node_id, + tabs_node_id, + child_node_id, + target_leaf_node_id, + buffer_id, + new_buffer_id, + title_str, + index, + active, + direction, + sizes_vec, + child_node_ids_vec, + titles_vec, + insert_before, + ): EncodedNodeRequest<'_> = match req { + NodeRequest::GetTree { session_id, .. } => ( + fb::NodeOp::GetTree, + (*session_id).into(), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + None, + 0, + 0, + fb::SplitDirectionWire::Horizontal, + None, + None, + None, + false, + ), + NodeRequest::Split { + leaf_node_id, + direction, + new_buffer_id, + .. + } => { + let dir = match direction { + SplitDirection::Horizontal => fb::SplitDirectionWire::Horizontal, + SplitDirection::Vertical => fb::SplitDirectionWire::Vertical, + }; + ( + fb::NodeOp::Split, + 0, + 0, + (*leaf_node_id).into(), + 0, + 0, + 0, + 0, + (*new_buffer_id).into(), + None, + 0, + 0, + dir, + None, + None, + None, + false, + ) + } + NodeRequest::CreateSplit { + session_id, + direction, + child_node_ids, + sizes, + .. + } => { + let dir = match direction { + SplitDirection::Horizontal => fb::SplitDirectionWire::Horizontal, + SplitDirection::Vertical => fb::SplitDirectionWire::Vertical, + }; + ( + fb::NodeOp::CreateSplit, + (*session_id).into(), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + None, + 0, + 0, + dir, + Some(sizes), + Some( + child_node_ids + .iter() + .map(|node_id| u64::from(*node_id)) + .collect::>(), + ), + None, + false, + ) + } + NodeRequest::CreateTabs { + session_id, + child_node_ids, + titles, + active, + .. + } => ( + fb::NodeOp::CreateTabs, + (*session_id).into(), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + None, + 0, + *active, + fb::SplitDirectionWire::Horizontal, + None, + Some( + child_node_ids + .iter() + .map(|node_id| u64::from(*node_id)) + .collect::>(), + ), + Some(titles.clone()), + false, + ), + NodeRequest::ReplaceNode { + node_id, + child_node_id, + .. + } => ( + fb::NodeOp::ReplaceNode, + 0, + (*node_id).into(), + 0, + 0, + (*child_node_id).into(), + 0, + 0, + 0, + None, + 0, + 0, + fb::SplitDirectionWire::Horizontal, + None, + None, + None, + false, + ), + NodeRequest::WrapInSplit { + node_id, + child_node_id, + direction, + insert_before, + .. + } => { + let dir = match direction { + SplitDirection::Horizontal => fb::SplitDirectionWire::Horizontal, + SplitDirection::Vertical => fb::SplitDirectionWire::Vertical, + }; + ( + fb::NodeOp::WrapInSplit, + 0, + (*node_id).into(), + 0, + 0, + (*child_node_id).into(), + 0, + 0, + 0, + None, + 0, + 0, + dir, + None, + None, + None, + *insert_before, + ) + } + NodeRequest::WrapInTabs { node_id, title, .. } => ( + fb::NodeOp::WrapInTabs, + 0, + (*node_id).into(), + 0, + 0, + 0, + 0, + 0, + 0, + Some(title.as_str()), + 0, + 0, + fb::SplitDirectionWire::Horizontal, + None, + None, + None, + false, + ), + NodeRequest::AddTab { + tabs_node_id, + title, + buffer_id, + child_node_id, + index, + .. + } => ( + fb::NodeOp::AddTab, + 0, + 0, + 0, + (*tabs_node_id).into(), + child_node_id.map_or(0, u64::from), + 0, + buffer_id.map_or(0, u64::from), + 0, + Some(title.as_str()), + *index, + 0, + fb::SplitDirectionWire::Horizontal, + None, + None, + None, + false, + ), + NodeRequest::SelectTab { + tabs_node_id, + index, + .. + } => ( + fb::NodeOp::SelectTab, + 0, + 0, + 0, + (*tabs_node_id).into(), + 0, + 0, + 0, + 0, + None, + *index, + 0, + fb::SplitDirectionWire::Horizontal, + None, + None, + None, + false, + ), + NodeRequest::Focus { + session_id, + node_id, + .. + } => ( + fb::NodeOp::Focus, + (*session_id).into(), + (*node_id).into(), + 0, + 0, + 0, + 0, + 0, + 0, + None, + 0, + 0, + fb::SplitDirectionWire::Horizontal, + None, + None, + None, + false, + ), + NodeRequest::Close { node_id, .. } => ( + fb::NodeOp::Close, + 0, + (*node_id).into(), + 0, + 0, + 0, + 0, + 0, + 0, + None, + 0, + 0, + fb::SplitDirectionWire::Horizontal, + None, + None, + None, + false, + ), + NodeRequest::MoveBufferToNode { + buffer_id, + target_leaf_node_id, + .. + } => ( + fb::NodeOp::MoveBufferToNode, + 0, + 0, + 0, + 0, + 0, + (*target_leaf_node_id).into(), + (*buffer_id).into(), + 0, + None, + 0, + 0, + fb::SplitDirectionWire::Horizontal, + None, + None, + None, + false, + ), + NodeRequest::Resize { node_id, sizes, .. } => ( + fb::NodeOp::Resize, + 0, + (*node_id).into(), + 0, + 0, + 0, + 0, + 0, + 0, + None, + 0, + 0, + fb::SplitDirectionWire::Horizontal, + Some(sizes), + None, + None, + false, + ), + }; + + let title = title_str.map(|s| builder.create_string(s)); + let sizes = sizes_vec.map(|sizes| builder.create_vector(sizes)); + let child_node_ids = child_node_ids_vec.map(|ids| builder.create_vector(&ids)); + let titles = titles_vec.map(|values| create_string_vector(builder, &values)); + let node_req = fb::NodeRequest::create( + builder, + &fb::NodeRequestArgs { + op, + session_id, + node_id, + leaf_node_id, + tabs_node_id, + child_node_id, + target_leaf_node_id, + buffer_id, + new_buffer_id, + title, + index, + active, + direction, + sizes, + child_node_ids, + titles, + insert_before, + }, + ); + + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: req.request_id().into(), + kind: fb::MessageKind::NodeRequest, + node_request: Some(node_req), + ..Default::default() + }, + ) +} + +fn encode_floating_request<'a>( + builder: &mut FlatBufferBuilder<'a>, + req: &FloatingRequest, +) -> flatbuffers::WIPOffset> { + let ( + op, + floating_id, + session_id, + root_node_id, + buffer_id, + title_str, + geom, + focus, + close_on_empty, + ) = match req { + FloatingRequest::Create { + session_id, + root_node_id, + buffer_id, + geometry, + title, + focus, + close_on_empty, + .. + } => ( + fb::FloatingOp::Create, + 0, + (*session_id).into(), + root_node_id.map_or(0, u64::from), + buffer_id.map_or(0, u64::from), + title.as_deref(), + Some(*geometry), + *focus, + *close_on_empty, + ), + FloatingRequest::Close { floating_id, .. } => ( + fb::FloatingOp::Close, + (*floating_id).into(), + 0, + 0, + 0, + None, + None, + true, + true, + ), + FloatingRequest::Move { + floating_id, + geometry, + .. + } => ( + fb::FloatingOp::Move, + (*floating_id).into(), + 0, + 0, + 0, + None, + Some(*geometry), + true, + true, + ), + FloatingRequest::Focus { floating_id, .. } => ( + fb::FloatingOp::Focus, + (*floating_id).into(), + 0, + 0, + 0, + None, + None, + true, + true, + ), + }; + + let title = title_str.map(|s| builder.create_string(s)); + let (x, y, width, height) = geom + .map(|g| (g.x, g.y, g.width, g.height)) + .unwrap_or((0, 0, 0, 0)); + + let floating_req = fb::FloatingRequest::create( + builder, + &fb::FloatingRequestArgs { + op, + floating_id, + session_id, + root_node_id, + buffer_id, + title, + x, + y, + width, + height, + focus, + close_on_empty, + }, + ); + + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: req.request_id().into(), + kind: fb::MessageKind::FloatingRequest, + floating_request: Some(floating_req), + ..Default::default() + }, + ) +} + +fn encode_input_request<'a>( + builder: &mut FlatBufferBuilder<'a>, + req: &InputRequest, +) -> flatbuffers::WIPOffset> { + let (op, buffer_id, bytes_vec, cols, rows) = match req { + InputRequest::Send { + buffer_id, bytes, .. + } => (fb::InputOp::Send, (*buffer_id).into(), Some(bytes), 0, 0), + InputRequest::Resize { + buffer_id, + cols, + rows, + .. + } => (fb::InputOp::Resize, (*buffer_id).into(), None, *cols, *rows), + }; + + let bytes = bytes_vec.map(|b| builder.create_vector(b)); + let input_req = fb::InputRequest::create( + builder, + &fb::InputRequestArgs { + op, + buffer_id, + bytes, + cols, + rows, + }, + ); + + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: req.request_id().into(), + kind: fb::MessageKind::InputRequest, + input_request: Some(input_req), + ..Default::default() + }, + ) +} + +fn encode_subscribe_request<'a>( + builder: &mut FlatBufferBuilder<'a>, + req: &SubscribeRequest, +) -> flatbuffers::WIPOffset> { + let subscribe_req = fb::SubscribeRequest::create( + builder, + &fb::SubscribeRequestArgs { + session_id: req.session_id.map(|s| s.into()).unwrap_or(0), + }, + ); + + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: req.request_id.into(), + kind: fb::MessageKind::SubscribeRequest, + subscribe_request: Some(subscribe_req), + ..Default::default() + }, + ) +} + +fn encode_unsubscribe_request<'a>( + builder: &mut FlatBufferBuilder<'a>, + req: &UnsubscribeRequest, +) -> flatbuffers::WIPOffset> { + let unsubscribe_req = fb::UnsubscribeRequest::create( + builder, + &fb::UnsubscribeRequestArgs { + subscription_id: req.subscription_id, + }, + ); + + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: req.request_id.into(), + kind: fb::MessageKind::UnsubscribeRequest, + unsubscribe_request: Some(unsubscribe_req), + ..Default::default() + }, + ) +} + +pub fn encode_server_envelope(envelope: &ServerEnvelope) -> Result, ProtocolError> { + let mut builder = FlatBufferBuilder::new(); + + let fb_envelope = match envelope { + ServerEnvelope::Response(response) => encode_server_response(&mut builder, response), + ServerEnvelope::Event(event) => encode_server_event(&mut builder, event), + }; + + builder.finish(fb_envelope, Some("EMBR")); + Ok(builder.finished_data().to_vec()) +} + +fn encode_server_response<'a>( + builder: &mut FlatBufferBuilder<'a>, + response: &ServerResponse, +) -> flatbuffers::WIPOffset> { + match response { + ServerResponse::Pong(r) => { + let payload = builder.create_string(&r.payload); + let pong = fb::PingResponse::create( + builder, + &fb::PingResponseArgs { + payload: Some(payload), + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: r.request_id.into(), + kind: fb::MessageKind::PingResponse, + ping_response: Some(pong), + ..Default::default() + }, + ) + } + ServerResponse::Ok(r) => { + let ok = fb::OkResponse::create(builder, &fb::OkResponseArgs {}); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: r.request_id.into(), + kind: fb::MessageKind::OkResponse, + ok_response: Some(ok), + ..Default::default() + }, + ) + } + ServerResponse::Error(r) => { + let msg = builder.create_string(&r.error.message); + let err = fb::ErrorResponse::create( + builder, + &fb::ErrorResponseArgs { + code: encode_error_code(r.error.code), + message: Some(msg), + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: r.request_id.map(|r| r.into()).unwrap_or(0), + kind: fb::MessageKind::ErrorResponse, + error_response: Some(err), + ..Default::default() + }, + ) + } + ServerResponse::Sessions(r) => { + let sessions_vec: Vec<_> = r + .sessions + .iter() + .map(|s| encode_session_record(builder, s)) + .collect(); + let sessions = builder.create_vector(&sessions_vec); + let sessions_resp = fb::SessionsResponse::create( + builder, + &fb::SessionsResponseArgs { + sessions: Some(sessions), + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: r.request_id.into(), + kind: fb::MessageKind::SessionsResponse, + sessions_response: Some(sessions_resp), + ..Default::default() + }, + ) + } + ServerResponse::SessionSnapshot(r) => { + let snapshot = encode_session_snapshot(builder, &r.snapshot); + let snapshot_resp = fb::SessionSnapshotResponse::create( + builder, + &fb::SessionSnapshotResponseArgs { + snapshot: Some(snapshot), + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: r.request_id.into(), + kind: fb::MessageKind::SessionSnapshotResponse, + session_snapshot_response: Some(snapshot_resp), + ..Default::default() + }, + ) + } + ServerResponse::Buffers(r) => { + let buffers_vec: Vec<_> = r + .buffers + .iter() + .map(|b| encode_buffer_record(builder, b)) + .collect(); + let buffers = builder.create_vector(&buffers_vec); + let buffers_resp = fb::BuffersResponse::create( + builder, + &fb::BuffersResponseArgs { + buffers: Some(buffers), + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: r.request_id.into(), + kind: fb::MessageKind::BuffersResponse, + buffers_response: Some(buffers_resp), + ..Default::default() + }, + ) + } + ServerResponse::Buffer(r) => { + let buffer = encode_buffer_record(builder, &r.buffer); + let buffer_resp = fb::BufferResponse::create( + builder, + &fb::BufferResponseArgs { + buffer: Some(buffer), + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: r.request_id.into(), + kind: fb::MessageKind::BufferResponse, + buffer_response: Some(buffer_resp), + ..Default::default() + }, + ) + } + ServerResponse::FloatingList(r) => { + let floating_vec: Vec<_> = r + .floating + .iter() + .map(|f| encode_floating_record(builder, f)) + .collect(); + let floating = builder.create_vector(&floating_vec); + let floating_resp = fb::FloatingListResponse::create( + builder, + &fb::FloatingListResponseArgs { + floating: Some(floating), + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: r.request_id.into(), + kind: fb::MessageKind::FloatingListResponse, + floating_list_response: Some(floating_resp), + ..Default::default() + }, + ) + } + ServerResponse::Floating(r) => { + let floating = encode_floating_record(builder, &r.floating); + let floating_resp = fb::FloatingResponse::create( + builder, + &fb::FloatingResponseArgs { + floating: Some(floating), + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: r.request_id.into(), + kind: fb::MessageKind::FloatingResponse, + floating_response: Some(floating_resp), + ..Default::default() + }, + ) + } + ServerResponse::SubscriptionAck(r) => { + let ack = fb::SubscriptionAckResponse::create( + builder, + &fb::SubscriptionAckResponseArgs { + subscription_id: r.subscription_id, + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: r.request_id.into(), + kind: fb::MessageKind::SubscriptionAckResponse, + subscription_ack_response: Some(ack), + ..Default::default() + }, + ) + } + ServerResponse::Snapshot(r) => { + let title = r.title.as_ref().map(|t| builder.create_string(t)); + let cwd = r.cwd.as_ref().map(|c| builder.create_string(c)); + let lines_vec: Vec<_> = r.lines.iter().map(|l| builder.create_string(l)).collect(); + let lines = builder.create_vector(&lines_vec); + let snapshot = fb::SnapshotResponse::create( + builder, + &fb::SnapshotResponseArgs { + buffer_id: r.buffer_id.into(), + sequence: r.sequence, + cols: r.size.cols, + rows: r.size.rows, + lines: Some(lines), + title, + cwd, + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: r.request_id.into(), + kind: fb::MessageKind::SnapshotResponse, + snapshot_response: Some(snapshot), + ..Default::default() + }, + ) + } + ServerResponse::VisibleSnapshot(r) => { + let title = r.title.as_ref().map(|t| builder.create_string(t)); + let cwd = r.cwd.as_ref().map(|c| builder.create_string(c)); + let lines_vec: Vec<_> = r.lines.iter().map(|l| builder.create_string(l)).collect(); + let lines = builder.create_vector(&lines_vec); + let cursor = r + .cursor + .as_ref() + .map(|cursor| encode_cursor_state(builder, cursor)); + let snapshot = fb::VisibleSnapshotResponse::create( + builder, + &fb::VisibleSnapshotResponseArgs { + buffer_id: r.buffer_id.into(), + sequence: r.sequence, + cols: r.size.cols, + rows: r.size.rows, + lines: Some(lines), + title, + cwd, + viewport_top_line: r.viewport_top_line, + total_lines: r.total_lines, + alternate_screen: r.alternate_screen, + mouse_reporting: r.mouse_reporting, + focus_reporting: r.focus_reporting, + bracketed_paste: r.bracketed_paste, + cursor, + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: r.request_id.into(), + kind: fb::MessageKind::VisibleSnapshotResponse, + visible_snapshot_response: Some(snapshot), + ..Default::default() + }, + ) + } + ServerResponse::ScrollbackSlice(r) => { + let lines_vec: Vec<_> = r.lines.iter().map(|l| builder.create_string(l)).collect(); + let lines = builder.create_vector(&lines_vec); + let snapshot = fb::ScrollbackSliceResponse::create( + builder, + &fb::ScrollbackSliceResponseArgs { + buffer_id: r.buffer_id.into(), + start_line: r.start_line, + total_lines: r.total_lines, + lines: Some(lines), + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: r.request_id.into(), + kind: fb::MessageKind::ScrollbackSliceResponse, + scrollback_slice_response: Some(snapshot), + ..Default::default() + }, + ) + } + } +} + +fn encode_server_event<'a>( + builder: &mut FlatBufferBuilder<'a>, + event: &ServerEvent, +) -> flatbuffers::WIPOffset> { + match event { + ServerEvent::SessionCreated(e) => { + let session = encode_session_record(builder, &e.session); + let event = fb::SessionCreatedEvent::create( + builder, + &fb::SessionCreatedEventArgs { + session: Some(session), + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: 0, + kind: fb::MessageKind::SessionCreatedEvent, + session_created_event: Some(event), + ..Default::default() + }, + ) + } + ServerEvent::SessionClosed(e) => { + let event = fb::SessionClosedEvent::create( + builder, + &fb::SessionClosedEventArgs { + session_id: e.session_id.into(), + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: 0, + kind: fb::MessageKind::SessionClosedEvent, + session_closed_event: Some(event), + ..Default::default() + }, + ) + } + ServerEvent::BufferCreated(e) => { + let buffer = encode_buffer_record(builder, &e.buffer); + let event = fb::BufferCreatedEvent::create( + builder, + &fb::BufferCreatedEventArgs { + buffer: Some(buffer), + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: 0, + kind: fb::MessageKind::BufferCreatedEvent, + buffer_created_event: Some(event), + ..Default::default() + }, + ) + } + ServerEvent::BufferDetached(e) => { + let event = fb::BufferDetachedEvent::create( + builder, + &fb::BufferDetachedEventArgs { + buffer_id: e.buffer_id.into(), + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: 0, + kind: fb::MessageKind::BufferDetachedEvent, + buffer_detached_event: Some(event), + ..Default::default() + }, + ) + } + ServerEvent::NodeChanged(e) => { + let event = fb::NodeChangedEvent::create( + builder, + &fb::NodeChangedEventArgs { + session_id: e.session_id.into(), + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: 0, + kind: fb::MessageKind::NodeChangedEvent, + node_changed_event: Some(event), + ..Default::default() + }, + ) + } + ServerEvent::FloatingChanged(e) => { + let event = fb::FloatingChangedEvent::create( + builder, + &fb::FloatingChangedEventArgs { + session_id: e.session_id.into(), + floating_id: e.floating_id.map(|f| f.into()).unwrap_or(0), + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: 0, + kind: fb::MessageKind::FloatingChangedEvent, + floating_changed_event: Some(event), + ..Default::default() + }, + ) + } + ServerEvent::FocusChanged(e) => { + let event = fb::FocusChangedEvent::create( + builder, + &fb::FocusChangedEventArgs { + session_id: e.session_id.into(), + focused_leaf_id: e.focused_leaf_id.map(|n| n.into()).unwrap_or(0), + focused_floating_id: e.focused_floating_id.map(|f| f.into()).unwrap_or(0), + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: 0, + kind: fb::MessageKind::FocusChangedEvent, + focus_changed_event: Some(event), + ..Default::default() + }, + ) + } + ServerEvent::RenderInvalidated(e) => { + let event = fb::RenderInvalidatedEvent::create( + builder, + &fb::RenderInvalidatedEventArgs { + buffer_id: e.buffer_id.into(), + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: 0, + kind: fb::MessageKind::RenderInvalidatedEvent, + render_invalidated_event: Some(event), + ..Default::default() + }, + ) + } + } +} + +// ==================== RECORD ENCODING ==================== + +fn encode_session_record<'a>( + builder: &mut FlatBufferBuilder<'a>, + record: &SessionRecord, +) -> flatbuffers::WIPOffset> { + let name = builder.create_string(&record.name); + let floating_ids: Vec = record.floating_ids.iter().map(|&f| f.into()).collect(); + let floating_ids_vec = builder.create_vector(&floating_ids); + + fb::SessionRecord::create( + builder, + &fb::SessionRecordArgs { + id: record.id.into(), + name: Some(name), + root_node_id: record.root_node_id.into(), + floating_ids: Some(floating_ids_vec), + focused_leaf_id: record.focused_leaf_id.map(|n| n.into()).unwrap_or(0), + focused_floating_id: record.focused_floating_id.map(|f| f.into()).unwrap_or(0), + }, + ) +} + +fn encode_buffer_record<'a>( + builder: &mut FlatBufferBuilder<'a>, + record: &BufferRecord, +) -> flatbuffers::WIPOffset> { + let title = builder.create_string(&record.title); + let command_vec: Vec<_> = record + .command + .iter() + .map(|c| builder.create_string(c)) + .collect(); + let command = builder.create_vector(&command_vec); + let cwd = record.cwd.as_ref().map(|c| builder.create_string(c)); + let env_keys_vec = record.env.keys().cloned().collect::>(); + let env_values_vec = record.env.values().cloned().collect::>(); + let env_keys = create_string_vector(builder, &env_keys_vec); + let env_values = create_string_vector(builder, &env_values_vec); + + let state = match record.state { + BufferRecordState::Created => fb::BufferStateWire::Created, + BufferRecordState::Running => fb::BufferStateWire::Running, + BufferRecordState::Exited => fb::BufferStateWire::Exited, + }; + + let activity = match record.activity { + ActivityState::Idle => fb::ActivityStateWire::Idle, + ActivityState::Activity => fb::ActivityStateWire::Activity, + ActivityState::Bell => fb::ActivityStateWire::Bell, + }; + + fb::BufferRecord::create( + builder, + &fb::BufferRecordArgs { + id: record.id.into(), + title: Some(title), + command: Some(command), + cwd, + state, + pid: record.pid.unwrap_or(0), + has_pid: record.pid.is_some(), + attachment_node_id: record.attachment_node_id.map(|n| n.into()).unwrap_or(0), + pty_cols: record.pty_size.cols, + pty_rows: record.pty_size.rows, + activity, + last_snapshot_seq: record.last_snapshot_seq, + exit_code: record.exit_code.unwrap_or(0), + has_exit_code: record.exit_code.is_some(), + env_keys: Some(env_keys), + env_values: Some(env_values), + }, + ) +} + +fn encode_node_record<'a>( + builder: &mut FlatBufferBuilder<'a>, + record: &NodeRecord, +) -> flatbuffers::WIPOffset> { + let kind = match record.kind { + NodeRecordKind::BufferView => fb::NodeRecordKindWire::BufferView, + NodeRecordKind::Split => fb::NodeRecordKindWire::Split, + NodeRecordKind::Tabs => fb::NodeRecordKindWire::Tabs, + }; + + let buffer_view = record.buffer_view.as_ref().map(|bv| { + fb::BufferViewRecord::create( + builder, + &fb::BufferViewRecordArgs { + buffer_id: bv.buffer_id.into(), + focused: bv.focused, + zoomed: bv.zoomed, + follow_output: bv.follow_output, + last_render_cols: bv.last_render_size.cols, + last_render_rows: bv.last_render_size.rows, + }, + ) + }); + + let split = record.split.as_ref().map(|split| { + let dir = match split.direction { + SplitDirection::Horizontal => fb::SplitDirectionWire::Horizontal, + SplitDirection::Vertical => fb::SplitDirectionWire::Vertical, + }; + let child_ids: Vec = split.child_ids.iter().map(|&c| c.into()).collect(); + let child_ids_vec = builder.create_vector(&child_ids); + let sizes_vec = builder.create_vector(&split.sizes); + + fb::SplitRecord::create( + builder, + &fb::SplitRecordArgs { + direction: dir, + child_ids: Some(child_ids_vec), + sizes: Some(sizes_vec), + }, + ) + }); + + let tabs = record.tabs.as_ref().map(|tabs| { + let tabs_vec: Vec<_> = tabs + .tabs + .iter() + .map(|tab| { + let title = builder.create_string(&tab.title); + fb::TabRecord::create( + builder, + &fb::TabRecordArgs { + title: Some(title), + child_id: tab.child_id.into(), + }, + ) + }) + .collect(); + let tabs_vector = builder.create_vector(&tabs_vec); + + fb::TabsRecord::create( + builder, + &fb::TabsRecordArgs { + active: tabs.active, + tabs: Some(tabs_vector), + }, + ) + }); + + fb::NodeRecord::create( + builder, + &fb::NodeRecordArgs { + id: record.id.into(), + session_id: record.session_id.into(), + parent_id: record.parent_id.map(|p| p.into()).unwrap_or(0), + kind, + buffer_view, + split, + tabs, + }, + ) +} + +fn encode_floating_record<'a>( + builder: &mut FlatBufferBuilder<'a>, + record: &FloatingRecord, +) -> flatbuffers::WIPOffset> { + let title = record.title.as_ref().map(|t| builder.create_string(t)); + + fb::FloatingRecord::create( + builder, + &fb::FloatingRecordArgs { + id: record.id.into(), + session_id: record.session_id.into(), + root_node_id: record.root_node_id.into(), + title, + x: record.geometry.x, + y: record.geometry.y, + width: record.geometry.width, + height: record.geometry.height, + focused: record.focused, + visible: record.visible, + close_on_empty: record.close_on_empty, + }, + ) +} + +fn encode_session_snapshot<'a>( + builder: &mut FlatBufferBuilder<'a>, + snapshot: &SessionSnapshot, +) -> flatbuffers::WIPOffset> { + let session = encode_session_record(builder, &snapshot.session); + let nodes_vec: Vec<_> = snapshot + .nodes + .iter() + .map(|n| encode_node_record(builder, n)) + .collect(); + let nodes = builder.create_vector(&nodes_vec); + let buffers_vec: Vec<_> = snapshot + .buffers + .iter() + .map(|b| encode_buffer_record(builder, b)) + .collect(); + let buffers = builder.create_vector(&buffers_vec); + let floating_vec: Vec<_> = snapshot + .floating + .iter() + .map(|f| encode_floating_record(builder, f)) + .collect(); + let floating = builder.create_vector(&floating_vec); + + fb::SessionSnapshot::create( + builder, + &fb::SessionSnapshotArgs { + session: Some(session), + nodes: Some(nodes), + buffers: Some(buffers), + floating: Some(floating), + }, + ) +} + +// ==================== DECODING ==================== + +pub fn decode_client_message(bytes: &[u8]) -> Result { + let envelope = fb::root_as_envelope(bytes)?; + let request_id = RequestId(envelope.request_id()); + + match envelope.kind() { + fb::MessageKind::PingRequest => { + let req = required(envelope.ping_request(), "ping_request")?; + let payload = required(req.payload(), "ping_request.payload")?; + Ok(ClientMessage::Ping(PingRequest { + request_id, + payload: payload.to_owned(), + })) + } + fb::MessageKind::SessionRequest => { + let req = required(envelope.session_request(), "session_request")?; + let session_request = match req.op() { + fb::SessionOp::Create => { + let name = required(req.name(), "session_request.name")?; + SessionRequest::Create { + request_id, + name: name.to_owned(), + } + } + fb::SessionOp::List => SessionRequest::List { request_id }, + fb::SessionOp::Get => SessionRequest::Get { + request_id, + session_id: SessionId(req.session_id()), + }, + fb::SessionOp::Close => SessionRequest::Close { + request_id, + session_id: SessionId(req.session_id()), + force: req.force(), + }, + fb::SessionOp::AddRootTab => SessionRequest::AddRootTab { + request_id, + session_id: SessionId(req.session_id()), + title: required(req.title(), "session_request.title")?.to_owned(), + buffer_id: (req.buffer_id() != 0).then(|| BufferId(req.buffer_id())), + child_node_id: (req.child_node_id() != 0).then(|| NodeId(req.child_node_id())), + }, + fb::SessionOp::SelectRootTab => SessionRequest::SelectRootTab { + request_id, + session_id: SessionId(req.session_id()), + index: req.index(), + }, + fb::SessionOp::RenameRootTab => SessionRequest::RenameRootTab { + request_id, + session_id: SessionId(req.session_id()), + index: req.index(), + title: required(req.title(), "session_request.title")?.to_owned(), + }, + fb::SessionOp::CloseRootTab => SessionRequest::CloseRootTab { + request_id, + session_id: SessionId(req.session_id()), + index: req.index(), + }, + _ => return Err(ProtocolError::InvalidMessage("unknown session op")), + }; + Ok(ClientMessage::Session(session_request)) + } + fb::MessageKind::BufferRequest => { + let req = required(envelope.buffer_request(), "buffer_request")?; + let buffer_request = match req.op() { + fb::BufferOp::Create => { + let command = required(req.command(), "buffer_request.command")?; + let command_vec: Vec = command.iter().map(|s| s.to_owned()).collect(); + let env = + decode_string_map(req.env_keys(), req.env_values(), "buffer_request.env")?; + BufferRequest::Create { + request_id, + title: req.title().map(|t| t.to_owned()), + command: command_vec, + cwd: req.cwd().map(|c| c.to_owned()), + env, + } + } + fb::BufferOp::List => BufferRequest::List { + request_id, + session_id: if req.session_id() == 0 { + None + } else { + Some(SessionId(req.session_id())) + }, + attached_only: req.attached_only(), + detached_only: req.detached_only(), + }, + fb::BufferOp::Get => BufferRequest::Get { + request_id, + buffer_id: BufferId(req.buffer_id()), + }, + fb::BufferOp::Detach => BufferRequest::Detach { + request_id, + buffer_id: BufferId(req.buffer_id()), + }, + fb::BufferOp::Kill => BufferRequest::Kill { + request_id, + buffer_id: BufferId(req.buffer_id()), + force: req.force(), + }, + fb::BufferOp::Capture => BufferRequest::Capture { + request_id, + buffer_id: BufferId(req.buffer_id()), + }, + fb::BufferOp::CaptureVisible => BufferRequest::CaptureVisible { + request_id, + buffer_id: BufferId(req.buffer_id()), + }, + fb::BufferOp::ScrollbackSlice => BufferRequest::ScrollbackSlice { + request_id, + buffer_id: BufferId(req.buffer_id()), + start_line: req.start_line(), + line_count: req.line_count(), + }, + _ => return Err(ProtocolError::InvalidMessage("unknown buffer op")), + }; + Ok(ClientMessage::Buffer(buffer_request)) + } + fb::MessageKind::NodeRequest => { + let req = required(envelope.node_request(), "node_request")?; + let node_request = match req.op() { + fb::NodeOp::GetTree => NodeRequest::GetTree { + request_id, + session_id: SessionId(req.session_id()), + }, + fb::NodeOp::Split => { + let direction = match req.direction() { + fb::SplitDirectionWire::Horizontal => SplitDirection::Horizontal, + fb::SplitDirectionWire::Vertical => SplitDirection::Vertical, + _ => return Err(ProtocolError::InvalidMessage("unknown split direction")), + }; + NodeRequest::Split { + request_id, + leaf_node_id: NodeId(req.leaf_node_id()), + direction, + new_buffer_id: BufferId(req.new_buffer_id()), + } + } + fb::NodeOp::CreateSplit => { + let direction = match req.direction() { + fb::SplitDirectionWire::Horizontal => SplitDirection::Horizontal, + fb::SplitDirectionWire::Vertical => SplitDirection::Vertical, + _ => return Err(ProtocolError::InvalidMessage("unknown split direction")), + }; + let child_node_ids = + required(req.child_node_ids(), "node_request.child_node_ids")? + .iter() + .map(NodeId) + .collect(); + let sizes = req + .sizes() + .map(|sizes| sizes.iter().collect()) + .unwrap_or_default(); + NodeRequest::CreateSplit { + request_id, + session_id: SessionId(req.session_id()), + direction, + child_node_ids, + sizes, + } + } + fb::NodeOp::CreateTabs => { + let child_node_ids = + required(req.child_node_ids(), "node_request.child_node_ids")? + .iter() + .map(NodeId) + .collect(); + let titles = required(req.titles(), "node_request.titles")? + .iter() + .map(|title| title.to_owned()) + .collect(); + NodeRequest::CreateTabs { + request_id, + session_id: SessionId(req.session_id()), + child_node_ids, + titles, + active: req.active(), + } + } + fb::NodeOp::ReplaceNode => NodeRequest::ReplaceNode { + request_id, + node_id: NodeId(req.node_id()), + child_node_id: NodeId(req.child_node_id()), + }, + fb::NodeOp::WrapInSplit => { + let direction = match req.direction() { + fb::SplitDirectionWire::Horizontal => SplitDirection::Horizontal, + fb::SplitDirectionWire::Vertical => SplitDirection::Vertical, + _ => return Err(ProtocolError::InvalidMessage("unknown split direction")), + }; + NodeRequest::WrapInSplit { + request_id, + node_id: NodeId(req.node_id()), + child_node_id: NodeId(req.child_node_id()), + direction, + insert_before: req.insert_before(), + } + } + fb::NodeOp::WrapInTabs => { + let title = required(req.title(), "node_request.title")?; + NodeRequest::WrapInTabs { + request_id, + node_id: NodeId(req.node_id()), + title: title.to_owned(), + } + } + fb::NodeOp::AddTab => { + let title = required(req.title(), "node_request.title")?; + NodeRequest::AddTab { + request_id, + tabs_node_id: NodeId(req.tabs_node_id()), + title: title.to_owned(), + buffer_id: (req.buffer_id() != 0).then(|| BufferId(req.buffer_id())), + child_node_id: (req.child_node_id() != 0) + .then(|| NodeId(req.child_node_id())), + index: req.index(), + } + } + fb::NodeOp::SelectTab => NodeRequest::SelectTab { + request_id, + tabs_node_id: NodeId(req.tabs_node_id()), + index: req.index(), + }, + fb::NodeOp::Focus => NodeRequest::Focus { + request_id, + session_id: SessionId(req.session_id()), + node_id: NodeId(req.node_id()), + }, + fb::NodeOp::Close => NodeRequest::Close { + request_id, + node_id: NodeId(req.node_id()), + }, + fb::NodeOp::MoveBufferToNode => NodeRequest::MoveBufferToNode { + request_id, + buffer_id: BufferId(req.buffer_id()), + target_leaf_node_id: NodeId(req.target_leaf_node_id()), + }, + fb::NodeOp::Resize => { + let sizes = required(req.sizes(), "node_request.sizes")?; + NodeRequest::Resize { + request_id, + node_id: NodeId(req.node_id()), + sizes: sizes.iter().collect(), + } + } + _ => return Err(ProtocolError::InvalidMessage("unknown node op")), + }; + Ok(ClientMessage::Node(node_request)) + } + fb::MessageKind::FloatingRequest => { + let req = required(envelope.floating_request(), "floating_request")?; + let floating_request = match req.op() { + fb::FloatingOp::Create => FloatingRequest::Create { + request_id, + session_id: SessionId(req.session_id()), + root_node_id: (req.root_node_id() != 0).then(|| NodeId(req.root_node_id())), + buffer_id: (req.buffer_id() != 0).then(|| BufferId(req.buffer_id())), + geometry: FloatGeometry { + x: req.x(), + y: req.y(), + width: req.width(), + height: req.height(), + }, + title: req.title().map(|t| t.to_owned()), + focus: req.focus(), + close_on_empty: req.close_on_empty(), + }, + fb::FloatingOp::Close => FloatingRequest::Close { + request_id, + floating_id: FloatingId(req.floating_id()), + }, + fb::FloatingOp::Move => FloatingRequest::Move { + request_id, + floating_id: FloatingId(req.floating_id()), + geometry: FloatGeometry { + x: req.x(), + y: req.y(), + width: req.width(), + height: req.height(), + }, + }, + fb::FloatingOp::Focus => FloatingRequest::Focus { + request_id, + floating_id: FloatingId(req.floating_id()), + }, + _ => return Err(ProtocolError::InvalidMessage("unknown floating op")), + }; + Ok(ClientMessage::Floating(floating_request)) + } + fb::MessageKind::InputRequest => { + let req = required(envelope.input_request(), "input_request")?; + let input_request = match req.op() { + fb::InputOp::Send => { + let bytes = required(req.bytes(), "input_request.bytes")?; + InputRequest::Send { + request_id, + buffer_id: BufferId(req.buffer_id()), + bytes: bytes.iter().collect(), + } + } + fb::InputOp::Resize => InputRequest::Resize { + request_id, + buffer_id: BufferId(req.buffer_id()), + cols: req.cols(), + rows: req.rows(), + }, + _ => return Err(ProtocolError::InvalidMessage("unknown input op")), + }; + Ok(ClientMessage::Input(input_request)) + } + fb::MessageKind::SubscribeRequest => { + let req = required(envelope.subscribe_request(), "subscribe_request")?; + Ok(ClientMessage::Subscribe(SubscribeRequest { + request_id, + session_id: if req.session_id() == 0 { + None + } else { + Some(SessionId(req.session_id())) + }, + })) + } + fb::MessageKind::UnsubscribeRequest => { + let req = required(envelope.unsubscribe_request(), "unsubscribe_request")?; + Ok(ClientMessage::Unsubscribe(UnsubscribeRequest { + request_id, + subscription_id: req.subscription_id(), + })) + } + other => Err(ProtocolError::InvalidMessageOwned(format!( + "unexpected client message kind: {:?}", + other + ))), + } +} + +pub fn decode_server_envelope(bytes: &[u8]) -> Result { + let envelope = fb::root_as_envelope(bytes)?; + + match envelope.kind() { + fb::MessageKind::PingResponse => { + let resp = required(envelope.ping_response(), "ping_response")?; + let payload = required(resp.payload(), "ping_response.payload")?; + Ok(ServerEnvelope::Response(ServerResponse::Pong( + PingResponse { + request_id: RequestId(envelope.request_id()), + payload: payload.to_owned(), + }, + ))) + } + fb::MessageKind::OkResponse => { + Ok(ServerEnvelope::Response(ServerResponse::Ok(OkResponse { + request_id: RequestId(envelope.request_id()), + }))) + } + fb::MessageKind::ErrorResponse => { + let resp = required(envelope.error_response(), "error_response")?; + let message = required(resp.message(), "error_response.message")?; + Ok(ServerEnvelope::Response(ServerResponse::Error( + ErrorResponse { + request_id: if envelope.request_id() == 0 { + None + } else { + Some(RequestId(envelope.request_id())) + }, + error: WireError::new(decode_error_code(resp.code()), message), + }, + ))) + } + fb::MessageKind::SessionsResponse => { + let resp = required(envelope.sessions_response(), "sessions_response")?; + let sessions = required(resp.sessions(), "sessions_response.sessions")?; + let sessions_vec: Result, _> = + sessions.iter().map(decode_session_record).collect(); + Ok(ServerEnvelope::Response(ServerResponse::Sessions( + SessionsResponse { + request_id: RequestId(envelope.request_id()), + sessions: sessions_vec?, + }, + ))) + } + fb::MessageKind::SessionSnapshotResponse => { + let resp = required( + envelope.session_snapshot_response(), + "session_snapshot_response", + )?; + let snapshot = required(resp.snapshot(), "session_snapshot_response.snapshot")?; + Ok(ServerEnvelope::Response(ServerResponse::SessionSnapshot( + SessionSnapshotResponse { + request_id: RequestId(envelope.request_id()), + snapshot: decode_session_snapshot(snapshot)?, + }, + ))) + } + fb::MessageKind::BuffersResponse => { + let resp = required(envelope.buffers_response(), "buffers_response")?; + let buffers = required(resp.buffers(), "buffers_response.buffers")?; + let buffers_vec: Result, _> = buffers.iter().map(decode_buffer_record).collect(); + Ok(ServerEnvelope::Response(ServerResponse::Buffers( + BuffersResponse { + request_id: RequestId(envelope.request_id()), + buffers: buffers_vec?, + }, + ))) + } + fb::MessageKind::BufferResponse => { + let resp = required(envelope.buffer_response(), "buffer_response")?; + let buffer = required(resp.buffer(), "buffer_response.buffer")?; + Ok(ServerEnvelope::Response(ServerResponse::Buffer( + BufferResponse { + request_id: RequestId(envelope.request_id()), + buffer: decode_buffer_record(buffer)?, + }, + ))) + } + fb::MessageKind::FloatingListResponse => { + let resp = required(envelope.floating_list_response(), "floating_list_response")?; + let floating = required(resp.floating(), "floating_list_response.floating")?; + let floating_vec: Result, _> = + floating.iter().map(decode_floating_record).collect(); + Ok(ServerEnvelope::Response(ServerResponse::FloatingList( + FloatingListResponse { + request_id: RequestId(envelope.request_id()), + floating: floating_vec?, + }, + ))) + } + fb::MessageKind::FloatingResponse => { + let resp = required(envelope.floating_response(), "floating_response")?; + let floating = required(resp.floating(), "floating_response.floating")?; + Ok(ServerEnvelope::Response(ServerResponse::Floating( + FloatingResponse { + request_id: RequestId(envelope.request_id()), + floating: decode_floating_record(floating)?, + }, + ))) + } + fb::MessageKind::SubscriptionAckResponse => { + let resp = required( + envelope.subscription_ack_response(), + "subscription_ack_response", + )?; + Ok(ServerEnvelope::Response(ServerResponse::SubscriptionAck( + SubscriptionAckResponse { + request_id: RequestId(envelope.request_id()), + subscription_id: resp.subscription_id(), + }, + ))) + } + fb::MessageKind::SnapshotResponse => { + let resp = required(envelope.snapshot_response(), "snapshot_response")?; + let lines = required(resp.lines(), "snapshot_response.lines")?; + let lines_vec: Vec = lines.iter().map(|l| l.to_owned()).collect(); + Ok(ServerEnvelope::Response(ServerResponse::Snapshot( + SnapshotResponse { + request_id: RequestId(envelope.request_id()), + buffer_id: BufferId(resp.buffer_id()), + sequence: resp.sequence(), + size: PtySize { + cols: resp.cols(), + rows: resp.rows(), + pixel_width: 0, + pixel_height: 0, + }, + lines: lines_vec, + title: resp.title().map(|t| t.to_owned()), + cwd: resp.cwd().map(|c| c.to_owned()), + }, + ))) + } + fb::MessageKind::VisibleSnapshotResponse => { + let resp = required( + envelope.visible_snapshot_response(), + "visible_snapshot_response", + )?; + let lines = required(resp.lines(), "visible_snapshot_response.lines")?; + let lines_vec: Vec = lines.iter().map(|l| l.to_owned()).collect(); + let cursor = resp.cursor().map(decode_cursor_state).transpose()?; + Ok(ServerEnvelope::Response(ServerResponse::VisibleSnapshot( + VisibleSnapshotResponse { + request_id: RequestId(envelope.request_id()), + buffer_id: BufferId(resp.buffer_id()), + sequence: resp.sequence(), + size: PtySize { + cols: resp.cols(), + rows: resp.rows(), + pixel_width: 0, + pixel_height: 0, + }, + lines: lines_vec, + title: resp.title().map(|t| t.to_owned()), + cwd: resp.cwd().map(|c| c.to_owned()), + viewport_top_line: resp.viewport_top_line(), + total_lines: resp.total_lines(), + alternate_screen: resp.alternate_screen(), + mouse_reporting: resp.mouse_reporting(), + focus_reporting: resp.focus_reporting(), + bracketed_paste: resp.bracketed_paste(), + cursor, + }, + ))) + } + fb::MessageKind::ScrollbackSliceResponse => { + let resp = required( + envelope.scrollback_slice_response(), + "scrollback_slice_response", + )?; + let lines = required(resp.lines(), "scrollback_slice_response.lines")?; + let lines_vec: Vec = lines.iter().map(|l| l.to_owned()).collect(); + Ok(ServerEnvelope::Response(ServerResponse::ScrollbackSlice( + ScrollbackSliceResponse { + request_id: RequestId(envelope.request_id()), + buffer_id: BufferId(resp.buffer_id()), + start_line: resp.start_line(), + total_lines: resp.total_lines(), + lines: lines_vec, + }, + ))) + } + fb::MessageKind::SessionCreatedEvent => { + let event = required(envelope.session_created_event(), "session_created_event")?; + let session = required(event.session(), "session_created_event.session")?; + Ok(ServerEnvelope::Event(ServerEvent::SessionCreated( + SessionCreatedEvent { + session: decode_session_record(session)?, + }, + ))) + } + fb::MessageKind::SessionClosedEvent => { + let event = required(envelope.session_closed_event(), "session_closed_event")?; + Ok(ServerEnvelope::Event(ServerEvent::SessionClosed( + SessionClosedEvent { + session_id: SessionId(event.session_id()), + }, + ))) + } + fb::MessageKind::BufferCreatedEvent => { + let event = required(envelope.buffer_created_event(), "buffer_created_event")?; + let buffer = required(event.buffer(), "buffer_created_event.buffer")?; + Ok(ServerEnvelope::Event(ServerEvent::BufferCreated( + BufferCreatedEvent { + buffer: decode_buffer_record(buffer)?, + }, + ))) + } + fb::MessageKind::BufferDetachedEvent => { + let event = required(envelope.buffer_detached_event(), "buffer_detached_event")?; + Ok(ServerEnvelope::Event(ServerEvent::BufferDetached( + BufferDetachedEvent { + buffer_id: BufferId(event.buffer_id()), + }, + ))) + } + fb::MessageKind::NodeChangedEvent => { + let event = required(envelope.node_changed_event(), "node_changed_event")?; + Ok(ServerEnvelope::Event(ServerEvent::NodeChanged( + NodeChangedEvent { + session_id: SessionId(event.session_id()), + }, + ))) + } + fb::MessageKind::FloatingChangedEvent => { + let event = required(envelope.floating_changed_event(), "floating_changed_event")?; + Ok(ServerEnvelope::Event(ServerEvent::FloatingChanged( + FloatingChangedEvent { + session_id: SessionId(event.session_id()), + floating_id: if event.floating_id() == 0 { + None + } else { + Some(FloatingId(event.floating_id())) + }, + }, + ))) + } + fb::MessageKind::FocusChangedEvent => { + let event = required(envelope.focus_changed_event(), "focus_changed_event")?; + Ok(ServerEnvelope::Event(ServerEvent::FocusChanged( + FocusChangedEvent { + session_id: SessionId(event.session_id()), + focused_leaf_id: if event.focused_leaf_id() == 0 { + None + } else { + Some(NodeId(event.focused_leaf_id())) + }, + focused_floating_id: if event.focused_floating_id() == 0 { + None + } else { + Some(FloatingId(event.focused_floating_id())) + }, + }, + ))) + } + fb::MessageKind::RenderInvalidatedEvent => { + let event = required( + envelope.render_invalidated_event(), + "render_invalidated_event", + )?; + Ok(ServerEnvelope::Event(ServerEvent::RenderInvalidated( + RenderInvalidatedEvent { + buffer_id: BufferId(event.buffer_id()), + }, + ))) + } + other => Err(ProtocolError::InvalidMessageOwned(format!( + "unexpected server envelope kind: {:?}", + other + ))), + } +} + +// ==================== RECORD DECODING ==================== + +fn decode_session_record(record: fb::SessionRecord) -> Result { + let name = required(record.name(), "session_record.name")?; + let floating_ids_fb = required(record.floating_ids(), "session_record.floating_ids")?; + let floating_ids: Vec = floating_ids_fb.iter().map(FloatingId).collect(); + + Ok(SessionRecord { + id: SessionId(record.id()), + name: name.to_owned(), + root_node_id: NodeId(record.root_node_id()), + floating_ids, + focused_leaf_id: if record.focused_leaf_id() == 0 { + None + } else { + Some(NodeId(record.focused_leaf_id())) + }, + focused_floating_id: if record.focused_floating_id() == 0 { + None + } else { + Some(FloatingId(record.focused_floating_id())) + }, + }) +} + +fn decode_buffer_record(record: fb::BufferRecord) -> Result { + let title = required(record.title(), "buffer_record.title")?; + let command_fb = required(record.command(), "buffer_record.command")?; + let command: Vec = command_fb.iter().map(|s| s.to_owned()).collect(); + + let state = match record.state() { + fb::BufferStateWire::Created => BufferRecordState::Created, + fb::BufferStateWire::Running => BufferRecordState::Running, + fb::BufferStateWire::Exited => BufferRecordState::Exited, + _ => return Err(ProtocolError::InvalidMessage("unknown buffer state")), + }; + + let activity = match record.activity() { + fb::ActivityStateWire::Idle => ActivityState::Idle, + fb::ActivityStateWire::Activity => ActivityState::Activity, + fb::ActivityStateWire::Bell => ActivityState::Bell, + _ => return Err(ProtocolError::InvalidMessage("unknown activity state")), + }; + let env = decode_string_map(record.env_keys(), record.env_values(), "buffer_record.env")?; + + Ok(BufferRecord { + id: BufferId(record.id()), + title: title.to_owned(), + command, + cwd: record.cwd().map(|c| c.to_owned()), + state, + pid: record.has_pid().then(|| record.pid()), + attachment_node_id: if record.attachment_node_id() == 0 { + None + } else { + Some(NodeId(record.attachment_node_id())) + }, + pty_size: PtySize { + cols: record.pty_cols(), + rows: record.pty_rows(), + pixel_width: 0, + pixel_height: 0, + }, + activity, + last_snapshot_seq: record.last_snapshot_seq(), + exit_code: if record.has_exit_code() { + Some(record.exit_code()) + } else { + None + }, + env, + }) +} + +fn decode_node_record(record: fb::NodeRecord) -> Result { + let (kind, buffer_view, split, tabs) = match record.kind() { + fb::NodeRecordKindWire::BufferView => { + let buffer_view = required(record.buffer_view(), "node_record.buffer_view")?; + ( + NodeRecordKind::BufferView, + Some(BufferViewRecord { + buffer_id: BufferId(buffer_view.buffer_id()), + focused: buffer_view.focused(), + zoomed: buffer_view.zoomed(), + follow_output: buffer_view.follow_output(), + last_render_size: PtySize { + cols: buffer_view.last_render_cols(), + rows: buffer_view.last_render_rows(), + pixel_width: 0, + pixel_height: 0, + }, + }), + None, + None, + ) + } + fb::NodeRecordKindWire::Split => { + let split = required(record.split(), "node_record.split")?; + let child_ids = required(split.child_ids(), "node_record.split.child_ids")? + .iter() + .map(NodeId) + .collect(); + let sizes = required(split.sizes(), "node_record.split.sizes")? + .iter() + .collect(); + let direction = match split.direction() { + fb::SplitDirectionWire::Horizontal => SplitDirection::Horizontal, + fb::SplitDirectionWire::Vertical => SplitDirection::Vertical, + _ => return Err(ProtocolError::InvalidMessage("node_record.split.direction")), + }; + ( + NodeRecordKind::Split, + None, + Some(SplitRecord { + direction, + child_ids, + sizes, + }), + None, + ) + } + fb::NodeRecordKindWire::Tabs => { + let tabs = required(record.tabs(), "node_record.tabs")?; + let tabs_fb = required(tabs.tabs(), "node_record.tabs.tabs")?; + let tabs_vec = tabs_fb + .iter() + .map(|tab| { + Ok(TabRecord { + title: required(tab.title(), "node_record.tabs.title")?.to_owned(), + child_id: NodeId(tab.child_id()), + }) + }) + .collect::, ProtocolError>>()?; + ( + NodeRecordKind::Tabs, + None, + None, + Some(TabsRecord { + active: tabs.active(), + tabs: tabs_vec, + }), + ) + } + _ => return Err(ProtocolError::InvalidMessage("unknown node kind")), + }; + + Ok(NodeRecord { + id: NodeId(record.id()), + session_id: SessionId(record.session_id()), + parent_id: if record.parent_id() == 0 { + None + } else { + Some(NodeId(record.parent_id())) + }, + kind, + buffer_view, + split, + tabs, + }) +} + +fn decode_floating_record(record: fb::FloatingRecord) -> Result { + Ok(FloatingRecord { + id: FloatingId(record.id()), + session_id: SessionId(record.session_id()), + root_node_id: NodeId(record.root_node_id()), + title: record.title().map(|t| t.to_owned()), + geometry: FloatGeometry { + x: record.x(), + y: record.y(), + width: record.width(), + height: record.height(), + }, + focused: record.focused(), + visible: record.visible(), + close_on_empty: record.close_on_empty(), + }) +} + +fn decode_session_snapshot( + snapshot: fb::SessionSnapshot, +) -> Result { + let session = required(snapshot.session(), "session_snapshot.session")?; + let nodes = required(snapshot.nodes(), "session_snapshot.nodes")?; + let buffers = required(snapshot.buffers(), "session_snapshot.buffers")?; + let floating = required(snapshot.floating(), "session_snapshot.floating")?; + + let nodes_vec: Result, _> = nodes.iter().map(decode_node_record).collect(); + let buffers_vec: Result, _> = buffers.iter().map(decode_buffer_record).collect(); + let floating_vec: Result, _> = floating.iter().map(decode_floating_record).collect(); + + Ok(SessionSnapshot { + session: decode_session_record(session)?, + nodes: nodes_vec?, + buffers: buffers_vec?, + floating: floating_vec?, + }) +} + +// ==================== HELPERS ==================== + +fn encode_error_code(code: ErrorCode) -> fb::ErrorCodeWire { + match code { + ErrorCode::Unknown => fb::ErrorCodeWire::Unknown, + ErrorCode::InvalidRequest => fb::ErrorCodeWire::InvalidRequest, + ErrorCode::ProtocolViolation => fb::ErrorCodeWire::ProtocolViolation, + ErrorCode::Transport => fb::ErrorCodeWire::Transport, + ErrorCode::NotFound => fb::ErrorCodeWire::NotFound, + ErrorCode::Conflict => fb::ErrorCodeWire::Conflict, + ErrorCode::Unsupported => fb::ErrorCodeWire::Unsupported, + ErrorCode::Timeout => fb::ErrorCodeWire::Timeout, + ErrorCode::Internal => fb::ErrorCodeWire::Internal, + } +} + +fn decode_error_code(code: fb::ErrorCodeWire) -> ErrorCode { + match code { + fb::ErrorCodeWire::Unknown => ErrorCode::Unknown, + fb::ErrorCodeWire::InvalidRequest => ErrorCode::InvalidRequest, + fb::ErrorCodeWire::ProtocolViolation => ErrorCode::ProtocolViolation, + fb::ErrorCodeWire::Transport => ErrorCode::Transport, + fb::ErrorCodeWire::NotFound => ErrorCode::NotFound, + fb::ErrorCodeWire::Conflict => ErrorCode::Conflict, + fb::ErrorCodeWire::Unsupported => ErrorCode::Unsupported, + fb::ErrorCodeWire::Timeout => ErrorCode::Timeout, + fb::ErrorCodeWire::Internal => ErrorCode::Internal, + _ => ErrorCode::Unknown, + } +} + +#[cfg(test)] +mod tests { + use flatbuffers::FlatBufferBuilder; + + use super::*; + + #[test] + fn decode_node_record_rejects_missing_buffer_view_payload() { + let mut builder = FlatBufferBuilder::new(); + let node = fb::NodeRecord::create( + &mut builder, + &fb::NodeRecordArgs { + id: 1, + session_id: 1, + kind: fb::NodeRecordKindWire::BufferView, + ..Default::default() + }, + ); + builder.finish(node, None); + + let record = + flatbuffers::root::(builder.finished_data()).expect("node record root"); + let error = decode_node_record(record).expect_err("missing payload should be rejected"); + + assert!(matches!( + error, + ProtocolError::InvalidMessage("node_record.buffer_view") + )); + } + + #[test] + fn decode_node_record_rejects_split_without_children() { + let mut builder = FlatBufferBuilder::new(); + let split = fb::SplitRecord::create( + &mut builder, + &fb::SplitRecordArgs { + direction: fb::SplitDirectionWire::Vertical, + ..Default::default() + }, + ); + let node = fb::NodeRecord::create( + &mut builder, + &fb::NodeRecordArgs { + id: 2, + session_id: 1, + kind: fb::NodeRecordKindWire::Split, + split: Some(split), + ..Default::default() + }, + ); + builder.finish(node, None); + + let record = + flatbuffers::root::(builder.finished_data()).expect("node record root"); + let error = decode_node_record(record).expect_err("missing child ids should be rejected"); + + assert!(matches!( + error, + ProtocolError::InvalidMessage("node_record.split.child_ids") + )); + } + + #[test] + fn decode_node_record_rejects_tabs_without_tab_entries() { + let mut builder = FlatBufferBuilder::new(); + let tabs = fb::TabsRecord::create( + &mut builder, + &fb::TabsRecordArgs { + active: 0, + ..Default::default() + }, + ); + let node = fb::NodeRecord::create( + &mut builder, + &fb::NodeRecordArgs { + id: 3, + session_id: 1, + kind: fb::NodeRecordKindWire::Tabs, + tabs: Some(tabs), + ..Default::default() + }, + ); + builder.finish(node, None); + + let record = + flatbuffers::root::(builder.finished_data()).expect("node record root"); + let error = decode_node_record(record).expect_err("missing tabs should be rejected"); + + assert!(matches!( + error, + ProtocolError::InvalidMessage("node_record.tabs.tabs") + )); + } +} diff --git a/crates/embers-protocol/src/framing.rs b/crates/embers-protocol/src/framing.rs new file mode 100644 index 0000000..205f696 --- /dev/null +++ b/crates/embers-protocol/src/framing.rs @@ -0,0 +1,115 @@ +use embers_core::RequestId; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +use crate::codec::ProtocolError; + +pub const MAX_FRAME_LEN: usize = 8 * 1024 * 1024; +const FRAME_HEADER_LEN: usize = 13; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FrameType { + Request = 1, + Response = 2, + Event = 3, +} + +impl TryFrom for FrameType { + type Error = ProtocolError; + + fn try_from(value: u8) -> Result { + match value { + 1 => Ok(Self::Request), + 2 => Ok(Self::Response), + 3 => Ok(Self::Event), + other => Err(ProtocolError::InvalidFrameType(other)), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RawFrame { + pub frame_type: FrameType, + pub request_id: RequestId, + pub payload: Vec, +} + +impl RawFrame { + pub fn new(frame_type: FrameType, request_id: RequestId, payload: Vec) -> Self { + Self { + frame_type, + request_id, + payload, + } + } +} + +pub async fn read_frame(reader: &mut R) -> Result, ProtocolError> +where + R: AsyncRead + Unpin, +{ + let mut header = [0_u8; FRAME_HEADER_LEN]; + match reader.read_exact(&mut header).await { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), + Err(error) => return Err(error.into()), + } + + let length = u32::from_le_bytes(header[0..4].try_into().expect("length bytes")) as usize; + if length > MAX_FRAME_LEN { + return Err(ProtocolError::FrameTooLarge(length)); + } + + let frame_type = FrameType::try_from(header[4])?; + let request_id = RequestId(u64::from_le_bytes( + header[5..13].try_into().expect("request id bytes"), + )); + + let mut payload = vec![0_u8; length]; + reader.read_exact(&mut payload).await?; + + Ok(Some(RawFrame { + frame_type, + request_id, + payload, + })) +} + +pub async fn write_frame(writer: &mut W, frame: &RawFrame) -> Result<(), ProtocolError> +where + W: AsyncWrite + Unpin, +{ + write_frame_inner(writer, frame, true).await +} + +pub async fn write_frame_no_flush(writer: &mut W, frame: &RawFrame) -> Result<(), ProtocolError> +where + W: AsyncWrite + Unpin, +{ + write_frame_inner(writer, frame, false).await +} + +async fn write_frame_inner( + writer: &mut W, + frame: &RawFrame, + flush: bool, +) -> Result<(), ProtocolError> +where + W: AsyncWrite + Unpin, +{ + if frame.payload.len() > MAX_FRAME_LEN { + return Err(ProtocolError::FrameTooLarge(frame.payload.len())); + } + + writer + .write_all(&(frame.payload.len() as u32).to_le_bytes()) + .await?; + writer.write_all(&[frame.frame_type as u8]).await?; + writer + .write_all(&u64::from(frame.request_id).to_le_bytes()) + .await?; + writer.write_all(&frame.payload).await?; + if flush { + writer.flush().await?; + } + Ok(()) +} diff --git a/crates/embers-protocol/src/lib.rs b/crates/embers-protocol/src/lib.rs new file mode 100644 index 0000000..6e72e01 --- /dev/null +++ b/crates/embers-protocol/src/lib.rs @@ -0,0 +1,36 @@ +pub mod client; +pub mod codec; +pub mod framing; +pub mod types; + +pub mod generated { + #![allow( + clippy::all, + dead_code, + non_camel_case_types, + non_snake_case, + non_upper_case_globals, + unused_imports + )] + include!(concat!(env!("OUT_DIR"), "/embers_generated.rs")); +} + +pub use client::ProtocolClient; +pub use codec::{ + ProtocolError, decode_client_message, decode_server_envelope, encode_client_message, + encode_server_envelope, +}; +pub use framing::{ + FrameType, MAX_FRAME_LEN, RawFrame, read_frame, write_frame, write_frame_no_flush, +}; +pub use types::{ + BufferCreatedEvent, BufferDetachedEvent, BufferRecord, BufferRecordState, BufferRequest, + BufferResponse, BufferViewRecord, BuffersResponse, ClientMessage, ErrorResponse, + FloatingChangedEvent, FloatingListResponse, FloatingRecord, FloatingRequest, FloatingResponse, + FocusChangedEvent, InputRequest, NodeChangedEvent, NodeRecord, NodeRecordKind, NodeRequest, + OkResponse, PingRequest, PingResponse, RenderInvalidatedEvent, ScrollbackSliceResponse, + ServerEnvelope, ServerEvent, ServerResponse, SessionClosedEvent, SessionCreatedEvent, + SessionRecord, SessionRequest, SessionSnapshot, SessionSnapshotResponse, SessionsResponse, + SnapshotResponse, SplitRecord, SubscribeRequest, SubscriptionAckResponse, TabRecord, + TabsRecord, UnsubscribeRequest, VisibleSnapshotResponse, +}; diff --git a/crates/embers-protocol/src/types.rs b/crates/embers-protocol/src/types.rs new file mode 100644 index 0000000..aebc5fc --- /dev/null +++ b/crates/embers-protocol/src/types.rs @@ -0,0 +1,635 @@ +use std::collections::BTreeMap; + +use embers_core::{ + ActivityState, BufferId, CursorState, FloatGeometry, FloatingId, NodeId, PtySize, RequestId, + SessionId, SplitDirection, WireError, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PingRequest { + pub request_id: RequestId, + pub payload: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PingResponse { + pub request_id: RequestId, + pub payload: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SessionRequest { + Create { + request_id: RequestId, + name: String, + }, + List { + request_id: RequestId, + }, + Get { + request_id: RequestId, + session_id: SessionId, + }, + Close { + request_id: RequestId, + session_id: SessionId, + force: bool, + }, + AddRootTab { + request_id: RequestId, + session_id: SessionId, + title: String, + buffer_id: Option, + child_node_id: Option, + }, + SelectRootTab { + request_id: RequestId, + session_id: SessionId, + index: u32, + }, + RenameRootTab { + request_id: RequestId, + session_id: SessionId, + index: u32, + title: String, + }, + CloseRootTab { + request_id: RequestId, + session_id: SessionId, + index: u32, + }, +} + +impl SessionRequest { + pub fn request_id(&self) -> RequestId { + match self { + Self::Create { request_id, .. } + | Self::List { request_id } + | Self::Get { request_id, .. } + | Self::Close { request_id, .. } + | Self::AddRootTab { request_id, .. } + | Self::SelectRootTab { request_id, .. } + | Self::RenameRootTab { request_id, .. } + | Self::CloseRootTab { request_id, .. } => *request_id, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BufferRequest { + Create { + request_id: RequestId, + title: Option, + command: Vec, + cwd: Option, + env: BTreeMap, + }, + List { + request_id: RequestId, + session_id: Option, + attached_only: bool, + detached_only: bool, + }, + Get { + request_id: RequestId, + buffer_id: BufferId, + }, + Detach { + request_id: RequestId, + buffer_id: BufferId, + }, + Kill { + request_id: RequestId, + buffer_id: BufferId, + force: bool, + }, + Capture { + request_id: RequestId, + buffer_id: BufferId, + }, + CaptureVisible { + request_id: RequestId, + buffer_id: BufferId, + }, + ScrollbackSlice { + request_id: RequestId, + buffer_id: BufferId, + start_line: u64, + line_count: u32, + }, +} + +impl BufferRequest { + pub fn request_id(&self) -> RequestId { + match self { + Self::Create { request_id, .. } + | Self::List { request_id, .. } + | Self::Get { request_id, .. } + | Self::Detach { request_id, .. } + | Self::Kill { request_id, .. } + | Self::Capture { request_id, .. } + | Self::CaptureVisible { request_id, .. } + | Self::ScrollbackSlice { request_id, .. } => *request_id, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum NodeRequest { + GetTree { + request_id: RequestId, + session_id: SessionId, + }, + Split { + request_id: RequestId, + leaf_node_id: NodeId, + direction: SplitDirection, + new_buffer_id: BufferId, + }, + CreateSplit { + request_id: RequestId, + session_id: SessionId, + direction: SplitDirection, + child_node_ids: Vec, + sizes: Vec, + }, + CreateTabs { + request_id: RequestId, + session_id: SessionId, + child_node_ids: Vec, + titles: Vec, + active: u32, + }, + ReplaceNode { + request_id: RequestId, + node_id: NodeId, + child_node_id: NodeId, + }, + WrapInSplit { + request_id: RequestId, + node_id: NodeId, + child_node_id: NodeId, + direction: SplitDirection, + insert_before: bool, + }, + WrapInTabs { + request_id: RequestId, + node_id: NodeId, + title: String, + }, + AddTab { + request_id: RequestId, + tabs_node_id: NodeId, + title: String, + buffer_id: Option, + child_node_id: Option, + index: u32, + }, + SelectTab { + request_id: RequestId, + tabs_node_id: NodeId, + index: u32, + }, + Focus { + request_id: RequestId, + session_id: SessionId, + node_id: NodeId, + }, + Close { + request_id: RequestId, + node_id: NodeId, + }, + MoveBufferToNode { + request_id: RequestId, + buffer_id: BufferId, + target_leaf_node_id: NodeId, + }, + Resize { + request_id: RequestId, + node_id: NodeId, + sizes: Vec, + }, +} + +impl NodeRequest { + pub fn request_id(&self) -> RequestId { + match self { + Self::GetTree { request_id, .. } + | Self::Split { request_id, .. } + | Self::CreateSplit { request_id, .. } + | Self::CreateTabs { request_id, .. } + | Self::ReplaceNode { request_id, .. } + | Self::WrapInSplit { request_id, .. } + | Self::WrapInTabs { request_id, .. } + | Self::AddTab { request_id, .. } + | Self::SelectTab { request_id, .. } + | Self::Focus { request_id, .. } + | Self::Close { request_id, .. } + | Self::MoveBufferToNode { request_id, .. } + | Self::Resize { request_id, .. } => *request_id, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FloatingRequest { + Create { + request_id: RequestId, + session_id: SessionId, + root_node_id: Option, + buffer_id: Option, + geometry: FloatGeometry, + title: Option, + focus: bool, + close_on_empty: bool, + }, + Close { + request_id: RequestId, + floating_id: FloatingId, + }, + Move { + request_id: RequestId, + floating_id: FloatingId, + geometry: FloatGeometry, + }, + Focus { + request_id: RequestId, + floating_id: FloatingId, + }, +} + +impl FloatingRequest { + pub fn request_id(&self) -> RequestId { + match self { + Self::Create { request_id, .. } + | Self::Close { request_id, .. } + | Self::Move { request_id, .. } + | Self::Focus { request_id, .. } => *request_id, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum InputRequest { + Send { + request_id: RequestId, + buffer_id: BufferId, + bytes: Vec, + }, + Resize { + request_id: RequestId, + buffer_id: BufferId, + cols: u16, + rows: u16, + }, +} + +impl InputRequest { + pub fn request_id(&self) -> RequestId { + match self { + Self::Send { request_id, .. } | Self::Resize { request_id, .. } => *request_id, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SubscribeRequest { + pub request_id: RequestId, + pub session_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UnsubscribeRequest { + pub request_id: RequestId, + pub subscription_id: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ClientMessage { + Ping(PingRequest), + Session(SessionRequest), + Buffer(BufferRequest), + Node(NodeRequest), + Floating(FloatingRequest), + Input(InputRequest), + Subscribe(SubscribeRequest), + Unsubscribe(UnsubscribeRequest), +} + +impl ClientMessage { + pub fn request_id(&self) -> RequestId { + match self { + Self::Ping(request) => request.request_id, + Self::Session(request) => request.request_id(), + Self::Buffer(request) => request.request_id(), + Self::Node(request) => request.request_id(), + Self::Floating(request) => request.request_id(), + Self::Input(request) => request.request_id(), + Self::Subscribe(request) => request.request_id, + Self::Unsubscribe(request) => request.request_id, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BufferRecordState { + Created, + Running, + Exited, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SessionRecord { + pub id: SessionId, + pub name: String, + pub root_node_id: NodeId, + pub floating_ids: Vec, + pub focused_leaf_id: Option, + pub focused_floating_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BufferRecord { + pub id: BufferId, + pub title: String, + pub command: Vec, + pub cwd: Option, + pub state: BufferRecordState, + pub pid: Option, + pub attachment_node_id: Option, + pub pty_size: PtySize, + pub activity: ActivityState, + pub last_snapshot_seq: u64, + pub exit_code: Option, + pub env: BTreeMap, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NodeRecordKind { + BufferView, + Split, + Tabs, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BufferViewRecord { + pub buffer_id: BufferId, + pub focused: bool, + pub zoomed: bool, + pub follow_output: bool, + pub last_render_size: PtySize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SplitRecord { + pub direction: SplitDirection, + pub child_ids: Vec, + pub sizes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TabRecord { + pub title: String, + pub child_id: NodeId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TabsRecord { + pub active: u32, + pub tabs: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NodeRecord { + pub id: NodeId, + pub session_id: SessionId, + pub parent_id: Option, + pub kind: NodeRecordKind, + pub buffer_view: Option, + pub split: Option, + pub tabs: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FloatingRecord { + pub id: FloatingId, + pub session_id: SessionId, + pub root_node_id: NodeId, + pub title: Option, + pub geometry: FloatGeometry, + pub focused: bool, + pub visible: bool, + pub close_on_empty: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SessionSnapshot { + pub session: SessionRecord, + pub nodes: Vec, + pub buffers: Vec, + pub floating: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OkResponse { + pub request_id: RequestId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ErrorResponse { + pub request_id: Option, + pub error: WireError, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SessionsResponse { + pub request_id: RequestId, + pub sessions: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SessionSnapshotResponse { + pub request_id: RequestId, + pub snapshot: SessionSnapshot, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BuffersResponse { + pub request_id: RequestId, + pub buffers: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BufferResponse { + pub request_id: RequestId, + pub buffer: BufferRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FloatingListResponse { + pub request_id: RequestId, + pub floating: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FloatingResponse { + pub request_id: RequestId, + pub floating: FloatingRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SubscriptionAckResponse { + pub request_id: RequestId, + pub subscription_id: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SnapshotResponse { + pub request_id: RequestId, + pub buffer_id: BufferId, + pub sequence: u64, + pub size: PtySize, + pub lines: Vec, + pub title: Option, + pub cwd: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisibleSnapshotResponse { + pub request_id: RequestId, + pub buffer_id: BufferId, + pub sequence: u64, + pub size: PtySize, + pub lines: Vec, + pub title: Option, + pub cwd: Option, + pub viewport_top_line: u64, + pub total_lines: u64, + pub alternate_screen: bool, + pub mouse_reporting: bool, + pub focus_reporting: bool, + pub bracketed_paste: bool, + pub cursor: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScrollbackSliceResponse { + pub request_id: RequestId, + pub buffer_id: BufferId, + pub start_line: u64, + pub total_lines: u64, + pub lines: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ServerResponse { + Pong(PingResponse), + Ok(OkResponse), + Error(ErrorResponse), + Sessions(SessionsResponse), + SessionSnapshot(SessionSnapshotResponse), + Buffers(BuffersResponse), + Buffer(BufferResponse), + FloatingList(FloatingListResponse), + Floating(FloatingResponse), + SubscriptionAck(SubscriptionAckResponse), + Snapshot(SnapshotResponse), + VisibleSnapshot(VisibleSnapshotResponse), + ScrollbackSlice(ScrollbackSliceResponse), +} + +impl ServerResponse { + pub fn request_id(&self) -> Option { + match self { + Self::Pong(response) => Some(response.request_id), + Self::Ok(response) => Some(response.request_id), + Self::Error(response) => response.request_id, + Self::Sessions(response) => Some(response.request_id), + Self::SessionSnapshot(response) => Some(response.request_id), + Self::Buffers(response) => Some(response.request_id), + Self::Buffer(response) => Some(response.request_id), + Self::FloatingList(response) => Some(response.request_id), + Self::Floating(response) => Some(response.request_id), + Self::SubscriptionAck(response) => Some(response.request_id), + Self::Snapshot(response) => Some(response.request_id), + Self::VisibleSnapshot(response) => Some(response.request_id), + Self::ScrollbackSlice(response) => Some(response.request_id), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SessionCreatedEvent { + pub session: SessionRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SessionClosedEvent { + pub session_id: SessionId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BufferCreatedEvent { + pub buffer: BufferRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BufferDetachedEvent { + pub buffer_id: BufferId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NodeChangedEvent { + pub session_id: SessionId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FloatingChangedEvent { + pub session_id: SessionId, + pub floating_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FocusChangedEvent { + pub session_id: SessionId, + pub focused_leaf_id: Option, + pub focused_floating_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RenderInvalidatedEvent { + pub buffer_id: BufferId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ServerEvent { + SessionCreated(SessionCreatedEvent), + SessionClosed(SessionClosedEvent), + BufferCreated(BufferCreatedEvent), + BufferDetached(BufferDetachedEvent), + NodeChanged(NodeChangedEvent), + FloatingChanged(FloatingChangedEvent), + FocusChanged(FocusChangedEvent), + RenderInvalidated(RenderInvalidatedEvent), +} + +impl ServerEvent { + pub fn session_id(&self) -> Option { + match self { + Self::SessionCreated(event) => Some(event.session.id), + Self::SessionClosed(event) => Some(event.session_id), + Self::BufferCreated(_) => None, + Self::BufferDetached(_) => None, + Self::NodeChanged(event) => Some(event.session_id), + Self::FloatingChanged(event) => Some(event.session_id), + Self::FocusChanged(event) => Some(event.session_id), + Self::RenderInvalidated(_) => None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ServerEnvelope { + Response(ServerResponse), + Event(ServerEvent), +} diff --git a/crates/embers-protocol/tests/family_round_trip.rs b/crates/embers-protocol/tests/family_round_trip.rs new file mode 100644 index 0000000..84c03c6 --- /dev/null +++ b/crates/embers-protocol/tests/family_round_trip.rs @@ -0,0 +1,487 @@ +use embers_core::{ + ActivityState, BufferId, CursorPosition, CursorShape, CursorState, ErrorCode, FloatGeometry, + FloatingId, NodeId, PtySize, RequestId, SessionId, SplitDirection, WireError, +}; +use embers_protocol::*; + +#[test] +fn client_message_families_round_trip() { + let messages = vec![ + ClientMessage::Ping(PingRequest { + request_id: RequestId(1), + payload: "hello".to_owned(), + }), + ClientMessage::Session(SessionRequest::Create { + request_id: RequestId(2), + name: "main".to_owned(), + }), + ClientMessage::Session(SessionRequest::List { + request_id: RequestId(3), + }), + ClientMessage::Session(SessionRequest::Get { + request_id: RequestId(4), + session_id: SessionId(10), + }), + ClientMessage::Session(SessionRequest::Close { + request_id: RequestId(5), + session_id: SessionId(10), + force: true, + }), + ClientMessage::Session(SessionRequest::AddRootTab { + request_id: RequestId(6), + session_id: SessionId(10), + title: "editor".to_owned(), + buffer_id: Some(BufferId(20)), + child_node_id: None, + }), + ClientMessage::Session(SessionRequest::SelectRootTab { + request_id: RequestId(7), + session_id: SessionId(10), + index: 1, + }), + ClientMessage::Session(SessionRequest::RenameRootTab { + request_id: RequestId(8), + session_id: SessionId(10), + index: 0, + title: "shell".to_owned(), + }), + ClientMessage::Session(SessionRequest::CloseRootTab { + request_id: RequestId(9), + session_id: SessionId(10), + index: 2, + }), + ClientMessage::Buffer(BufferRequest::Create { + request_id: RequestId(10), + title: Some("shell".to_owned()), + command: vec!["bash".to_owned(), "-lc".to_owned(), "pwd".to_owned()], + cwd: Some("/tmp".to_owned()), + env: std::collections::BTreeMap::from([("FOO".to_owned(), "bar".to_owned())]), + }), + ClientMessage::Buffer(BufferRequest::List { + request_id: RequestId(11), + session_id: Some(SessionId(10)), + attached_only: true, + detached_only: false, + }), + ClientMessage::Buffer(BufferRequest::Get { + request_id: RequestId(12), + buffer_id: BufferId(20), + }), + ClientMessage::Buffer(BufferRequest::Detach { + request_id: RequestId(13), + buffer_id: BufferId(20), + }), + ClientMessage::Buffer(BufferRequest::Kill { + request_id: RequestId(14), + buffer_id: BufferId(20), + force: true, + }), + ClientMessage::Buffer(BufferRequest::Capture { + request_id: RequestId(15), + buffer_id: BufferId(20), + }), + ClientMessage::Buffer(BufferRequest::CaptureVisible { + request_id: RequestId(151), + buffer_id: BufferId(20), + }), + ClientMessage::Buffer(BufferRequest::ScrollbackSlice { + request_id: RequestId(152), + buffer_id: BufferId(20), + start_line: 4, + line_count: 8, + }), + ClientMessage::Node(NodeRequest::GetTree { + request_id: RequestId(16), + session_id: SessionId(10), + }), + ClientMessage::Node(NodeRequest::Split { + request_id: RequestId(17), + leaf_node_id: NodeId(30), + direction: SplitDirection::Vertical, + new_buffer_id: BufferId(21), + }), + ClientMessage::Node(NodeRequest::CreateSplit { + request_id: RequestId(170), + session_id: SessionId(10), + direction: SplitDirection::Horizontal, + child_node_ids: vec![NodeId(50), NodeId(51)], + sizes: vec![2, 1], + }), + ClientMessage::Node(NodeRequest::CreateTabs { + request_id: RequestId(171), + session_id: SessionId(10), + child_node_ids: vec![NodeId(52), NodeId(53)], + titles: vec!["one".to_owned(), "two".to_owned()], + active: 1, + }), + ClientMessage::Node(NodeRequest::ReplaceNode { + request_id: RequestId(172), + node_id: NodeId(54), + child_node_id: NodeId(55), + }), + ClientMessage::Node(NodeRequest::WrapInSplit { + request_id: RequestId(173), + node_id: NodeId(56), + child_node_id: NodeId(57), + direction: SplitDirection::Vertical, + insert_before: true, + }), + ClientMessage::Node(NodeRequest::WrapInTabs { + request_id: RequestId(18), + node_id: NodeId(31), + title: "editor".to_owned(), + }), + ClientMessage::Node(NodeRequest::AddTab { + request_id: RequestId(19), + tabs_node_id: NodeId(32), + title: "logs".to_owned(), + buffer_id: Some(BufferId(23)), + child_node_id: None, + index: 2, + }), + ClientMessage::Node(NodeRequest::SelectTab { + request_id: RequestId(20), + tabs_node_id: NodeId(32), + index: 1, + }), + ClientMessage::Node(NodeRequest::Focus { + request_id: RequestId(21), + session_id: SessionId(10), + node_id: NodeId(30), + }), + ClientMessage::Node(NodeRequest::Close { + request_id: RequestId(22), + node_id: NodeId(33), + }), + ClientMessage::Node(NodeRequest::MoveBufferToNode { + request_id: RequestId(23), + buffer_id: BufferId(22), + target_leaf_node_id: NodeId(34), + }), + ClientMessage::Node(NodeRequest::Resize { + request_id: RequestId(24), + node_id: NodeId(35), + sizes: vec![3, 2, 1], + }), + ClientMessage::Floating(FloatingRequest::Create { + request_id: RequestId(25), + session_id: SessionId(10), + root_node_id: Some(NodeId(35)), + buffer_id: None, + geometry: FloatGeometry::new(4, 2, 60, 18), + title: Some("inspector".to_owned()), + focus: false, + close_on_empty: false, + }), + ClientMessage::Floating(FloatingRequest::Close { + request_id: RequestId(26), + floating_id: FloatingId(40), + }), + ClientMessage::Floating(FloatingRequest::Move { + request_id: RequestId(27), + floating_id: FloatingId(40), + geometry: FloatGeometry::new(8, 6, 50, 14), + }), + ClientMessage::Floating(FloatingRequest::Focus { + request_id: RequestId(28), + floating_id: FloatingId(40), + }), + ClientMessage::Input(InputRequest::Send { + request_id: RequestId(29), + buffer_id: BufferId(22), + bytes: vec![0x1b, b'[', b'A'], + }), + ClientMessage::Input(InputRequest::Resize { + request_id: RequestId(30), + buffer_id: BufferId(22), + cols: 132, + rows: 42, + }), + ClientMessage::Subscribe(SubscribeRequest { + request_id: RequestId(31), + session_id: Some(SessionId(10)), + }), + ClientMessage::Unsubscribe(UnsubscribeRequest { + request_id: RequestId(32), + subscription_id: 99, + }), + ]; + + for message in messages { + let encoded = encode_client_message(&message).expect("encode client message"); + let decoded = decode_client_message(&encoded).expect("decode client message"); + assert_eq!(decoded, message); + } +} + +#[test] +fn server_envelope_families_round_trip() { + let snapshot = sample_snapshot(); + let session = snapshot.session.clone(); + let buffers = snapshot.buffers.clone(); + let floating = snapshot.floating.clone(); + + let envelopes = vec![ + ServerEnvelope::Response(ServerResponse::Pong(PingResponse { + request_id: RequestId(30), + payload: "pong".to_owned(), + })), + ServerEnvelope::Response(ServerResponse::Ok(OkResponse { + request_id: RequestId(31), + })), + ServerEnvelope::Response(ServerResponse::Error(ErrorResponse { + request_id: None, + error: WireError::new(ErrorCode::ProtocolViolation, "bad frame"), + })), + ServerEnvelope::Response(ServerResponse::Error(ErrorResponse { + request_id: Some(RequestId(32)), + error: WireError::new(ErrorCode::NotFound, "missing"), + })), + ServerEnvelope::Response(ServerResponse::Sessions(SessionsResponse { + request_id: RequestId(33), + sessions: vec![session.clone()], + })), + ServerEnvelope::Response(ServerResponse::SessionSnapshot(SessionSnapshotResponse { + request_id: RequestId(34), + snapshot: snapshot.clone(), + })), + ServerEnvelope::Response(ServerResponse::Buffers(BuffersResponse { + request_id: RequestId(35), + buffers: buffers.clone(), + })), + ServerEnvelope::Response(ServerResponse::Buffer(BufferResponse { + request_id: RequestId(36), + buffer: buffers[0].clone(), + })), + ServerEnvelope::Response(ServerResponse::FloatingList(FloatingListResponse { + request_id: RequestId(37), + floating: floating.clone(), + })), + ServerEnvelope::Response(ServerResponse::Floating(FloatingResponse { + request_id: RequestId(38), + floating: floating[0].clone(), + })), + ServerEnvelope::Response(ServerResponse::SubscriptionAck(SubscriptionAckResponse { + request_id: RequestId(39), + subscription_id: 700, + })), + ServerEnvelope::Response(ServerResponse::Snapshot(SnapshotResponse { + request_id: RequestId(40), + buffer_id: BufferId(11), + sequence: 9, + size: PtySize::new(120, 40), + lines: vec!["alpha".to_owned(), "beta".to_owned()], + title: Some("shell".to_owned()), + cwd: Some("/tmp".to_owned()), + })), + ServerEnvelope::Response(ServerResponse::VisibleSnapshot(VisibleSnapshotResponse { + request_id: RequestId(401), + buffer_id: BufferId(11), + sequence: 10, + size: PtySize::new(120, 40), + lines: vec!["alpha".to_owned(), "beta".to_owned(), "".to_owned()], + title: Some("shell".to_owned()), + cwd: Some("/tmp".to_owned()), + viewport_top_line: 17, + total_lines: 43, + alternate_screen: true, + mouse_reporting: true, + focus_reporting: true, + bracketed_paste: true, + cursor: Some(CursorState { + position: CursorPosition { row: 1, col: 2 }, + shape: CursorShape::Beam, + }), + })), + ServerEnvelope::Response(ServerResponse::ScrollbackSlice(ScrollbackSliceResponse { + request_id: RequestId(402), + buffer_id: BufferId(11), + start_line: 12, + total_lines: 43, + lines: vec!["gamma".to_owned(), "delta".to_owned()], + })), + ServerEnvelope::Event(ServerEvent::SessionCreated(SessionCreatedEvent { + session: session.clone(), + })), + ServerEnvelope::Event(ServerEvent::SessionClosed(SessionClosedEvent { + session_id: SessionId(10), + })), + ServerEnvelope::Event(ServerEvent::BufferCreated(BufferCreatedEvent { + buffer: buffers[0].clone(), + })), + ServerEnvelope::Event(ServerEvent::BufferDetached(BufferDetachedEvent { + buffer_id: BufferId(11), + })), + ServerEnvelope::Event(ServerEvent::NodeChanged(NodeChangedEvent { + session_id: SessionId(10), + })), + ServerEnvelope::Event(ServerEvent::FloatingChanged(FloatingChangedEvent { + session_id: SessionId(10), + floating_id: Some(FloatingId(30)), + })), + ServerEnvelope::Event(ServerEvent::FocusChanged(FocusChangedEvent { + session_id: SessionId(10), + focused_leaf_id: Some(NodeId(21)), + focused_floating_id: Some(FloatingId(30)), + })), + ServerEnvelope::Event(ServerEvent::RenderInvalidated(RenderInvalidatedEvent { + buffer_id: BufferId(11), + })), + ]; + + for envelope in envelopes { + let encoded = encode_server_envelope(&envelope).expect("encode server envelope"); + let decoded = decode_server_envelope(&encoded).expect("decode server envelope"); + assert_eq!(decoded, envelope); + } +} + +fn sample_snapshot() -> SessionSnapshot { + SessionSnapshot { + session: SessionRecord { + id: SessionId(10), + name: "main".to_owned(), + root_node_id: NodeId(20), + floating_ids: vec![FloatingId(30)], + focused_leaf_id: Some(NodeId(21)), + focused_floating_id: Some(FloatingId(30)), + }, + nodes: vec![ + NodeRecord { + id: NodeId(20), + session_id: SessionId(10), + parent_id: None, + kind: NodeRecordKind::Split, + buffer_view: None, + split: Some(SplitRecord { + direction: SplitDirection::Horizontal, + child_ids: vec![NodeId(21), NodeId(22)], + sizes: vec![70, 50], + }), + tabs: None, + }, + NodeRecord { + id: NodeId(21), + session_id: SessionId(10), + parent_id: Some(NodeId(20)), + kind: NodeRecordKind::BufferView, + buffer_view: Some(BufferViewRecord { + buffer_id: BufferId(11), + focused: true, + zoomed: false, + follow_output: true, + last_render_size: PtySize::new(120, 40), + }), + split: None, + tabs: None, + }, + NodeRecord { + id: NodeId(22), + session_id: SessionId(10), + parent_id: Some(NodeId(20)), + kind: NodeRecordKind::Tabs, + buffer_view: None, + split: None, + tabs: Some(TabsRecord { + active: 1, + tabs: vec![ + TabRecord { + title: "logs".to_owned(), + child_id: NodeId(23), + }, + TabRecord { + title: "shell".to_owned(), + child_id: NodeId(24), + }, + ], + }), + }, + NodeRecord { + id: NodeId(23), + session_id: SessionId(10), + parent_id: Some(NodeId(22)), + kind: NodeRecordKind::BufferView, + buffer_view: Some(BufferViewRecord { + buffer_id: BufferId(12), + focused: false, + zoomed: false, + follow_output: false, + last_render_size: PtySize::new(80, 24), + }), + split: None, + tabs: None, + }, + NodeRecord { + id: NodeId(24), + session_id: SessionId(10), + parent_id: Some(NodeId(22)), + kind: NodeRecordKind::BufferView, + buffer_view: Some(BufferViewRecord { + buffer_id: BufferId(13), + focused: false, + zoomed: true, + follow_output: true, + last_render_size: PtySize::new(100, 30), + }), + split: None, + tabs: None, + }, + ], + buffers: vec![ + sample_buffer_record( + BufferId(11), + Some(NodeId(21)), + BufferRecordState::Running, + ActivityState::Activity, + None, + ), + sample_buffer_record( + BufferId(12), + Some(NodeId(23)), + BufferRecordState::Exited, + ActivityState::Bell, + Some(0), + ), + sample_buffer_record( + BufferId(13), + Some(NodeId(24)), + BufferRecordState::Created, + ActivityState::Idle, + None, + ), + ], + floating: vec![FloatingRecord { + id: FloatingId(30), + session_id: SessionId(10), + root_node_id: NodeId(24), + title: Some("inspector".to_owned()), + geometry: FloatGeometry::new(4, 2, 60, 18), + focused: true, + visible: true, + close_on_empty: false, + }], + } +} + +fn sample_buffer_record( + id: BufferId, + attachment_node_id: Option, + state: BufferRecordState, + activity: ActivityState, + exit_code: Option, +) -> BufferRecord { + BufferRecord { + id, + title: format!("buffer-{id}"), + command: vec!["bash".to_owned(), "-lc".to_owned(), "echo mux".to_owned()], + cwd: Some("/tmp".to_owned()), + state, + pid: Some(4242), + attachment_node_id, + pty_size: PtySize::new(120, 40), + activity, + last_snapshot_seq: 9, + exit_code, + env: std::collections::BTreeMap::from([("TERM".to_owned(), "xterm-256color".to_owned())]), + } +} diff --git a/crates/embers-protocol/tests/ping_round_trip.rs b/crates/embers-protocol/tests/ping_round_trip.rs new file mode 100644 index 0000000..bdd9c76 --- /dev/null +++ b/crates/embers-protocol/tests/ping_round_trip.rs @@ -0,0 +1,34 @@ +use embers_core::{RequestId, init_test_tracing}; +use embers_protocol::{ + ClientMessage, FrameType, PingRequest, RawFrame, decode_client_message, encode_client_message, + read_frame, write_frame, +}; + +#[tokio::test] +async fn ping_round_trips_through_codec_and_frame() { + init_test_tracing(); + + let request = ClientMessage::Ping(PingRequest { + request_id: RequestId(7), + payload: "phase2".to_owned(), + }); + let payload = encode_client_message(&request).expect("encode request"); + let frame = RawFrame::new(FrameType::Request, RequestId(7), payload); + let (mut writer, mut reader) = tokio::io::duplex(256); + + let write_task = tokio::spawn(async move { + write_frame(&mut writer, &frame).await.expect("write frame"); + }); + + let received_frame = read_frame(&mut reader) + .await + .expect("read frame") + .expect("frame payload"); + write_task.await.expect("writer task joins"); + + assert_eq!(received_frame.frame_type, FrameType::Request); + assert_eq!(received_frame.request_id, RequestId(7)); + + let decoded = decode_client_message(&received_frame.payload).expect("decode request"); + assert_eq!(decoded, request); +} diff --git a/crates/mux-server/Cargo.toml b/crates/embers-server/Cargo.toml similarity index 52% rename from crates/mux-server/Cargo.toml rename to crates/embers-server/Cargo.toml index 6f40321..c2a986e 100644 --- a/crates/mux-server/Cargo.toml +++ b/crates/embers-server/Cargo.toml @@ -1,15 +1,18 @@ [package] -name = "mux-server" +name = "embers-server" edition.workspace = true license.workspace = true rust-version.workspace = true version.workspace = true [dependencies] -mux-core = { path = "../mux-core" } -mux-protocol = { path = "../mux-protocol" } +alacritty_terminal = "0.25.1" +embers-core = { path = "../embers-core" } +embers-protocol = { path = "../embers-protocol" } +portable-pty.workspace = true tokio.workspace = true tracing.workspace = true [dev-dependencies] +proptest.workspace = true tempfile.workspace = true diff --git a/crates/embers-server/src/buffer_runtime.rs b/crates/embers-server/src/buffer_runtime.rs new file mode 100644 index 0000000..97ec825 --- /dev/null +++ b/crates/embers-server/src/buffer_runtime.rs @@ -0,0 +1,280 @@ +use std::any::Any; +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::io::{Read, Write}; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::thread; + +use embers_core::{BufferId, MuxError, PtySize, Result}; +use portable_pty::{ + Child, ChildKiller, CommandBuilder, MasterPty, NativePtySystem, PtySize as PortablePtySize, + PtySystem, +}; +use tracing::error; + +#[derive(Clone)] +pub struct BufferRuntimeHandle { + inner: Arc, +} + +struct BufferRuntimeInner { + buffer_id: BufferId, + pid: Option, + master: Mutex>, + writer: Mutex>, + killer: Mutex>, + threads: Mutex, +} + +#[derive(Default)] +struct RuntimeThreads { + reader: Option>, + wait: Option>, +} + +#[derive(Clone)] +pub struct BufferRuntimeCallbacks { + pub on_output: Arc) + Send + Sync>, + pub on_exit: Arc) + Send + Sync>, +} + +impl std::fmt::Debug for BufferRuntimeHandle { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("BufferRuntimeHandle") + .field("buffer_id", &self.inner.buffer_id) + .field("pid", &self.inner.pid) + .finish() + } +} + +impl BufferRuntimeHandle { + pub fn spawn( + buffer_id: BufferId, + command: &[String], + cwd: Option<&Path>, + env: &BTreeMap, + size: PtySize, + callbacks: BufferRuntimeCallbacks, + ) -> Result { + let Some(program) = command.first() else { + return Err(MuxError::invalid_input("buffer command must not be empty")); + }; + + let pty_system = NativePtySystem::default(); + let pair = pty_system + .openpty(to_portable_size(size)) + .map_err(|error| MuxError::pty(error.to_string()))?; + + let mut command_builder = CommandBuilder::new(program); + command_builder.args(&command[1..]); + if let Some(cwd) = cwd { + command_builder.cwd(cwd); + } + for (key, value) in env { + command_builder.env(key, value); + } + + let mut child = pair + .slave + .spawn_command(command_builder) + .map_err(|error| MuxError::pty(error.to_string()))?; + let pid = child.process_id(); + let mut killer = child.clone_killer(); + let reader = pair + .master + .try_clone_reader() + .map_err(|error| MuxError::pty(error.to_string()))?; + let writer = pair + .master + .take_writer() + .map_err(|error| MuxError::pty(error.to_string()))?; + + let on_output = callbacks.on_output.clone(); + let reader_handle = thread::Builder::new() + .name(format!("buffer-{buffer_id}-reader")) + .spawn(move || read_loop(buffer_id, reader, on_output)) + .map_err(|error| { + let _ = killer.kill(); + let _ = child.wait(); + MuxError::internal(error.to_string()) + })?; + + let on_exit = callbacks.on_exit.clone(); + let wait_handle = match thread::Builder::new() + .name(format!("buffer-{buffer_id}-wait")) + .spawn(move || wait_loop(buffer_id, child, on_exit)) + { + Ok(handle) => handle, + Err(error) => { + let _ = killer.kill(); + join_thread(buffer_id, "reader", reader_handle); + return Err(MuxError::internal(error.to_string())); + } + }; + + Ok(Self { + inner: Arc::new(BufferRuntimeInner { + buffer_id, + pid, + master: Mutex::new(pair.master), + writer: Mutex::new(writer), + killer: Mutex::new(killer), + threads: Mutex::new(RuntimeThreads { + reader: Some(reader_handle), + wait: Some(wait_handle), + }), + }), + }) + } + + pub fn pid(&self) -> Option { + self.inner.pid + } + + pub async fn write(&self, bytes: Vec) -> Result<()> { + let inner = self.inner.clone(); + tokio::task::spawn_blocking(move || { + let mut writer = inner + .writer + .lock() + .map_err(|_| MuxError::internal("buffer runtime writer lock poisoned"))?; + writer.write_all(&bytes)?; + writer.flush()?; + Ok(()) + }) + .await + .map_err(|error| MuxError::internal(error.to_string()))? + } + + pub async fn resize(&self, size: PtySize) -> Result<()> { + let inner = self.inner.clone(); + tokio::task::spawn_blocking(move || { + let master = inner + .master + .lock() + .map_err(|_| MuxError::internal("buffer runtime master lock poisoned"))?; + master + .resize(to_portable_size(size)) + .map_err(|error| MuxError::pty(error.to_string())) + }) + .await + .map_err(|error| MuxError::internal(error.to_string()))? + } + + pub async fn kill(&self) -> Result<()> { + let inner = self.inner.clone(); + tokio::task::spawn_blocking(move || { + let mut killer = inner + .killer + .lock() + .map_err(|_| MuxError::internal("buffer runtime killer lock poisoned"))?; + killer + .kill() + .map_err(|error| MuxError::pty(error.to_string())) + }) + .await + .map_err(|error| MuxError::internal(error.to_string()))? + } + + pub async fn join_threads(&self) -> Result<()> { + let inner = self.inner.clone(); + tokio::task::spawn_blocking(move || inner.join_threads_blocking()) + .await + .map_err(|error| MuxError::internal(error.to_string())) + } +} + +impl BufferRuntimeInner { + fn join_threads_blocking(&self) { + let mut threads = match self.threads.lock() { + Ok(threads) => threads, + Err(poisoned) => { + error!( + %self.buffer_id, + "buffer runtime thread registry lock poisoned during shutdown" + ); + poisoned.into_inner() + } + }; + let RuntimeThreads { reader, wait } = std::mem::take(&mut *threads); + drop(threads); + + if let Some(handle) = reader { + join_thread(self.buffer_id, "reader", handle); + } + if let Some(handle) = wait { + join_thread(self.buffer_id, "wait", handle); + } + } +} + +impl Drop for BufferRuntimeInner { + fn drop(&mut self) { + self.join_threads_blocking(); + } +} + +fn read_loop( + buffer_id: BufferId, + mut reader: Box, + on_output: Arc) + Send + Sync>, +) { + let mut buffer = [0_u8; 4096]; + loop { + match reader.read(&mut buffer) { + Ok(0) => break, + Ok(read) => on_output(buffer_id, buffer[..read].to_vec()), + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } +} + +fn wait_loop( + buffer_id: BufferId, + mut child: Box, + on_exit: Arc) + Send + Sync>, +) { + let exit_code = child.wait().ok().and_then(exit_status_code); + on_exit(buffer_id, exit_code); +} + +fn exit_status_code(status: portable_pty::ExitStatus) -> Option { + if status.signal().is_some() { + None + } else { + i32::try_from(status.exit_code()).ok() + } +} + +fn to_portable_size(size: PtySize) -> PortablePtySize { + PortablePtySize { + rows: size.rows, + cols: size.cols, + pixel_width: size.pixel_width, + pixel_height: size.pixel_height, + } +} + +fn join_thread(buffer_id: BufferId, role: &str, handle: thread::JoinHandle<()>) { + if let Err(payload) = handle.join() { + error!( + %buffer_id, + thread = role, + panic = %panic_payload_message(payload), + "buffer runtime thread panicked" + ); + } +} + +fn panic_payload_message(payload: Box) -> String { + match payload.downcast::() { + Ok(message) => *message, + Err(payload) => match payload.downcast::<&'static str>() { + Ok(message) => (*message).to_owned(), + Err(_) => "non-string panic payload".to_owned(), + }, + } +} diff --git a/crates/embers-server/src/config.rs b/crates/embers-server/src/config.rs new file mode 100644 index 0000000..674f2f4 --- /dev/null +++ b/crates/embers-server/src/config.rs @@ -0,0 +1,25 @@ +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::PathBuf; + +pub const SOCKET_ENV_VAR: &str = "EMBERS_SOCKET"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ServerConfig { + pub socket_path: PathBuf, + pub buffer_env: BTreeMap, +} + +impl ServerConfig { + pub fn new(socket_path: PathBuf) -> Self { + let mut buffer_env = BTreeMap::new(); + buffer_env.insert( + SOCKET_ENV_VAR.to_owned(), + socket_path.as_os_str().to_owned(), + ); + Self { + socket_path, + buffer_env, + } + } +} diff --git a/crates/embers-server/src/lib.rs b/crates/embers-server/src/lib.rs new file mode 100644 index 0000000..85615b8 --- /dev/null +++ b/crates/embers-server/src/lib.rs @@ -0,0 +1,21 @@ +pub mod model; +pub mod state; + +mod buffer_runtime; +mod config; +mod protocol; +mod server; +mod terminal_backend; + +pub use buffer_runtime::{BufferRuntimeCallbacks, BufferRuntimeHandle}; +pub use config::{SOCKET_ENV_VAR, ServerConfig}; +pub use model::{ + Buffer, BufferAttachment, BufferState, BufferViewNode, BufferViewState, ExitedBuffer, + FloatingWindow, Node, RunningBuffer, Session, SplitNode, TabEntry, TabsNode, +}; +pub use server::{Server, ServerHandle}; +pub use state::ServerState; +pub use terminal_backend::{ + AlacrittyTerminalBackend, BackendDamage, BackendMetadata, BackendScrollbackSlice, + RawByteRouter, TerminalBackend, +}; diff --git a/crates/embers-server/src/model.rs b/crates/embers-server/src/model.rs new file mode 100644 index 0000000..c266daf --- /dev/null +++ b/crates/embers-server/src/model.rs @@ -0,0 +1,222 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; + +use embers_core::{ + ActivityState, BufferId, FloatGeometry, FloatingId, NodeId, PtySize, SessionId, SplitDirection, + Timestamp, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Session { + pub id: SessionId, + pub name: String, + pub root_node: NodeId, + pub floating: Vec, + pub focused_leaf: Option, + pub focused_floating: Option, + pub created_at: Timestamp, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Buffer { + pub id: BufferId, + pub title: String, + pub command: Vec, + pub cwd: Option, + pub env: BTreeMap, + pub state: BufferState, + pub attachment: BufferAttachment, + pub pty_size: PtySize, + pub activity: ActivityState, + pub last_snapshot_seq: u64, + pub created_at: Timestamp, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RunningBuffer { + pub pid: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExitedBuffer { + pub exit_code: Option, + pub exited_at: Timestamp, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum BufferState { + #[default] + Created, + Running(RunningBuffer), + Exited(ExitedBuffer), +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum BufferAttachment { + Attached(NodeId), + #[default] + Detached, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Node { + BufferView(BufferViewNode), + Split(SplitNode), + Tabs(TabsNode), +} + +impl Node { + pub fn id(&self) -> NodeId { + match self { + Self::BufferView(node) => node.id, + Self::Split(node) => node.id, + Self::Tabs(node) => node.id, + } + } + + pub fn session_id(&self) -> SessionId { + match self { + Self::BufferView(node) => node.session_id, + Self::Split(node) => node.session_id, + Self::Tabs(node) => node.session_id, + } + } + + pub fn parent(&self) -> Option { + match self { + Self::BufferView(node) => node.parent, + Self::Split(node) => node.parent, + Self::Tabs(node) => node.parent, + } + } + + pub fn set_parent(&mut self, parent: Option) { + match self { + Self::BufferView(node) => node.parent = parent, + Self::Split(node) => node.parent = parent, + Self::Tabs(node) => node.parent = parent, + } + } + + pub fn child_ids(&self) -> Vec { + match self { + Self::BufferView(_) => Vec::new(), + Self::Split(node) => node.children.clone(), + Self::Tabs(node) => node.tabs.iter().map(|tab| tab.child).collect(), + } + } + + pub fn last_focused_descendant(&self) -> Option { + match self { + Self::BufferView(node) => node.view.focused.then_some(node.id), + Self::Split(node) => node.last_focused_descendant, + Self::Tabs(node) => node.last_focused_descendant, + } + } + + pub fn set_last_focused_descendant(&mut self, leaf_id: Option) { + match self { + Self::BufferView(_) => {} + Self::Split(node) => node.last_focused_descendant = leaf_id, + Self::Tabs(node) => node.last_focused_descendant = leaf_id, + } + } + + pub fn as_buffer_view(&self) -> Option<&BufferViewNode> { + match self { + Self::BufferView(node) => Some(node), + _ => None, + } + } + + pub fn as_split(&self) -> Option<&SplitNode> { + match self { + Self::Split(node) => Some(node), + _ => None, + } + } + + pub fn as_tabs(&self) -> Option<&TabsNode> { + match self { + Self::Tabs(node) => Some(node), + _ => None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BufferViewNode { + pub id: NodeId, + pub session_id: SessionId, + pub parent: Option, + pub buffer_id: BufferId, + pub view: BufferViewState, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BufferViewState { + pub focused: bool, + pub zoomed: bool, + pub follow_output: bool, + pub last_render_size: PtySize, +} + +impl Default for BufferViewState { + fn default() -> Self { + Self { + focused: false, + zoomed: false, + follow_output: true, + last_render_size: PtySize::new(80, 24), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SplitNode { + pub id: NodeId, + pub session_id: SessionId, + pub parent: Option, + pub direction: SplitDirection, + pub children: Vec, + pub sizes: Vec, + pub last_focused_descendant: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TabsNode { + pub id: NodeId, + pub session_id: SessionId, + pub parent: Option, + pub tabs: Vec, + pub active: usize, + pub last_focused_descendant: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TabEntry { + pub title: String, + pub child: NodeId, +} + +impl TabEntry { + pub fn new(title: impl Into, child: NodeId) -> Self { + Self { + title: title.into(), + child, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FloatingWindow { + pub id: FloatingId, + pub session_id: SessionId, + pub root_node: NodeId, + pub title: Option, + pub geometry: FloatGeometry, + pub focused: bool, + pub visible: bool, + pub close_on_empty: bool, + pub last_focused_leaf: Option, +} diff --git a/crates/embers-server/src/protocol.rs b/crates/embers-server/src/protocol.rs new file mode 100644 index 0000000..9f0ae37 --- /dev/null +++ b/crates/embers-server/src/protocol.rs @@ -0,0 +1,150 @@ +use embers_core::{MuxError, Result}; +use embers_protocol::{ + BufferRecord, BufferRecordState, BufferViewRecord, FloatingRecord, NodeRecord, NodeRecordKind, + SessionRecord, SessionSnapshot, SplitRecord, TabRecord, TabsRecord, +}; + +use crate::model::{Buffer, BufferAttachment, BufferState, FloatingWindow, Node, Session}; +use crate::state::ServerState; + +pub fn session_record(session: &Session) -> SessionRecord { + SessionRecord { + id: session.id, + name: session.name.clone(), + root_node_id: session.root_node, + floating_ids: session.floating.clone(), + focused_leaf_id: session.focused_leaf, + focused_floating_id: session.focused_floating, + } +} + +pub fn buffer_record(buffer: &Buffer) -> BufferRecord { + let (state, pid, exit_code) = match &buffer.state { + BufferState::Created => (BufferRecordState::Created, None, None), + BufferState::Running(running) => (BufferRecordState::Running, running.pid, None), + BufferState::Exited(exited) => (BufferRecordState::Exited, None, exited.exit_code), + }; + + BufferRecord { + id: buffer.id, + title: buffer.title.clone(), + command: buffer.command.clone(), + cwd: buffer + .cwd + .as_ref() + .map(|path| path.to_string_lossy().into_owned()), + state, + pid, + attachment_node_id: match buffer.attachment { + BufferAttachment::Attached(node_id) => Some(node_id), + BufferAttachment::Detached => None, + }, + pty_size: buffer.pty_size, + activity: buffer.activity, + last_snapshot_seq: buffer.last_snapshot_seq, + exit_code, + env: buffer.env.clone(), + } +} + +pub fn node_record(node: &Node) -> NodeRecord { + match node { + Node::BufferView(view) => NodeRecord { + id: view.id, + session_id: view.session_id, + parent_id: view.parent, + kind: NodeRecordKind::BufferView, + buffer_view: Some(BufferViewRecord { + buffer_id: view.buffer_id, + focused: view.view.focused, + zoomed: view.view.zoomed, + follow_output: view.view.follow_output, + last_render_size: view.view.last_render_size, + }), + split: None, + tabs: None, + }, + Node::Split(split) => NodeRecord { + id: split.id, + session_id: split.session_id, + parent_id: split.parent, + kind: NodeRecordKind::Split, + buffer_view: None, + split: Some(SplitRecord { + direction: split.direction, + child_ids: split.children.clone(), + sizes: split.sizes.clone(), + }), + tabs: None, + }, + Node::Tabs(tabs) => NodeRecord { + id: tabs.id, + session_id: tabs.session_id, + parent_id: tabs.parent, + kind: NodeRecordKind::Tabs, + buffer_view: None, + split: None, + tabs: Some(TabsRecord { + active: u32::try_from(tabs.active) + .expect("server tab indices fit into the protocol width"), + tabs: tabs + .tabs + .iter() + .map(|tab| TabRecord { + title: tab.title.clone(), + child_id: tab.child, + }) + .collect(), + }), + }, + } +} + +pub fn floating_record(floating: &FloatingWindow) -> FloatingRecord { + FloatingRecord { + id: floating.id, + session_id: floating.session_id, + root_node_id: floating.root_node, + title: floating.title.clone(), + geometry: floating.geometry, + focused: floating.focused, + visible: floating.visible, + close_on_empty: floating.close_on_empty, + } +} + +pub fn session_snapshot( + state: &ServerState, + session_id: embers_core::SessionId, +) -> Result { + let session = state.session(session_id)?; + let nodes = state + .session_node_ids(session_id)? + .into_iter() + .map(|node_id| state.node(node_id).map(node_record)) + .collect::>>()?; + let buffers = state + .session_buffer_ids(session_id)? + .into_iter() + .map(|buffer_id| state.buffer(buffer_id).map(buffer_record)) + .collect::>>()?; + let floating = session + .floating + .iter() + .map(|floating_id| state.floating_window(*floating_id).map(floating_record)) + .collect::>>()?; + + if !nodes.iter().any(|node| node.id == session.root_node) { + return Err(MuxError::conflict(format!( + "session snapshot for {} is missing its root node {}", + session_id, session.root_node + ))); + } + + Ok(SessionSnapshot { + session: session_record(session), + nodes, + buffers, + floating, + }) +} diff --git a/crates/embers-server/src/server.rs b/crates/embers-server/src/server.rs new file mode 100644 index 0000000..8f955e1 --- /dev/null +++ b/crates/embers-server/src/server.rs @@ -0,0 +1,1659 @@ +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use embers_core::{ + BufferId, ErrorCode, MuxError, PtySize, RequestId, Result, WireError, request_span, +}; +use embers_protocol::{ + BufferCreatedEvent, BufferDetachedEvent, BufferRequest, BufferResponse, BuffersResponse, + ClientMessage, ErrorResponse, FloatingChangedEvent, FloatingRequest, FloatingResponse, + FocusChangedEvent, FrameType, InputRequest, NodeChangedEvent, OkResponse, PingResponse, + ProtocolError, RawFrame, RenderInvalidatedEvent, ScrollbackSliceResponse, ServerEnvelope, + ServerEvent, ServerResponse, SessionClosedEvent, SessionCreatedEvent, SessionRequest, + SessionSnapshotResponse, SessionsResponse, SnapshotResponse, SubscriptionAckResponse, + VisibleSnapshotResponse, decode_client_message, encode_server_envelope, read_frame, + write_frame_no_flush, +}; +use tokio::net::UnixListener; +use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf}; +use tokio::sync::{Mutex, mpsc, oneshot}; +use tokio::task::JoinHandle; +use tracing::{debug, error, info}; + +use crate::protocol::{buffer_record, floating_record, session_record, session_snapshot}; +use crate::{ + AlacrittyTerminalBackend, BackendDamage, BufferAttachment, BufferRuntimeCallbacks, + BufferRuntimeHandle, BufferState, RawByteRouter, ServerConfig, ServerState, TabEntry, + TerminalBackend, +}; + +#[derive(Debug)] +pub struct Server { + config: ServerConfig, +} + +impl Server { + pub fn new(config: ServerConfig) -> Self { + Self { config } + } + + pub async fn start(self) -> Result { + if self.config.socket_path.exists() { + std::fs::remove_file(&self.config.socket_path)?; + } + + let listener = UnixListener::bind(&self.config.socket_path)?; + set_socket_permissions(&self.config.socket_path)?; + let socket_path = self.config.socket_path.clone(); + let runtime = Arc::new(Runtime::new(self.config.buffer_env.clone())); + let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); + + let join = tokio::spawn(async move { + let _cleanup = SocketCleanup::new(socket_path.clone()); + info!(socket_path = %socket_path.display(), "mux server listening"); + + loop { + tokio::select! { + _ = &mut shutdown_rx => { + debug!("server shutdown requested"); + break; + } + result = listener.accept() => { + let (stream, _) = result?; + let connection_id = runtime.next_connection_id.fetch_add(1, Ordering::Relaxed); + let (reader, writer) = stream.into_split(); + let (outbound_tx, outbound_rx) = mpsc::unbounded_channel(); + + let write_runtime = runtime.clone(); + let write_handle = tokio::spawn(write_loop(writer, outbound_rx)); + let read_runtime = runtime.clone(); + let read_handle = tokio::spawn(async move { + if let Err(error) = + handle_connection(read_runtime, connection_id, reader, outbound_tx) + .await + { + error!(%error, connection_id, "connection failed"); + } + }); + tokio::spawn(async move { + match write_handle.await { + Ok(Ok(())) => {} + Ok(Err(error)) => { + error!(%error, connection_id, "write_loop failed"); + } + Err(error) => { + error!(%error, connection_id, "write_loop panicked"); + } + } + read_handle.abort(); + let _ = read_handle.await; + write_runtime.cleanup_connection(connection_id).await; + }); + } + } + } + + runtime.shutdown_runtimes().await; + Ok(()) + }); + + Ok(ServerHandle { + socket_path: self.config.socket_path, + shutdown: Some(shutdown_tx), + join, + }) + } +} + +#[derive(Debug)] +pub struct ServerHandle { + socket_path: PathBuf, + shutdown: Option>, + join: JoinHandle>, +} + +impl ServerHandle { + pub fn socket_path(&self) -> &Path { + &self.socket_path + } + + pub async fn shutdown(mut self) -> Result<()> { + if let Some(sender) = self.shutdown.take() { + let _ = sender.send(()); + } + + self.join + .await + .map_err(|error| MuxError::internal(error.to_string()))? + } +} + +struct SocketCleanup { + socket_path: PathBuf, +} + +impl SocketCleanup { + fn new(socket_path: PathBuf) -> Self { + Self { socket_path } + } +} + +impl Drop for SocketCleanup { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.socket_path); + } +} + +#[derive(Debug)] +struct Subscription { + connection_id: u64, + session_id: Option, + sender: mpsc::UnboundedSender, +} + +#[derive(Debug)] +struct Runtime { + state: Mutex, + buffer_runtimes: Mutex>, + buffer_surfaces: Mutex>, + buffer_env: BTreeMap, + subscriptions: Mutex>, + next_connection_id: AtomicU64, + next_subscription_id: AtomicU64, +} + +struct BufferSurface { + router: RawByteRouter, + backend: Box, + size: PtySize, +} + +impl std::fmt::Debug for BufferSurface { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("BufferSurface") + .field("size", &self.size) + .finish() + } +} + +impl BufferSurface { + fn new(size: PtySize) -> Self { + Self { + router: RawByteRouter, + backend: Box::new(AlacrittyTerminalBackend::new(size)), + size, + } + } + + fn route_input(&mut self, bytes: Vec) -> Vec { + self.router.route_input(bytes) + } + + fn route_output(&mut self, bytes: &[u8]) { + self.router.route_output(self.backend.as_mut(), bytes); + } + + fn resize(&mut self, size: PtySize) { + self.size = size; + self.backend.resize(size); + } + + fn capture_lines(&self) -> Vec { + self.backend.capture_scrollback() + } + + fn capture_visible_snapshot( + &self, + sequence: u64, + cwd: Option, + ) -> embers_core::TerminalSnapshot { + self.backend.visible_snapshot(sequence, self.size, cwd) + } + + fn capture_scrollback_slice( + &self, + start_line: u64, + line_count: u32, + ) -> crate::BackendScrollbackSlice { + self.backend + .capture_scrollback_slice(start_line, line_count) + } + + fn metadata(&self) -> crate::BackendMetadata { + self.backend.metadata() + } + + fn take_activity(&mut self) -> embers_core::ActivityState { + self.backend.take_activity() + } + + fn damage(&mut self) -> BackendDamage { + self.backend.take_damage() + } +} + +impl Runtime { + fn new(buffer_env: BTreeMap) -> Self { + Self { + state: Mutex::new(ServerState::new()), + buffer_runtimes: Mutex::new(BTreeMap::new()), + buffer_surfaces: Mutex::new(BTreeMap::new()), + buffer_env, + subscriptions: Mutex::new(BTreeMap::new()), + next_connection_id: AtomicU64::new(1), + next_subscription_id: AtomicU64::new(1), + } + } +} + +impl Runtime { + async fn dispatch_request( + self: &Arc, + connection_id: u64, + outbound: &mpsc::UnboundedSender, + request: ClientMessage, + ) -> (ServerResponse, Vec) { + match request { + ClientMessage::Ping(request) => ( + ServerResponse::Pong(PingResponse { + request_id: request.request_id, + payload: request.payload, + }), + Vec::new(), + ), + ClientMessage::Session(request) => self.dispatch_session(request).await, + ClientMessage::Buffer(request) => self.dispatch_buffer(request).await, + ClientMessage::Node(request) => self.dispatch_node(request).await, + ClientMessage::Floating(request) => self.dispatch_floating(request).await, + ClientMessage::Input(request) => self.dispatch_input(request).await, + ClientMessage::Subscribe(request) => { + let subscription_id = self.next_subscription_id.fetch_add(1, Ordering::Relaxed); + self.subscriptions.lock().await.insert( + subscription_id, + Subscription { + connection_id, + session_id: request.session_id, + sender: outbound.clone(), + }, + ); + ( + ServerResponse::SubscriptionAck(SubscriptionAckResponse { + request_id: request.request_id, + subscription_id, + }), + Vec::new(), + ) + } + ClientMessage::Unsubscribe(request) => { + let mut subscriptions = self.subscriptions.lock().await; + match subscriptions.get(&request.subscription_id) { + Some(subscription) if subscription.connection_id == connection_id => { + subscriptions.remove(&request.subscription_id); + ( + ServerResponse::Ok(OkResponse { + request_id: request.request_id, + }), + Vec::new(), + ) + } + Some(_) => ( + error_response( + Some(request.request_id), + ErrorCode::Conflict, + format!( + "subscription {} does not belong to this connection", + request.subscription_id + ), + ), + Vec::new(), + ), + None => ( + error_response( + Some(request.request_id), + ErrorCode::NotFound, + format!("unknown subscription {}", request.subscription_id), + ), + Vec::new(), + ), + } + } + } + } + + async fn dispatch_session( + &self, + request: SessionRequest, + ) -> (ServerResponse, Vec) { + let mut state = self.state.lock().await; + + match request { + SessionRequest::Create { request_id, name } => { + let session_id = state.create_session(name); + match session_snapshot(&state, session_id) { + Ok(snapshot) => ( + ServerResponse::SessionSnapshot(SessionSnapshotResponse { + request_id, + snapshot: snapshot.clone(), + }), + vec![ServerEvent::SessionCreated(SessionCreatedEvent { + session: snapshot.session, + })], + ), + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + } + } + SessionRequest::List { request_id } => ( + ServerResponse::Sessions(SessionsResponse { + request_id, + sessions: state.sessions.values().map(session_record).collect(), + }), + Vec::new(), + ), + SessionRequest::Get { + request_id, + session_id, + } => match session_snapshot(&state, session_id) { + Ok(snapshot) => ( + ServerResponse::SessionSnapshot(SessionSnapshotResponse { + request_id, + snapshot, + }), + Vec::new(), + ), + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + }, + SessionRequest::Close { + request_id, + session_id, + force: _, + } => match state.close_session(session_id) { + Ok(()) => ( + ServerResponse::Ok(OkResponse { request_id }), + vec![ServerEvent::SessionClosed(SessionClosedEvent { + session_id, + })], + ), + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + }, + SessionRequest::AddRootTab { + request_id, + session_id, + title, + buffer_id, + child_node_id, + } => { + let result = match (buffer_id, child_node_id) { + (Some(buffer_id), None) => { + state.add_root_tab_from_buffer(session_id, title, buffer_id) + } + (None, Some(child_node_id)) => { + state.add_root_tab_from_subtree(session_id, title, child_node_id) + } + (Some(_), Some(_)) => Err(MuxError::invalid_input( + "add-root-tab requires either buffer_id or child_node_id, not both", + )), + (None, None) => Err(MuxError::invalid_input( + "add-root-tab requires either buffer_id or child_node_id", + )), + }; + match result { + Ok(_) => layout_snapshot_response(&state, request_id, session_id), + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + } + } + SessionRequest::SelectRootTab { + request_id, + session_id, + index, + } => match protocol_tab_index(index) + .and_then(|index| state.select_root_tab(session_id, index)) + { + Ok(()) => layout_snapshot_response(&state, request_id, session_id), + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + }, + SessionRequest::RenameRootTab { + request_id, + session_id, + index, + title, + } => match protocol_tab_index(index) + .and_then(|index| state.rename_root_tab(session_id, index, title)) + { + Ok(()) => layout_snapshot_response(&state, request_id, session_id), + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + }, + SessionRequest::CloseRootTab { + request_id, + session_id, + index, + } => match protocol_tab_index(index) + .and_then(|index| state.close_root_tab(session_id, index)) + { + Ok(()) => layout_snapshot_response(&state, request_id, session_id), + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + }, + } + } + + async fn dispatch_buffer( + self: &Arc, + request: BufferRequest, + ) -> (ServerResponse, Vec) { + match request { + BufferRequest::Create { + request_id, + title, + command, + cwd, + env, + } => { + if command.is_empty() { + return ( + error_response( + Some(request_id), + ErrorCode::InvalidRequest, + "buffer command must not be empty", + ), + Vec::new(), + ); + } + + let buffer_id = { + let mut state = self.state.lock().await; + state.create_buffer_with_env( + title.unwrap_or_else(|| "buffer".to_owned()), + command, + cwd.map(Into::into), + env, + ) + }; + + if let Err(error) = self.spawn_buffer_runtime(buffer_id).await { + let mut state = self.state.lock().await; + let _ = state.remove_buffer(buffer_id); + self.buffer_surfaces.lock().await.remove(&buffer_id); + return (mux_error_response(Some(request_id), error), Vec::new()); + } + + let record = self + .state + .lock() + .await + .buffer(buffer_id) + .map(buffer_record) + .map_err(|error| mux_error_response(Some(request_id), error)); + match record { + Ok(record) => ( + ServerResponse::Buffer(BufferResponse { + request_id, + buffer: record.clone(), + }), + vec![ServerEvent::BufferCreated(BufferCreatedEvent { + buffer: record, + })], + ), + Err(error) => (error, Vec::new()), + } + } + BufferRequest::List { + request_id, + session_id, + attached_only, + detached_only, + } => { + if attached_only && detached_only { + return ( + error_response( + Some(request_id), + ErrorCode::InvalidRequest, + "attached_only and detached_only cannot both be true", + ), + Vec::new(), + ); + } + + let state = self.state.lock().await; + let buffers = state + .buffers + .values() + .filter(|buffer| { + if attached_only && matches!(buffer.attachment, BufferAttachment::Detached) + { + return false; + } + if detached_only && !matches!(buffer.attachment, BufferAttachment::Detached) + { + return false; + } + match session_id { + Some(session_id) => match buffer.attachment { + BufferAttachment::Attached(node_id) => state + .node(node_id) + .map(|node| node.session_id() == session_id) + .unwrap_or(false), + BufferAttachment::Detached => false, + }, + None => true, + } + }) + .map(buffer_record) + .collect(); + + ( + ServerResponse::Buffers(BuffersResponse { + request_id, + buffers, + }), + Vec::new(), + ) + } + BufferRequest::Get { + request_id, + buffer_id, + } => match self.state.lock().await.buffer(buffer_id) { + Ok(buffer) => ( + ServerResponse::Buffer(BufferResponse { + request_id, + buffer: buffer_record(buffer), + }), + Vec::new(), + ), + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + }, + BufferRequest::Detach { + request_id, + buffer_id, + } => { + let mut state = self.state.lock().await; + let attached_view = match state.buffer(buffer_id) { + Ok(buffer) => match buffer.attachment { + BufferAttachment::Attached(node_id) => Some(node_id), + BufferAttachment::Detached => None, + }, + Err(error) => return (mux_error_response(Some(request_id), error), Vec::new()), + }; + + let mut events = vec![ServerEvent::BufferDetached(BufferDetachedEvent { + buffer_id, + })]; + if let Some(view_id) = attached_view { + let session_id = match state.node(view_id) { + Ok(node) => node.session_id(), + Err(error) => { + return (mux_error_response(Some(request_id), error), Vec::new()); + } + }; + if let Err(error) = state.close_node(view_id) { + return (mux_error_response(Some(request_id), error), Vec::new()); + } + if let Some(focus_event) = focus_changed_event(&state, session_id) { + events.push(ServerEvent::FocusChanged(focus_event)); + } + events.push(ServerEvent::NodeChanged(NodeChangedEvent { session_id })); + } + + (ServerResponse::Ok(OkResponse { request_id }), events) + } + BufferRequest::Kill { + request_id, + buffer_id, + force: _, + } => match self.running_buffer_runtime(buffer_id).await { + Ok(runtime) => match runtime.kill().await { + Ok(()) => (ServerResponse::Ok(OkResponse { request_id }), Vec::new()), + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + }, + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + }, + BufferRequest::Capture { + request_id, + buffer_id, + } => match self.capture_snapshot(request_id, buffer_id).await { + Ok(snapshot) => (ServerResponse::Snapshot(snapshot), Vec::new()), + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + }, + BufferRequest::CaptureVisible { + request_id, + buffer_id, + } => match self.capture_visible_snapshot(request_id, buffer_id).await { + Ok(snapshot) => (ServerResponse::VisibleSnapshot(snapshot), Vec::new()), + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + }, + BufferRequest::ScrollbackSlice { + request_id, + buffer_id, + start_line, + line_count, + } => match self + .capture_scrollback_slice(request_id, buffer_id, start_line, line_count) + .await + { + Ok(snapshot) => (ServerResponse::ScrollbackSlice(snapshot), Vec::new()), + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + }, + } + } + + async fn dispatch_input( + self: &Arc, + request: InputRequest, + ) -> (ServerResponse, Vec) { + match request { + InputRequest::Send { + request_id, + buffer_id, + bytes, + } => match self.running_buffer_runtime(buffer_id).await { + Ok(runtime) => { + let bytes = self.route_input_bytes(buffer_id, bytes).await; + match runtime.write(bytes).await { + Ok(()) => (ServerResponse::Ok(OkResponse { request_id }), Vec::new()), + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + } + } + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + }, + InputRequest::Resize { + request_id, + buffer_id, + cols, + rows, + } => { + let runtime = match self.running_buffer_runtime(buffer_id).await { + Ok(runtime) => runtime, + Err(error) => return (mux_error_response(Some(request_id), error), Vec::new()), + }; + let size = { + let state = self.state.lock().await; + match state.buffer(buffer_id) { + Ok(buffer) => PtySize { + cols, + rows, + pixel_width: buffer.pty_size.pixel_width, + pixel_height: buffer.pty_size.pixel_height, + }, + Err(error) => { + return (mux_error_response(Some(request_id), error), Vec::new()); + } + } + }; + + if let Err(error) = runtime.resize(size).await { + return (mux_error_response(Some(request_id), error), Vec::new()); + } + + { + let mut state = self.state.lock().await; + if let Err(error) = state.set_buffer_size(buffer_id, size) { + return (mux_error_response(Some(request_id), error), Vec::new()); + } + } + let damage = self.resize_surface(buffer_id, size).await; + + ( + ServerResponse::Ok(OkResponse { request_id }), + render_events(buffer_id, damage), + ) + } + } + } + + async fn dispatch_node( + &self, + request: embers_protocol::NodeRequest, + ) -> (ServerResponse, Vec) { + let mut state = self.state.lock().await; + + match request { + embers_protocol::NodeRequest::GetTree { + request_id, + session_id, + } => match session_snapshot(&state, session_id) { + Ok(snapshot) => ( + ServerResponse::SessionSnapshot(SessionSnapshotResponse { + request_id, + snapshot, + }), + Vec::new(), + ), + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + }, + embers_protocol::NodeRequest::Split { + request_id, + leaf_node_id, + direction, + new_buffer_id, + } => { + let session_id = match state.node(leaf_node_id) { + Ok(node) => node.session_id(), + Err(error) => return (mux_error_response(Some(request_id), error), Vec::new()), + }; + if let Err(error) = + state.split_leaf_with_new_buffer(leaf_node_id, direction, new_buffer_id) + { + return (mux_error_response(Some(request_id), error), Vec::new()); + } + + match session_snapshot(&state, session_id) { + Ok(snapshot) => { + let mut events = + vec![ServerEvent::NodeChanged(NodeChangedEvent { session_id })]; + if let Some(focus_event) = focus_changed_event(&state, session_id) { + events.push(ServerEvent::FocusChanged(focus_event)); + } + ( + ServerResponse::SessionSnapshot(SessionSnapshotResponse { + request_id, + snapshot, + }), + events, + ) + } + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + } + } + embers_protocol::NodeRequest::CreateSplit { + request_id, + session_id, + direction, + child_node_ids, + sizes, + } => match state.create_split_node(session_id, direction, child_node_ids) { + Ok(split_id) => { + if !sizes.is_empty() + && let Err(error) = state.resize_split_children(split_id, sizes) + { + return (mux_error_response(Some(request_id), error), Vec::new()); + } + layout_snapshot_response(&state, request_id, session_id) + } + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + }, + embers_protocol::NodeRequest::CreateTabs { + request_id, + session_id, + child_node_ids, + titles, + active, + } => { + if child_node_ids.len() != titles.len() { + return ( + mux_error_response( + Some(request_id), + MuxError::invalid_input( + "create-tabs requires the same number of titles and child ids", + ), + ), + Vec::new(), + ); + } + let tabs = titles + .into_iter() + .zip(child_node_ids) + .map(|(title, child)| TabEntry::new(title, child)) + .collect(); + match protocol_tab_index(active) + .and_then(|active| state.create_tabs_node(session_id, tabs, active)) + { + Ok(_) => layout_snapshot_response(&state, request_id, session_id), + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + } + } + embers_protocol::NodeRequest::ReplaceNode { + request_id, + node_id, + child_node_id, + } => { + let session_id = match state.node(node_id) { + Ok(node) => node.session_id(), + Err(error) => return (mux_error_response(Some(request_id), error), Vec::new()), + }; + match state.replace_node(node_id, child_node_id) { + Ok(()) => layout_snapshot_response(&state, request_id, session_id), + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + } + } + embers_protocol::NodeRequest::WrapInSplit { + request_id, + node_id, + child_node_id, + direction, + insert_before, + } => { + let session_id = match state.node(node_id) { + Ok(node) => node.session_id(), + Err(error) => return (mux_error_response(Some(request_id), error), Vec::new()), + }; + match state.wrap_node_in_split(node_id, direction, child_node_id, insert_before) { + Ok(_) => layout_snapshot_response(&state, request_id, session_id), + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + } + } + embers_protocol::NodeRequest::WrapInTabs { + request_id, + node_id, + title, + } => { + let session_id = match state.node(node_id) { + Ok(node) => node.session_id(), + Err(error) => return (mux_error_response(Some(request_id), error), Vec::new()), + }; + if let Err(error) = state.wrap_node_in_tabs(node_id, title) { + return (mux_error_response(Some(request_id), error), Vec::new()); + } + layout_snapshot_response(&state, request_id, session_id) + } + embers_protocol::NodeRequest::AddTab { + request_id, + tabs_node_id, + title, + buffer_id, + child_node_id, + index, + } => { + let session_id = match state.node(tabs_node_id) { + Ok(node) => node.session_id(), + Err(error) => return (mux_error_response(Some(request_id), error), Vec::new()), + }; + let result = + protocol_tab_index(index).and_then(|index| match (buffer_id, child_node_id) { + (Some(buffer_id), None) => { + state.add_tab_from_buffer_at(tabs_node_id, index, title, buffer_id) + } + (None, Some(child_node_id)) => { + state.add_tab_sibling_at(tabs_node_id, index, title, child_node_id) + } + (Some(_), Some(_)) => Err(MuxError::invalid_input( + "add-tab requires either buffer_id or child_node_id, not both", + )), + (None, None) => Err(MuxError::invalid_input( + "add-tab requires either buffer_id or child_node_id", + )), + }); + match result { + Ok(_) => layout_snapshot_response(&state, request_id, session_id), + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + } + } + embers_protocol::NodeRequest::SelectTab { + request_id, + tabs_node_id, + index, + } => { + let session_id = match state.node(tabs_node_id) { + Ok(node) => node.session_id(), + Err(error) => return (mux_error_response(Some(request_id), error), Vec::new()), + }; + let index = match protocol_tab_index(index) { + Ok(index) => index, + Err(error) => return (mux_error_response(Some(request_id), error), Vec::new()), + }; + if let Err(error) = state.switch_tab(tabs_node_id, index) { + return (mux_error_response(Some(request_id), error), Vec::new()); + } + match session_snapshot(&state, session_id) { + Ok(snapshot) => { + let mut events = + vec![ServerEvent::NodeChanged(NodeChangedEvent { session_id })]; + if let Some(focus_event) = focus_changed_event(&state, session_id) { + events.push(ServerEvent::FocusChanged(focus_event)); + } + ( + ServerResponse::SessionSnapshot(SessionSnapshotResponse { + request_id, + snapshot, + }), + events, + ) + } + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + } + } + embers_protocol::NodeRequest::Focus { + request_id, + session_id, + node_id, + } => { + let target_leaf = match state.node(node_id) { + Ok(crate::Node::BufferView(_)) => Some(node_id), + Ok(_) => state + .resolve_visible_leaf(node_id) + .or_else(|_| state.resolve_first_leaf(node_id)) + .ok() + .flatten(), + Err(error) => return (mux_error_response(Some(request_id), error), Vec::new()), + }; + + let Some(target_leaf) = target_leaf else { + return ( + error_response( + Some(request_id), + ErrorCode::InvalidRequest, + format!("node {node_id} has no focusable leaf"), + ), + Vec::new(), + ); + }; + + if let Err(error) = state.focus_leaf(session_id, target_leaf) { + return (mux_error_response(Some(request_id), error), Vec::new()); + } + + match session_snapshot(&state, session_id) { + Ok(snapshot) => { + let mut events = + vec![ServerEvent::NodeChanged(NodeChangedEvent { session_id })]; + if let Some(focus_event) = focus_changed_event(&state, session_id) { + events.push(ServerEvent::FocusChanged(focus_event)); + } + ( + ServerResponse::SessionSnapshot(SessionSnapshotResponse { + request_id, + snapshot, + }), + events, + ) + } + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + } + } + embers_protocol::NodeRequest::Close { + request_id, + node_id, + } => { + let session_id = match state.node(node_id) { + Ok(node) => node.session_id(), + Err(error) => return (mux_error_response(Some(request_id), error), Vec::new()), + }; + if let Err(error) = state.close_node(node_id) { + return (mux_error_response(Some(request_id), error), Vec::new()); + } + match session_snapshot(&state, session_id) { + Ok(snapshot) => { + let mut events = + vec![ServerEvent::NodeChanged(NodeChangedEvent { session_id })]; + if let Some(focus_event) = focus_changed_event(&state, session_id) { + events.push(ServerEvent::FocusChanged(focus_event)); + } + ( + ServerResponse::SessionSnapshot(SessionSnapshotResponse { + request_id, + snapshot, + }), + events, + ) + } + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + } + } + embers_protocol::NodeRequest::Resize { + request_id, + node_id, + sizes, + } => { + let session_id = match state.node(node_id) { + Ok(node) => node.session_id(), + Err(error) => return (mux_error_response(Some(request_id), error), Vec::new()), + }; + if let Err(error) = state.resize_split_children(node_id, sizes) { + return (mux_error_response(Some(request_id), error), Vec::new()); + } + layout_snapshot_response(&state, request_id, session_id) + } + embers_protocol::NodeRequest::MoveBufferToNode { + request_id, + buffer_id, + target_leaf_node_id, + } => { + let session_id = match state.node(target_leaf_node_id) { + Ok(node) => node.session_id(), + Err(error) => return (mux_error_response(Some(request_id), error), Vec::new()), + }; + if let Err(error) = state.move_buffer_to_leaf(buffer_id, target_leaf_node_id) { + return (mux_error_response(Some(request_id), error), Vec::new()); + } + layout_snapshot_response(&state, request_id, session_id) + } + } + } + + async fn dispatch_floating( + &self, + request: FloatingRequest, + ) -> (ServerResponse, Vec) { + let mut state = self.state.lock().await; + + match request { + FloatingRequest::Create { + request_id, + session_id, + root_node_id, + buffer_id, + geometry, + title, + focus, + close_on_empty, + } => match match (root_node_id, buffer_id) { + (Some(root_node_id), None) => state.create_floating_window_with_options( + session_id, + root_node_id, + geometry, + title, + focus, + close_on_empty, + ), + (None, Some(buffer_id)) => state.create_floating_from_buffer_with_options( + session_id, + buffer_id, + geometry, + title, + focus, + close_on_empty, + ), + (Some(_), Some(_)) => Err(MuxError::invalid_input( + "create-floating requires either root_node_id or buffer_id, not both", + )), + (None, None) => Err(MuxError::invalid_input( + "create-floating requires either root_node_id or buffer_id", + )), + } { + Ok(floating_id) => match state.floating_window(floating_id) { + Ok(floating) => { + let mut events = vec![ServerEvent::FloatingChanged(FloatingChangedEvent { + session_id, + floating_id: Some(floating_id), + })]; + if let Some(focus_event) = focus_changed_event(&state, session_id) { + events.push(ServerEvent::FocusChanged(focus_event)); + } + ( + ServerResponse::Floating(FloatingResponse { + request_id, + floating: floating_record(floating), + }), + events, + ) + } + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + }, + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + }, + FloatingRequest::Close { + request_id, + floating_id, + } => { + let session_id = match state.floating_window(floating_id) { + Ok(floating) => floating.session_id, + Err(error) => return (mux_error_response(Some(request_id), error), Vec::new()), + }; + if let Err(error) = state.close_floating(floating_id) { + return (mux_error_response(Some(request_id), error), Vec::new()); + } + let mut events = vec![ServerEvent::FloatingChanged(FloatingChangedEvent { + session_id, + floating_id: Some(floating_id), + })]; + if let Some(focus_event) = focus_changed_event(&state, session_id) { + events.push(ServerEvent::FocusChanged(focus_event)); + } + (ServerResponse::Ok(OkResponse { request_id }), events) + } + FloatingRequest::Move { + request_id, + floating_id, + geometry, + } => match state.move_floating(floating_id, geometry) { + Ok(()) => { + let floating = match state.floating_window(floating_id) { + Ok(floating) => floating, + Err(error) => { + return (mux_error_response(Some(request_id), error), Vec::new()); + } + }; + ( + ServerResponse::Floating(FloatingResponse { + request_id, + floating: floating_record(floating), + }), + vec![ServerEvent::FloatingChanged(FloatingChangedEvent { + session_id: floating.session_id, + floating_id: Some(floating_id), + })], + ) + } + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + }, + FloatingRequest::Focus { + request_id, + floating_id, + } => { + let session_id = match state.floating_window(floating_id) { + Ok(floating) => floating.session_id, + Err(error) => return (mux_error_response(Some(request_id), error), Vec::new()), + }; + if let Err(error) = state.focus_floating(floating_id) { + return (mux_error_response(Some(request_id), error), Vec::new()); + } + let floating = match state.floating_window(floating_id) { + Ok(floating) => floating, + Err(error) => return (mux_error_response(Some(request_id), error), Vec::new()), + }; + let mut events = Vec::new(); + if let Some(focus_event) = focus_changed_event(&state, session_id) { + events.push(ServerEvent::FocusChanged(focus_event)); + } + ( + ServerResponse::Floating(FloatingResponse { + request_id, + floating: floating_record(floating), + }), + events, + ) + } + } + } + + async fn spawn_buffer_runtime(self: &Arc, buffer_id: BufferId) -> Result<()> { + let (command, cwd, size, env_hints) = { + let state = self.state.lock().await; + let buffer = state.buffer(buffer_id)?.clone(); + (buffer.command, buffer.cwd, buffer.pty_size, buffer.env) + }; + + let output_handle = tokio::runtime::Handle::current(); + let exit_handle = output_handle.clone(); + let output_runtime = self.clone(); + let exit_runtime = self.clone(); + let mut buffer_env = self.buffer_env.clone(); + for (key, value) in env_hints { + buffer_env.insert(key, OsString::from(value)); + } + let runtime = BufferRuntimeHandle::spawn( + buffer_id, + &command, + cwd.as_deref(), + &buffer_env, + size, + BufferRuntimeCallbacks { + on_output: Arc::new(move |buffer_id, bytes| { + let runtime = output_runtime.clone(); + std::mem::drop(output_handle.spawn(async move { + runtime.record_buffer_output(buffer_id, bytes).await; + })); + }), + on_exit: Arc::new(move |buffer_id, exit_code| { + let runtime = exit_runtime.clone(); + std::mem::drop(exit_handle.spawn(async move { + runtime.record_buffer_exit(buffer_id, exit_code).await; + })); + }), + }, + )?; + + { + let mut state = self.state.lock().await; + if let Err(error) = state.mark_buffer_running(buffer_id, runtime.pid()) { + let _ = runtime.kill().await; + let _ = runtime.join_threads().await; + return Err(error); + } + } + + self.buffer_surfaces + .lock() + .await + .entry(buffer_id) + .or_insert_with(|| BufferSurface::new(size)); + self.buffer_runtimes.lock().await.insert(buffer_id, runtime); + Ok(()) + } + + async fn running_buffer_runtime(&self, buffer_id: BufferId) -> Result { + if let Some(runtime) = self.buffer_runtimes.lock().await.get(&buffer_id).cloned() { + return Ok(runtime); + } + + let state = self.state.lock().await; + let buffer = state.buffer(buffer_id)?; + match buffer.state { + BufferState::Created => Err(MuxError::conflict(format!( + "buffer {buffer_id} is not running" + ))), + BufferState::Running(_) => Err(MuxError::internal(format!( + "buffer {buffer_id} is marked running without an active runtime" + ))), + BufferState::Exited(_) => Err(MuxError::conflict(format!( + "buffer {buffer_id} has already exited" + ))), + } + } + + async fn capture_snapshot( + &self, + request_id: RequestId, + buffer_id: BufferId, + ) -> Result { + let buffer = { + let state = self.state.lock().await; + state.buffer(buffer_id)?.clone() + }; + let lines = self + .buffer_surfaces + .lock() + .await + .get(&buffer_id) + .map(BufferSurface::capture_lines) + .unwrap_or_default(); + + Ok(SnapshotResponse { + request_id, + buffer_id, + sequence: buffer.last_snapshot_seq, + size: buffer.pty_size, + lines, + title: Some(buffer.title), + cwd: buffer.cwd.map(|path| path.display().to_string()), + }) + } + + async fn capture_visible_snapshot( + &self, + request_id: RequestId, + buffer_id: BufferId, + ) -> Result { + let buffer = { + let state = self.state.lock().await; + state.buffer(buffer_id)?.clone() + }; + let snapshot = { + let mut surfaces = self.buffer_surfaces.lock().await; + surfaces + .entry(buffer_id) + .or_insert_with(|| BufferSurface::new(buffer.pty_size)) + .capture_visible_snapshot(buffer.last_snapshot_seq, buffer.cwd.clone()) + }; + + Ok(VisibleSnapshotResponse { + request_id, + buffer_id, + sequence: snapshot.sequence, + size: snapshot.size, + lines: snapshot.lines.into_iter().map(|line| line.text).collect(), + title: snapshot.title, + cwd: snapshot.cwd.map(|path| path.display().to_string()), + viewport_top_line: snapshot.viewport_top_line, + total_lines: snapshot.total_lines, + alternate_screen: snapshot.modes.alternate_screen, + mouse_reporting: snapshot.modes.mouse_reporting, + focus_reporting: snapshot.modes.focus_reporting, + bracketed_paste: snapshot.modes.bracketed_paste, + cursor: snapshot.cursor, + }) + } + + async fn capture_scrollback_slice( + &self, + request_id: RequestId, + buffer_id: BufferId, + start_line: u64, + line_count: u32, + ) -> Result { + let buffer = { + let state = self.state.lock().await; + state.buffer(buffer_id)?.clone() + }; + let slice = { + let mut surfaces = self.buffer_surfaces.lock().await; + surfaces + .entry(buffer_id) + .or_insert_with(|| BufferSurface::new(buffer.pty_size)) + .capture_scrollback_slice(start_line, line_count) + }; + + Ok(ScrollbackSliceResponse { + request_id, + buffer_id, + start_line: slice.start_line, + total_lines: slice.total_lines, + lines: slice.lines, + }) + } + + async fn route_input_bytes(&self, buffer_id: BufferId, bytes: Vec) -> Vec { + match self.buffer_surfaces.lock().await.get_mut(&buffer_id) { + Some(surface) => surface.route_input(bytes), + None => bytes, + } + } + + async fn resize_surface(&self, buffer_id: BufferId, size: PtySize) -> BackendDamage { + let mut surfaces = self.buffer_surfaces.lock().await; + let surface = surfaces + .entry(buffer_id) + .or_insert_with(|| BufferSurface::new(size)); + surface.resize(size); + surface.damage() + } + + async fn record_buffer_output(&self, buffer_id: BufferId, bytes: Vec) { + let size = { + let mut state = self.state.lock().await; + if let Err(error) = state.note_buffer_output(buffer_id) { + debug!(%buffer_id, %error, "dropping PTY output for unknown buffer"); + return; + } + match state.buffer(buffer_id) { + Ok(buffer) => buffer.pty_size, + Err(error) => { + debug!(%buffer_id, %error, "buffer disappeared while recording output"); + return; + } + } + }; + + let (metadata, activity, damage) = { + let mut surfaces = self.buffer_surfaces.lock().await; + let surface = surfaces + .entry(buffer_id) + .or_insert_with(|| BufferSurface::new(size)); + surface.resize(size); + surface.route_output(&bytes); + ( + surface.metadata(), + surface.take_activity(), + surface.damage(), + ) + }; + + { + let mut state = self.state.lock().await; + if let Some(title) = metadata.title + && let Err(error) = state.set_buffer_title(buffer_id, title) + { + debug!(%buffer_id, %error, "failed to apply terminal title update"); + } + if let Err(error) = state.set_buffer_activity(buffer_id, activity) { + debug!(%buffer_id, %error, "failed to apply buffer activity update"); + } + } + + self.broadcast(render_events(buffer_id, damage)).await; + } + + async fn record_buffer_exit(&self, buffer_id: BufferId, exit_code: Option) { + let runtime = self.buffer_runtimes.lock().await.remove(&buffer_id); + let updated = { + let mut state = self.state.lock().await; + match state.mark_buffer_exited(buffer_id, exit_code) { + Ok(()) => true, + Err(error) => { + debug!(%buffer_id, %error, "buffer exited after state cleanup"); + false + } + } + }; + + if let Some(runtime) = runtime + && let Err(error) = runtime.join_threads().await + { + debug!(%buffer_id, %error, "failed to join buffer runtime threads"); + } + + if updated { + self.broadcast(vec![ServerEvent::RenderInvalidated( + RenderInvalidatedEvent { buffer_id }, + )]) + .await; + } + } + + async fn shutdown_runtimes(&self) { + let runtimes: Vec<_> = self + .buffer_runtimes + .lock() + .await + .values() + .cloned() + .collect(); + for runtime in runtimes { + if let Err(error) = runtime.kill().await { + debug!(%error, "failed to kill buffer runtime during shutdown"); + } + if let Err(error) = runtime.join_threads().await { + debug!(%error, "failed to join buffer runtime threads during shutdown"); + } + } + } + + async fn broadcast(&self, events: Vec) { + if events.is_empty() { + return; + } + + let mut subscriptions = self.subscriptions.lock().await; + subscriptions.retain(|_, subscription| { + for event in &events { + let event_matches = event.session_id().is_none() + || subscription.session_id.is_none() + || subscription.session_id == event.session_id(); + + if event_matches + && subscription + .sender + .send(ServerEnvelope::Event(event.clone())) + .is_err() + { + return false; + } + } + true + }); + } + + async fn cleanup_connection(&self, connection_id: u64) { + self.subscriptions + .lock() + .await + .retain(|_, subscription| subscription.connection_id != connection_id); + } +} + +async fn handle_connection( + runtime: Arc, + connection_id: u64, + mut reader: OwnedReadHalf, + outbound: mpsc::UnboundedSender, +) -> Result<()> { + loop { + let Some(frame) = read_frame(&mut reader) + .await + .map_err(protocol_error_to_mux)? + else { + debug!(connection_id, "client disconnected"); + runtime.cleanup_connection(connection_id).await; + return Ok(()); + }; + + if frame.frame_type != FrameType::Request { + if outbound + .send(ServerEnvelope::Response(protocol_error_response( + Some(frame.request_id), + ProtocolError::UnexpectedFrameType(frame.frame_type), + ))) + .is_err() + { + return Err(MuxError::transport("connection writer closed")); + } + continue; + } + + let request = match decode_client_message(&frame.payload) { + Ok(request) => { + if request.request_id() != frame.request_id { + if outbound + .send(ServerEnvelope::Response(protocol_error_response( + Some(frame.request_id), + ProtocolError::MismatchedRequestId { + expected: frame.request_id, + actual: request.request_id(), + }, + ))) + .is_err() + { + return Err(MuxError::transport("connection writer closed")); + } + continue; + } + request + } + Err(error) => { + if outbound + .send(ServerEnvelope::Response(protocol_error_response( + Some(frame.request_id), + error, + ))) + .is_err() + { + return Err(MuxError::transport("connection writer closed")); + } + continue; + } + }; + + let span = request_span("handle_request", request.request_id()); + let _entered = span.enter(); + let (response, events) = runtime + .dispatch_request(connection_id, &outbound, request) + .await; + + if outbound.send(ServerEnvelope::Response(response)).is_err() { + return Err(MuxError::transport("connection writer closed")); + } + runtime.broadcast(events).await; + } +} + +async fn write_loop( + mut writer: OwnedWriteHalf, + mut outbound: mpsc::UnboundedReceiver, +) -> Result<()> { + while let Some(envelope) = outbound.recv().await { + let payload = encode_server_envelope(&envelope).map_err(protocol_error_to_mux)?; + let (frame_type, request_id) = match &envelope { + ServerEnvelope::Response(response) => ( + FrameType::Response, + response.request_id().unwrap_or(RequestId(0)), + ), + ServerEnvelope::Event(_) => (FrameType::Event, RequestId(0)), + }; + let frame = RawFrame::new(frame_type, request_id, payload); + write_frame_no_flush(&mut writer, &frame) + .await + .map_err(protocol_error_to_mux)?; + } + + Ok(()) +} + +fn set_socket_permissions(socket_path: &Path) -> Result<()> { + #[cfg(unix)] + fs::set_permissions(socket_path, fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +fn protocol_tab_index(index: u32) -> Result { + usize::try_from(index) + .map_err(|_| MuxError::invalid_input(format!("tab index {index} exceeds platform limits"))) +} + +fn focus_changed_event( + state: &ServerState, + session_id: embers_core::SessionId, +) -> Option { + state + .session(session_id) + .ok() + .map(|session| FocusChangedEvent { + session_id, + focused_leaf_id: session.focused_leaf, + focused_floating_id: session.focused_floating, + }) +} + +fn layout_snapshot_response( + state: &ServerState, + request_id: RequestId, + session_id: embers_core::SessionId, +) -> (ServerResponse, Vec) { + match session_snapshot(state, session_id) { + Ok(snapshot) => { + let mut events = vec![ServerEvent::NodeChanged(NodeChangedEvent { session_id })]; + if let Some(focus_event) = focus_changed_event(state, session_id) { + events.push(ServerEvent::FocusChanged(focus_event)); + } + ( + ServerResponse::SessionSnapshot(SessionSnapshotResponse { + request_id, + snapshot, + }), + events, + ) + } + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + } +} + +fn error_response( + request_id: Option, + code: ErrorCode, + message: impl Into, +) -> ServerResponse { + ServerResponse::Error(ErrorResponse { + request_id, + error: WireError::new(code, message), + }) +} + +fn protocol_error_response(request_id: Option, error: ProtocolError) -> ServerResponse { + error_response(request_id, ErrorCode::ProtocolViolation, error.to_string()) +} + +fn mux_error_response(request_id: Option, error: MuxError) -> ServerResponse { + let (code, message) = match error { + MuxError::Wire(wire) => (wire.code, wire.message), + MuxError::Io(io) => (ErrorCode::Transport, io.to_string()), + MuxError::Protocol(message) => (ErrorCode::ProtocolViolation, message), + MuxError::Transport(message) => (ErrorCode::Transport, message), + MuxError::InvalidInput(message) => (ErrorCode::InvalidRequest, message), + MuxError::NotFound(message) => (ErrorCode::NotFound, message), + MuxError::Conflict(message) => (ErrorCode::Conflict, message), + MuxError::Unsupported(message) => (ErrorCode::Unsupported, message), + MuxError::Timeout(message) => (ErrorCode::Timeout, message), + MuxError::Pty(message) => (ErrorCode::Transport, message), + MuxError::Internal(message) => (ErrorCode::Internal, message), + }; + + error_response(request_id, code, message) +} + +fn protocol_error_to_mux(error: ProtocolError) -> MuxError { + MuxError::protocol(error.to_string()) +} + +fn render_events(buffer_id: BufferId, damage: BackendDamage) -> Vec { + match damage { + BackendDamage::None => Vec::new(), + BackendDamage::Full | BackendDamage::Partial(_) => { + vec![ServerEvent::RenderInvalidated(RenderInvalidatedEvent { + buffer_id, + })] + } + } +} diff --git a/crates/embers-server/src/state.rs b/crates/embers-server/src/state.rs new file mode 100644 index 0000000..96cf73f --- /dev/null +++ b/crates/embers-server/src/state.rs @@ -0,0 +1,1969 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +use embers_core::{ + ActivityState, BufferId, FloatGeometry, FloatingId, IdAllocator, MuxError, NodeId, PtySize, + Result, SessionId, SplitDirection, Timestamp, +}; + +use crate::model::{ + Buffer, BufferAttachment, BufferState, BufferViewNode, BufferViewState, ExitedBuffer, + FloatingWindow, Node, RunningBuffer, Session, SplitNode, TabEntry, TabsNode, +}; + +#[derive(Debug)] +pub struct ServerState { + pub sessions: BTreeMap, + pub buffers: BTreeMap, + pub nodes: BTreeMap, + pub floating: BTreeMap, + session_ids: IdAllocator, + buffer_ids: IdAllocator, + node_ids: IdAllocator, + floating_ids: IdAllocator, +} + +impl Default for ServerState { + fn default() -> Self { + Self::new() + } +} + +impl ServerState { + pub fn new() -> Self { + Self { + sessions: BTreeMap::new(), + buffers: BTreeMap::new(), + nodes: BTreeMap::new(), + floating: BTreeMap::new(), + session_ids: IdAllocator::new(1), + buffer_ids: IdAllocator::new(1), + node_ids: IdAllocator::new(1), + floating_ids: IdAllocator::new(1), + } + } + + pub fn session(&self, session_id: SessionId) -> Result<&Session> { + self.sessions + .get(&session_id) + .ok_or_else(|| MuxError::not_found(format!("unknown session {session_id}"))) + } + + pub fn buffer(&self, buffer_id: BufferId) -> Result<&Buffer> { + self.buffers + .get(&buffer_id) + .ok_or_else(|| MuxError::not_found(format!("unknown buffer {buffer_id}"))) + } + + pub fn node(&self, node_id: NodeId) -> Result<&Node> { + self.nodes + .get(&node_id) + .ok_or_else(|| MuxError::not_found(format!("unknown node {node_id}"))) + } + + pub fn floating_window(&self, floating_id: FloatingId) -> Result<&FloatingWindow> { + self.floating + .get(&floating_id) + .ok_or_else(|| MuxError::not_found(format!("unknown floating window {floating_id}"))) + } + + pub fn root_node(&self, session_id: SessionId) -> Result { + Ok(self.session(session_id)?.root_node) + } + + pub fn root_tabs(&self, session_id: SessionId) -> Result { + let root_node = self.root_node(session_id)?; + if matches!(self.node(root_node)?, Node::Tabs(_)) { + Ok(root_node) + } else { + Err(MuxError::conflict(format!( + "session {session_id} root node {root_node} is not tabs" + ))) + } + } + + fn root_tabs_node(&self, session_id: SessionId) -> Result> { + let root_node = self.root_node(session_id)?; + Ok(matches!(self.node(root_node)?, Node::Tabs(_)).then_some(root_node)) + } + + fn ensure_root_tabs_container(&mut self, session_id: SessionId) -> Result { + if let Some(root_tabs) = self.root_tabs_node(session_id)? { + return Ok(root_tabs); + } + + let root_node = self.root_node(session_id)?; + let title = self.default_tab_title(root_node)?; + self.wrap_node_in_tabs(root_node, title) + } + + pub fn add_root_tab_from_buffer( + &mut self, + session_id: SessionId, + title: impl Into, + buffer_id: BufferId, + ) -> Result { + let root_tabs = self.ensure_root_tabs_container(session_id)?; + let child = self.create_buffer_view(session_id, buffer_id)?; + match self.add_tab_sibling(root_tabs, title, child) { + Ok(index) => Ok(index), + Err(error) => { + self.discard_buffer_view(child); + Err(error) + } + } + } + + pub fn add_root_tab_from_subtree( + &mut self, + session_id: SessionId, + title: impl Into, + child: NodeId, + ) -> Result { + let root_tabs = self.ensure_root_tabs_container(session_id)?; + self.add_tab_sibling(root_tabs, title, child) + } + + pub fn select_root_tab(&mut self, session_id: SessionId, index: usize) -> Result<()> { + if let Some(root_tabs) = self.root_tabs_node(session_id)? { + return self.switch_tab(root_tabs, index); + } + + if index == 0 { + Ok(()) + } else { + Err(MuxError::not_found(format!( + "tab index {index} is out of range for session {session_id}" + ))) + } + } + + pub fn rename_root_tab( + &mut self, + session_id: SessionId, + index: usize, + title: impl Into, + ) -> Result<()> { + let root_tabs = if let Some(root_tabs) = self.root_tabs_node(session_id)? { + root_tabs + } else { + if index != 0 { + return Err(MuxError::not_found(format!( + "tab index {index} is out of range for session {session_id}" + ))); + } + self.ensure_root_tabs_container(session_id)? + }; + self.rename_tab(root_tabs, index, title) + } + + pub fn close_root_tab(&mut self, session_id: SessionId, index: usize) -> Result<()> { + if let Some(root_tabs) = self.root_tabs_node(session_id)? { + return self.close_tab(root_tabs, index); + } + + if index == 0 { + self.clear_session_root(session_id) + } else { + Err(MuxError::not_found(format!( + "tab index {index} is out of range for session {session_id}" + ))) + } + } + + pub fn close_session(&mut self, session_id: SessionId) -> Result<()> { + let session = self.session(session_id)?.clone(); + for floating_id in session.floating.clone() { + self.close_floating(floating_id)?; + } + self.remove_subtree_nodes(session.root_node)?; + self.sessions.remove(&session_id); + Ok(()) + } + + pub fn create_session(&mut self, name: impl Into) -> SessionId { + let session_id = self.session_ids.next(); + let root_node = self.node_ids.next(); + self.nodes.insert( + root_node, + Node::Tabs(TabsNode { + id: root_node, + session_id, + parent: None, + tabs: Vec::new(), + active: 0, + last_focused_descendant: None, + }), + ); + self.sessions.insert( + session_id, + Session { + id: session_id, + name: name.into(), + root_node, + floating: Vec::new(), + focused_leaf: None, + focused_floating: None, + created_at: Timestamp::now(), + }, + ); + session_id + } + + pub fn create_buffer( + &mut self, + title: impl Into, + command: Vec, + cwd: Option, + ) -> BufferId { + self.create_buffer_with_env(title, command, cwd, BTreeMap::new()) + } + + pub fn create_buffer_with_env( + &mut self, + title: impl Into, + command: Vec, + cwd: Option, + env: BTreeMap, + ) -> BufferId { + let buffer_id = self.buffer_ids.next(); + self.buffers.insert( + buffer_id, + Buffer { + id: buffer_id, + title: title.into(), + command, + cwd, + env, + state: BufferState::Created, + attachment: BufferAttachment::Detached, + pty_size: PtySize::new(80, 24), + activity: ActivityState::Idle, + last_snapshot_seq: 0, + created_at: Timestamp::now(), + }, + ); + buffer_id + } + + pub fn remove_buffer(&mut self, buffer_id: BufferId) -> Result { + let buffer = self.buffer(buffer_id)?.clone(); + if !matches!(buffer.attachment, BufferAttachment::Detached) { + return Err(MuxError::conflict(format!( + "buffer {buffer_id} must be detached before removal" + ))); + } + self.buffers + .remove(&buffer_id) + .ok_or_else(|| MuxError::not_found(format!("unknown buffer {buffer_id}"))) + } + + pub fn mark_buffer_running(&mut self, buffer_id: BufferId, pid: Option) -> Result<()> { + let buffer = self.buffer_mut(buffer_id)?; + if matches!(buffer.state, BufferState::Exited(_)) { + return Err(MuxError::conflict(format!( + "buffer {buffer_id} has already exited" + ))); + } + buffer.state = BufferState::Running(RunningBuffer { pid }); + Ok(()) + } + + pub fn mark_buffer_exited( + &mut self, + buffer_id: BufferId, + exit_code: Option, + ) -> Result<()> { + let buffer = self.buffer_mut(buffer_id)?; + buffer.state = BufferState::Exited(ExitedBuffer { + exit_code, + exited_at: Timestamp::now(), + }); + Ok(()) + } + + pub fn set_buffer_size(&mut self, buffer_id: BufferId, size: PtySize) -> Result<()> { + self.buffer_mut(buffer_id)?.pty_size = size; + Ok(()) + } + + pub fn note_buffer_output(&mut self, buffer_id: BufferId) -> Result { + let buffer = self.buffer_mut(buffer_id)?; + buffer.last_snapshot_seq = buffer.last_snapshot_seq.saturating_add(1); + buffer.activity = ActivityState::Activity; + Ok(buffer.last_snapshot_seq) + } + + pub fn set_buffer_title( + &mut self, + buffer_id: BufferId, + title: impl Into, + ) -> Result<()> { + self.buffer_mut(buffer_id)?.title = title.into(); + Ok(()) + } + + pub fn set_buffer_activity( + &mut self, + buffer_id: BufferId, + activity: ActivityState, + ) -> Result<()> { + self.buffer_mut(buffer_id)?.activity = activity; + Ok(()) + } + + pub fn create_buffer_view( + &mut self, + session_id: SessionId, + buffer_id: BufferId, + ) -> Result { + self.ensure_session_exists(session_id)?; + self.buffer(buffer_id)?; + + let node_id = self.node_ids.next(); + self.nodes.insert( + node_id, + Node::BufferView(BufferViewNode { + id: node_id, + session_id, + parent: None, + buffer_id, + view: BufferViewState::default(), + }), + ); + if let Err(error) = self.attach_buffer(buffer_id, node_id) { + self.nodes.remove(&node_id); + return Err(error); + } + Ok(node_id) + } + + pub fn create_split_node( + &mut self, + session_id: SessionId, + direction: SplitDirection, + children: Vec, + ) -> Result { + self.ensure_session_exists(session_id)?; + if children.len() < 2 { + return Err(MuxError::invalid_input( + "split nodes require at least two children", + )); + } + + let mut seen_children = BTreeSet::new(); + let node_id = self.node_ids.next(); + for child in &children { + self.ensure_node_belongs_to(*child, session_id)?; + if !seen_children.insert(*child) { + return Err(MuxError::invalid_input(format!( + "split node {node_id} reuses child {child}" + ))); + } + if self.node_parent(*child)?.is_some() { + return Err(MuxError::invalid_input(format!( + "split child {child} already has a parent" + ))); + } + } + for child in &children { + self.set_parent(*child, Some(node_id))?; + } + + self.nodes.insert( + node_id, + Node::Split(SplitNode { + id: node_id, + session_id, + parent: None, + direction, + sizes: vec![1; children.len()], + children, + last_focused_descendant: None, + }), + ); + Ok(node_id) + } + + pub fn create_tabs_node( + &mut self, + session_id: SessionId, + tabs: Vec, + active: usize, + ) -> Result { + self.ensure_session_exists(session_id)?; + + let mut seen_children = BTreeSet::new(); + let node_id = self.node_ids.next(); + for tab in &tabs { + self.ensure_node_belongs_to(tab.child, session_id)?; + if !seen_children.insert(tab.child) { + return Err(MuxError::invalid_input(format!( + "tabs node {node_id} reuses child {}", + tab.child + ))); + } + if self.node_parent(tab.child)?.is_some() { + return Err(MuxError::invalid_input(format!( + "tabs child {} already has a parent", + tab.child + ))); + } + } + for tab in &tabs { + self.set_parent(tab.child, Some(node_id))?; + } + + self.nodes.insert( + node_id, + Node::Tabs(TabsNode { + id: node_id, + session_id, + parent: None, + tabs, + active: active.min(active.saturating_sub(0)), + last_focused_descendant: None, + }), + ); + + if matches!(self.node(node_id)?, Node::Tabs(tabs) if tabs.tabs.is_empty()) { + if let Node::Tabs(tabs) = self.node_mut(node_id)? { + tabs.active = 0; + } + } else if let Node::Tabs(tabs) = self.node_mut(node_id)? { + tabs.active = active.min(tabs.tabs.len().saturating_sub(1)); + } + + Ok(node_id) + } + + pub fn create_floating_window( + &mut self, + session_id: SessionId, + root_node: NodeId, + geometry: FloatGeometry, + title: Option, + ) -> Result { + self.create_floating_window_with_options(session_id, root_node, geometry, title, true, true) + } + + pub fn create_floating_window_with_options( + &mut self, + session_id: SessionId, + root_node: NodeId, + geometry: FloatGeometry, + title: Option, + focus: bool, + close_on_empty: bool, + ) -> Result { + self.ensure_session_exists(session_id)?; + self.ensure_node_belongs_to(root_node, session_id)?; + if self.node_parent(root_node)?.is_some() { + return Err(MuxError::invalid_input( + "floating roots must not already have a parent", + )); + } + if self.is_session_root(root_node) { + return Err(MuxError::invalid_input( + "session root cannot also become a floating root", + )); + } + if self.floating_id_by_root(root_node).is_some() { + return Err(MuxError::invalid_input( + "node is already a floating root".to_owned(), + )); + } + + let floating_id = self.floating_ids.next(); + self.floating.insert( + floating_id, + FloatingWindow { + id: floating_id, + session_id, + root_node, + title, + geometry, + focused: false, + visible: true, + close_on_empty, + last_focused_leaf: None, + }, + ); + self.session_mut(session_id)?.floating.push(floating_id); + if focus { + self.focus_floating(floating_id)?; + } + Ok(floating_id) + } + + pub fn create_floating_from_buffer( + &mut self, + session_id: SessionId, + buffer_id: BufferId, + geometry: FloatGeometry, + title: Option, + ) -> Result { + self.create_floating_from_buffer_with_options( + session_id, buffer_id, geometry, title, true, true, + ) + } + + pub fn create_floating_from_buffer_with_options( + &mut self, + session_id: SessionId, + buffer_id: BufferId, + geometry: FloatGeometry, + title: Option, + focus: bool, + close_on_empty: bool, + ) -> Result { + let root_node = self.create_buffer_view(session_id, buffer_id)?; + self.create_floating_window_with_options( + session_id, + root_node, + geometry, + title, + focus, + close_on_empty, + ) + } + + pub fn close_floating(&mut self, floating_id: FloatingId) -> Result<()> { + let floating = self.remove_floating_window(floating_id)?; + let session_id = floating.session_id; + self.remove_subtree_nodes(floating.root_node)?; + self.heal_focus(session_id) + } + + pub fn focus_floating(&mut self, floating_id: FloatingId) -> Result<()> { + let floating = self.floating_window(floating_id)?.clone(); + if let Some(leaf) = self.resolve_floating_focus(floating_id)? { + self.focus_leaf(floating.session_id, leaf) + } else { + Err(MuxError::not_found(format!( + "floating window {floating_id} has no focusable leaf" + ))) + } + } + + pub fn move_floating( + &mut self, + floating_id: FloatingId, + geometry: FloatGeometry, + ) -> Result<()> { + self.floating_mut(floating_id)?.geometry = geometry; + Ok(()) + } + + pub fn add_root_tab( + &mut self, + session_id: SessionId, + title: impl Into, + child: NodeId, + ) -> Result { + let root_tabs = self.ensure_root_tabs_container(session_id)?; + self.add_tab_sibling(root_tabs, title, child) + } + + pub fn add_tab_sibling( + &mut self, + tabs_id: NodeId, + title: impl Into, + child: NodeId, + ) -> Result { + let append_index = match self.node(tabs_id)? { + Node::Tabs(tabs) => tabs.tabs.len(), + _ => return Err(MuxError::invalid_input("node is not a tabs container")), + }; + self.add_tab_sibling_at(tabs_id, append_index, title, child) + } + + pub fn add_tab_sibling_at( + &mut self, + tabs_id: NodeId, + index: usize, + title: impl Into, + child: NodeId, + ) -> Result { + let session_id = self.node_session_id(tabs_id)?; + self.ensure_node_belongs_to(child, session_id)?; + if child == tabs_id { + return Err(MuxError::invalid_input( + "tabs container cannot contain itself".to_owned(), + )); + } + if !matches!(self.node(tabs_id)?, Node::Tabs(_)) { + return Err(MuxError::invalid_input("node is not a tabs container")); + } + if self.node_parent(child)?.is_some() { + return Err(MuxError::invalid_input( + "new tab child must not already have a parent", + )); + } + if self.is_session_root(child) { + return Err(MuxError::conflict( + "session root cannot become a tab child".to_owned(), + )); + } + if self.floating_id_by_root(child).is_some() { + return Err(MuxError::conflict( + "floating root cannot become a tab child".to_owned(), + )); + } + + let tab_len = match self.node(tabs_id)? { + Node::Tabs(tabs) => tabs.tabs.len(), + _ => return Err(MuxError::invalid_input("node is not a tabs container")), + }; + if index > tab_len { + return Err(MuxError::not_found(format!( + "tab insertion index {index} is out of range for node {tabs_id}" + ))); + } + + self.set_parent(child, Some(tabs_id))?; + let index = { + let tabs = match self.node_mut(tabs_id)? { + Node::Tabs(tabs) => tabs, + _ => return Err(MuxError::invalid_input("node is not a tabs container")), + }; + tabs.tabs.insert(index, TabEntry::new(title, child)); + tabs.active = index; + index + }; + + if let Some(leaf) = self.resolve_focus_candidate(child)? { + self.focus_leaf(session_id, leaf)?; + } else { + self.heal_focus(session_id)?; + } + + Ok(index) + } + + pub fn add_tab_from_buffer( + &mut self, + tabs_id: NodeId, + title: impl Into, + buffer_id: BufferId, + ) -> Result { + let append_index = match self.node(tabs_id)? { + Node::Tabs(tabs) => tabs.tabs.len(), + _ => return Err(MuxError::invalid_input("node is not a tabs container")), + }; + self.add_tab_from_buffer_at(tabs_id, append_index, title, buffer_id) + } + + pub fn add_tab_from_buffer_at( + &mut self, + tabs_id: NodeId, + index: usize, + title: impl Into, + buffer_id: BufferId, + ) -> Result { + if !matches!(self.node(tabs_id)?, Node::Tabs(_)) { + return Err(MuxError::invalid_input("node is not a tabs container")); + } + let session_id = self.node_session_id(tabs_id)?; + let child = self.create_buffer_view(session_id, buffer_id)?; + match self.add_tab_sibling_at(tabs_id, index, title, child) { + Ok(index) => Ok(index), + Err(error) => { + self.discard_buffer_view(child); + Err(error) + } + } + } + + pub fn rename_tab( + &mut self, + tabs_id: NodeId, + index: usize, + title: impl Into, + ) -> Result<()> { + let title = title.into(); + let tabs = match self.node_mut(tabs_id)? { + Node::Tabs(tabs) => tabs, + _ => return Err(MuxError::invalid_input("node is not a tabs container")), + }; + if index >= tabs.tabs.len() { + return Err(MuxError::not_found(format!( + "tab index {index} is out of range for node {tabs_id}" + ))); + } + tabs.tabs[index].title = title; + Ok(()) + } + + pub fn wrap_node_in_tabs( + &mut self, + node_id: NodeId, + title: impl Into, + ) -> Result { + let session_id = self.node_session_id(node_id)?; + let old_parent = self.node_parent(node_id)?; + let tabs_id = self.node_ids.next(); + + self.nodes.insert( + tabs_id, + Node::Tabs(TabsNode { + id: tabs_id, + session_id, + parent: old_parent, + tabs: vec![TabEntry::new(title, node_id)], + active: 0, + last_focused_descendant: self.node(node_id)?.last_focused_descendant(), + }), + ); + self.set_parent(node_id, Some(tabs_id))?; + self.repoint_owner_reference(session_id, old_parent, node_id, tabs_id)?; + + Ok(tabs_id) + } + + pub fn wrap_node_in_split( + &mut self, + node_id: NodeId, + direction: SplitDirection, + sibling: NodeId, + insert_before: bool, + ) -> Result { + let session_id = self.node_session_id(node_id)?; + self.ensure_node_belongs_to(sibling, session_id)?; + if node_id == sibling { + return Err(MuxError::invalid_input( + "split sibling cannot be the same node".to_owned(), + )); + } + if self.node_parent(sibling)?.is_some() { + return Err(MuxError::invalid_input( + "split sibling must not already have a parent".to_owned(), + )); + } + if self.is_session_root(sibling) { + return Err(MuxError::conflict( + "session root cannot become a split child".to_owned(), + )); + } + if self.floating_id_by_root(sibling).is_some() { + return Err(MuxError::conflict( + "floating root cannot become a split child".to_owned(), + )); + } + + let old_parent = self.node_parent(node_id)?; + let split_id = self.node_ids.next(); + let children = if insert_before { + vec![sibling, node_id] + } else { + vec![node_id, sibling] + }; + self.nodes.insert( + split_id, + Node::Split(SplitNode { + id: split_id, + session_id, + parent: old_parent, + direction, + children: children.clone(), + sizes: vec![1; children.len()], + last_focused_descendant: self.resolve_focus_candidate(sibling)?, + }), + ); + self.set_parent(node_id, Some(split_id))?; + self.set_parent(sibling, Some(split_id))?; + self.repoint_owner_reference(session_id, old_parent, node_id, split_id)?; + if let Some(leaf) = self.resolve_focus_candidate(sibling)? { + self.focus_leaf(session_id, leaf)?; + } else { + self.heal_focus(session_id)?; + } + Ok(split_id) + } + + pub fn split_leaf_with_new_buffer( + &mut self, + leaf_id: NodeId, + direction: SplitDirection, + new_buffer: BufferId, + ) -> Result { + self.ensure_leaf(leaf_id)?; + let session_id = self.node_session_id(leaf_id)?; + let old_parent = self.node_parent(leaf_id)?; + let new_leaf = self.create_buffer_view(session_id, new_buffer)?; + let split_id = self.node_ids.next(); + + self.nodes.insert( + split_id, + Node::Split(SplitNode { + id: split_id, + session_id, + parent: old_parent, + direction, + children: vec![leaf_id, new_leaf], + sizes: vec![1, 1], + last_focused_descendant: Some(new_leaf), + }), + ); + self.set_parent(leaf_id, Some(split_id))?; + self.set_parent(new_leaf, Some(split_id))?; + self.repoint_owner_reference(session_id, old_parent, leaf_id, split_id)?; + self.focus_leaf(session_id, new_leaf)?; + + Ok(split_id) + } + + pub fn resize_split_children(&mut self, split_id: NodeId, sizes: Vec) -> Result<()> { + let split = match self.node_mut(split_id)? { + Node::Split(split) => split, + _ => return Err(MuxError::invalid_input("node is not a split")), + }; + if sizes.len() != split.children.len() { + return Err(MuxError::invalid_input(format!( + "split {split_id} expected {} sizes but received {}", + split.children.len(), + sizes.len() + ))); + } + if sizes.contains(&0) { + return Err(MuxError::invalid_input( + "split sizes must be greater than zero", + )); + } + split.sizes = sizes; + Ok(()) + } + + pub fn node_parent(&self, node_id: NodeId) -> Result> { + Ok(self.node(node_id)?.parent()) + } + + pub fn set_parent(&mut self, node_id: NodeId, parent: Option) -> Result<()> { + self.node_mut(node_id)?.set_parent(parent); + Ok(()) + } + + pub fn replace_child( + &mut self, + parent_id: NodeId, + old_child: NodeId, + new_child: NodeId, + ) -> Result<()> { + let session_id = self.node_session_id(parent_id)?; + self.ensure_node_belongs_to(old_child, session_id)?; + self.ensure_node_belongs_to(new_child, session_id)?; + + let replaced = match self.node_mut(parent_id)? { + Node::Split(split) => { + if let Some(index) = split.children.iter().position(|child| *child == old_child) { + split.children[index] = new_child; + true + } else { + false + } + } + Node::Tabs(tabs) => { + if let Some(tab) = tabs.tabs.iter_mut().find(|tab| tab.child == old_child) { + tab.child = new_child; + true + } else { + false + } + } + Node::BufferView(_) => { + return Err(MuxError::invalid_input( + "buffer views cannot replace child references", + )); + } + }; + + if !replaced { + return Err(MuxError::not_found(format!( + "node {old_child} is not a child of parent {parent_id}" + ))); + } + + self.set_parent(old_child, None)?; + self.set_parent(new_child, Some(parent_id))?; + Ok(()) + } + + pub fn remove_child(&mut self, parent_id: NodeId, child_id: NodeId) -> Result<()> { + let removed = match self.node_mut(parent_id)? { + Node::Split(split) => { + if let Some(index) = split.children.iter().position(|child| *child == child_id) { + split.children.remove(index); + if index < split.sizes.len() { + split.sizes.remove(index); + } + true + } else { + false + } + } + Node::Tabs(tabs) => { + if let Some(index) = tabs.tabs.iter().position(|tab| tab.child == child_id) { + tabs.tabs.remove(index); + if tabs.tabs.is_empty() { + tabs.active = 0; + } else if tabs.active > index { + tabs.active -= 1; + } else if tabs.active >= tabs.tabs.len() { + tabs.active = tabs.tabs.len() - 1; + } + true + } else { + false + } + } + Node::BufferView(_) => { + return Err(MuxError::invalid_input( + "buffer views cannot remove child references", + )); + } + }; + + if !removed { + return Err(MuxError::not_found(format!( + "node {child_id} is not a child of parent {parent_id}" + ))); + } + + self.set_parent(child_id, None)?; + Ok(()) + } + + pub fn resolve_first_leaf(&self, node_id: NodeId) -> Result> { + match self.node(node_id)? { + Node::BufferView(_) => Ok(Some(node_id)), + Node::Split(split) => { + for child in &split.children { + if let Some(leaf) = self.resolve_first_leaf(*child)? { + return Ok(Some(leaf)); + } + } + Ok(None) + } + Node::Tabs(tabs) => { + for tab in &tabs.tabs { + if let Some(leaf) = self.resolve_first_leaf(tab.child)? { + return Ok(Some(leaf)); + } + } + Ok(None) + } + } + } + + pub fn resolve_visible_leaf(&self, node_id: NodeId) -> Result> { + match self.node(node_id)? { + Node::BufferView(_) => Ok(Some(node_id)), + Node::Split(split) => { + for child in &split.children { + if let Some(leaf) = self.resolve_visible_leaf(*child)? { + return Ok(Some(leaf)); + } + } + Ok(None) + } + Node::Tabs(tabs) => { + let active_child = tabs + .tabs + .get(tabs.active) + .or_else(|| tabs.tabs.first()) + .map(|tab| tab.child); + if let Some(child) = active_child { + self.resolve_visible_leaf(child) + } else { + Ok(None) + } + } + } + } + + pub fn visible_leaf_ids(&self, node_id: NodeId) -> Result> { + let mut leaves = Vec::new(); + self.collect_visible_leaf_ids(node_id, &mut leaves)?; + Ok(leaves) + } + + pub fn visible_session_leaves(&self, session_id: SessionId) -> Result> { + self.visible_leaf_ids(self.root_node(session_id)?) + } + + pub fn find_last_focused_descendant(&self, node_id: NodeId) -> Result> { + Ok(self.node(node_id)?.last_focused_descendant()) + } + + pub fn session_node_ids(&self, session_id: SessionId) -> Result> { + let session = self.session(session_id)?; + let mut seen = BTreeSet::new(); + self.collect_subtree_nodes(session.root_node, &mut seen)?; + for floating_id in &session.floating { + let floating = self.floating_window(*floating_id)?; + self.collect_subtree_nodes(floating.root_node, &mut seen)?; + } + Ok(seen.into_iter().collect()) + } + + pub fn session_buffer_ids(&self, session_id: SessionId) -> Result> { + let mut buffers = BTreeSet::new(); + for node_id in self.session_node_ids(session_id)? { + if let Node::BufferView(leaf) = self.node(node_id)? { + buffers.insert(leaf.buffer_id); + } + } + Ok(buffers.into_iter().collect()) + } + + pub fn attach_buffer(&mut self, buffer_id: BufferId, node_id: NodeId) -> Result<()> { + self.buffer(buffer_id)?; + let current_attachment = self.buffer(buffer_id)?.attachment.clone(); + if let BufferAttachment::Attached(existing_view) = current_attachment + && existing_view != node_id + { + return Err(MuxError::conflict(format!( + "buffer {buffer_id} is already attached to view {existing_view}" + ))); + } + + let current_buffer = self.buffer_view_buffer_id(node_id)?; + if current_buffer != buffer_id { + if let Some(previous_buffer) = self.buffers.get_mut(¤t_buffer) + && matches!(previous_buffer.attachment, BufferAttachment::Attached(attached) if attached == node_id) + { + previous_buffer.attachment = BufferAttachment::Detached; + } + match self.node_mut(node_id)? { + Node::BufferView(leaf) => leaf.buffer_id = buffer_id, + _ => return Err(MuxError::invalid_input("node is not a buffer view")), + } + } + + self.buffer_mut(buffer_id)?.attachment = BufferAttachment::Attached(node_id); + Ok(()) + } + + pub fn move_buffer_to_leaf(&mut self, buffer_id: BufferId, target_leaf: NodeId) -> Result<()> { + self.ensure_leaf(target_leaf)?; + let target_session = self.node_session_id(target_leaf)?; + let source_view = match self.buffer(buffer_id)?.attachment { + BufferAttachment::Attached(node_id) => Some(node_id), + BufferAttachment::Detached => None, + }; + + if source_view == Some(target_leaf) { + return self.focus_leaf(target_session, target_leaf); + } + + if let Some(source_view) = source_view { + let source_session = self.node_session_id(source_view)?; + if source_session != target_session { + return Err(MuxError::conflict( + "attached buffers must be detached before moving across sessions".to_owned(), + )); + } + self.close_node(source_view)?; + } + + self.attach_buffer(buffer_id, target_leaf)?; + self.focus_leaf(target_session, target_leaf) + } + + pub fn detach_buffer(&mut self, buffer_id: BufferId) -> Result<()> { + match self.buffer(buffer_id)?.attachment { + BufferAttachment::Attached(node_id) => self.close_node(node_id), + BufferAttachment::Detached => Ok(()), + } + } + + pub fn focus_leaf(&mut self, session_id: SessionId, leaf_id: NodeId) -> Result<()> { + self.ensure_leaf_belongs_to(leaf_id, session_id)?; + self.ensure_leaf_is_focusable(session_id, leaf_id)?; + self.clear_session_focus(session_id)?; + self.set_leaf_focus(leaf_id, true)?; + + let floating_owner = self.floating_id_for_node(leaf_id)?; + { + let session = self.session_mut(session_id)?; + session.focused_leaf = Some(leaf_id); + session.focused_floating = floating_owner; + } + + let floating_ids = self.session(session_id)?.floating.clone(); + for floating_id in floating_ids { + if let Some(floating) = self.floating.get_mut(&floating_id) { + floating.focused = Some(floating_id) == floating_owner; + if floating.focused { + floating.last_focused_leaf = Some(leaf_id); + } + } + } + + let mut child = leaf_id; + while let Some(parent) = self.node_parent(child)? { + match self.node_mut(parent)? { + Node::Split(split) => { + split.last_focused_descendant = Some(leaf_id); + } + Node::Tabs(tabs) => { + tabs.last_focused_descendant = Some(leaf_id); + if let Some(index) = tabs.tabs.iter().position(|tab| tab.child == child) { + tabs.active = index; + } + } + Node::BufferView(_) => {} + } + child = parent; + } + + Ok(()) + } + + pub fn switch_tab(&mut self, tabs_id: NodeId, index: usize) -> Result<()> { + let session_id = self.node_session_id(tabs_id)?; + let child = { + let tabs = match self.node_mut(tabs_id)? { + Node::Tabs(tabs) => tabs, + _ => return Err(MuxError::invalid_input("node is not a tabs container")), + }; + if index >= tabs.tabs.len() { + return Err(MuxError::not_found(format!( + "tab index {index} is out of range for node {tabs_id}" + ))); + } + tabs.active = index; + tabs.tabs[index].child + }; + + if self.is_node_visible_in_session(session_id, tabs_id)? { + if let Some(leaf) = self.resolve_focus_candidate(child)? { + self.focus_leaf(session_id, leaf)?; + } else { + self.heal_focus(session_id)?; + } + } + + Ok(()) + } + + pub fn close_tab(&mut self, tabs_id: NodeId, index: usize) -> Result<()> { + let session_id = self.node_session_id(tabs_id)?; + let child = { + let tabs = match self.node_mut(tabs_id)? { + Node::Tabs(tabs) => tabs, + _ => return Err(MuxError::invalid_input("node is not a tabs container")), + }; + if index >= tabs.tabs.len() { + return Err(MuxError::not_found(format!( + "tab index {index} is out of range for node {tabs_id}" + ))); + } + let child = tabs.tabs[index].child; + tabs.tabs.remove(index); + if tabs.tabs.is_empty() { + tabs.active = 0; + tabs.last_focused_descendant = None; + } else if tabs.active > index { + tabs.active -= 1; + } else if tabs.active >= tabs.tabs.len() { + tabs.active = tabs.tabs.len() - 1; + } + child + }; + + self.set_parent(child, None)?; + self.remove_subtree_nodes(child)?; + self.normalize_upwards(tabs_id)?; + self.heal_focus(session_id) + } + + pub fn close_node(&mut self, node_id: NodeId) -> Result<()> { + let session_id = self.node_session_id(node_id)?; + if self.is_session_root(node_id) { + return self.clear_session_root(session_id); + } + + if let Some(parent) = self.node_parent(node_id)? { + self.remove_child(parent, node_id)?; + self.remove_subtree_nodes(node_id)?; + self.normalize_upwards(parent)?; + } else if let Some(floating_id) = self.floating_id_by_root(node_id) { + let floating = self.remove_floating_window(floating_id)?; + self.remove_subtree_nodes(floating.root_node)?; + } else { + return Err(MuxError::invalid_input(format!( + "node {node_id} has no owning container" + ))); + } + + self.heal_focus(session_id) + } + + pub fn replace_node(&mut self, node_id: NodeId, replacement: NodeId) -> Result<()> { + let session_id = self.node_session_id(node_id)?; + self.ensure_node_belongs_to(replacement, session_id)?; + if node_id == replacement { + return Ok(()); + } + if self.node_parent(replacement)?.is_some() { + return Err(MuxError::invalid_input( + "replacement node must not already have a parent".to_owned(), + )); + } + if self.is_session_root(replacement) { + return Err(MuxError::conflict( + "session root cannot become a replacement child".to_owned(), + )); + } + if self.floating_id_by_root(replacement).is_some() { + return Err(MuxError::conflict( + "floating root cannot become a replacement child".to_owned(), + )); + } + + self.replace_node_in_owner(node_id, replacement)?; + self.remove_subtree_nodes(node_id)?; + if let Some(leaf) = self.resolve_focus_candidate(replacement)? { + self.focus_leaf(session_id, leaf)?; + } else { + self.heal_focus(session_id)?; + } + Ok(()) + } + + pub fn normalize_upwards(&mut self, start: NodeId) -> Result<()> { + let mut current = Some(start); + while let Some(node_id) = current { + if !self.nodes.contains_key(&node_id) { + break; + } + + current = match self.node(node_id)? { + Node::BufferView(_) => self.node_parent(node_id)?, + Node::Split(_) => self.normalize_split_node(node_id)?, + Node::Tabs(_) => self.normalize_tabs_node(node_id)?, + }; + } + Ok(()) + } + + pub fn validate(&self) -> Result<()> { + let mut seen = BTreeSet::new(); + + for session in self.sessions.values() { + let root = self.node(session.root_node)?; + if root.parent().is_some() { + return Err(MuxError::conflict(format!( + "session {} root node {} must not have a parent", + session.id, session.root_node + ))); + } + + self.validate_subtree(session.id, session.root_node, None, true, &mut seen)?; + + for floating_id in &session.floating { + let floating = self.floating_window(*floating_id)?; + if floating.session_id != session.id { + return Err(MuxError::conflict(format!( + "floating window {floating_id} belongs to the wrong session" + ))); + } + if floating.root_node == session.root_node { + return Err(MuxError::conflict(format!( + "floating window {floating_id} reuses the session root" + ))); + } + if self.node_parent(floating.root_node)?.is_some() { + return Err(MuxError::conflict(format!( + "floating window {floating_id} root {} must not have a parent", + floating.root_node + ))); + } + self.validate_subtree(session.id, floating.root_node, None, false, &mut seen)?; + } + + if let Some(focused_leaf) = session.focused_leaf { + if !matches!(self.node(focused_leaf)?, Node::BufferView(_)) { + return Err(MuxError::conflict(format!( + "focused leaf {focused_leaf} is not a buffer view" + ))); + } + if !self.is_node_visible_in_session(session.id, focused_leaf)? { + return Err(MuxError::conflict(format!( + "focused leaf {focused_leaf} is not visible in session {}", + session.id + ))); + } + } + } + + if seen.len() != self.nodes.len() { + return Err(MuxError::conflict(format!( + "orphaned node(s) detected: visited {} of {} node(s)", + seen.len(), + self.nodes.len() + ))); + } + + for (buffer_id, buffer) in &self.buffers { + if let BufferAttachment::Attached(node_id) = buffer.attachment { + match self.node(node_id)? { + Node::BufferView(leaf) if leaf.buffer_id == *buffer_id => {} + _ => { + return Err(MuxError::conflict(format!( + "buffer {buffer_id} attachment does not match view {node_id}" + ))); + } + } + } + } + + for node in self.nodes.values() { + if let Node::BufferView(leaf) = node { + match self.buffer(leaf.buffer_id)?.attachment { + BufferAttachment::Attached(attached) if attached == leaf.id => {} + _ => { + return Err(MuxError::conflict(format!( + "buffer view {} points at detached buffer {}", + leaf.id, leaf.buffer_id + ))); + } + } + } + } + + Ok(()) + } + + fn clear_session_root(&mut self, session_id: SessionId) -> Result<()> { + let old_root = self.root_node(session_id)?; + self.remove_subtree_nodes(old_root)?; + let new_root = self.node_ids.next(); + self.nodes.insert( + new_root, + Node::Tabs(TabsNode { + id: new_root, + session_id, + parent: None, + tabs: Vec::new(), + active: 0, + last_focused_descendant: None, + }), + ); + self.session_mut(session_id)?.root_node = new_root; + self.heal_focus(session_id) + } + + fn heal_focus(&mut self, session_id: SessionId) -> Result<()> { + let preferred_floating = self + .session(session_id)? + .focused_floating + .filter(|floating_id| self.floating.contains_key(floating_id)); + + if let Some(floating_id) = preferred_floating + && let Some(leaf) = self.resolve_floating_focus(floating_id)? + { + return self.focus_leaf(session_id, leaf); + } + + let root = self.root_node(session_id)?; + if let Some(leaf) = self.resolve_focus_candidate(root)? { + return self.focus_leaf(session_id, leaf); + } + + let floating_ids = self.session(session_id)?.floating.clone(); + for floating_id in floating_ids { + if let Some(leaf) = self.resolve_floating_focus(floating_id)? { + return self.focus_leaf(session_id, leaf); + } + } + + self.clear_session_focus(session_id) + } + + fn clear_session_focus(&mut self, session_id: SessionId) -> Result<()> { + let previous_leaf = self.session(session_id)?.focused_leaf; + if let Some(previous_leaf) = previous_leaf { + let _ = self.set_leaf_focus(previous_leaf, false); + } + + let floating_ids = self.session(session_id)?.floating.clone(); + for floating_id in floating_ids { + if let Some(floating) = self.floating.get_mut(&floating_id) { + floating.focused = false; + } + } + + let session = self.session_mut(session_id)?; + session.focused_leaf = None; + session.focused_floating = None; + Ok(()) + } + + fn resolve_floating_focus(&self, floating_id: FloatingId) -> Result> { + let floating = self.floating_window(floating_id)?; + if !floating.visible { + return Ok(None); + } + + if let Some(last_leaf) = floating.last_focused_leaf + && self.nodes.contains_key(&last_leaf) + && self.top_root_for_node(last_leaf)? == floating.root_node + && self.is_node_visible_from(floating.root_node, last_leaf)? + { + return Ok(Some(last_leaf)); + } + + self.resolve_focus_candidate(floating.root_node) + } + + fn resolve_focus_candidate(&self, node_id: NodeId) -> Result> { + match self.node(node_id)? { + Node::BufferView(_) => Ok(Some(node_id)), + Node::Split(split) => { + if let Some(last_leaf) = split.last_focused_descendant + && self.nodes.contains_key(&last_leaf) + && self.is_node_visible_from(node_id, last_leaf)? + { + return Ok(Some(last_leaf)); + } + for child in &split.children { + if let Some(leaf) = self.resolve_focus_candidate(*child)? { + return Ok(Some(leaf)); + } + } + Ok(None) + } + Node::Tabs(tabs) => { + let active_child = tabs + .tabs + .get(tabs.active) + .or_else(|| tabs.tabs.first()) + .map(|tab| tab.child); + if let Some(child) = active_child { + self.resolve_focus_candidate(child) + } else { + Ok(None) + } + } + } + } + + fn default_tab_title(&self, node_id: NodeId) -> Result { + if let Some(leaf_id) = self.resolve_focus_candidate(node_id)? { + let buffer_id = self.buffer_view_buffer_id(leaf_id)?; + return Ok(self.buffer(buffer_id)?.title.clone()); + } + + Ok("window".to_owned()) + } + + fn set_leaf_focus(&mut self, leaf_id: NodeId, focused: bool) -> Result<()> { + match self.node_mut(leaf_id)? { + Node::BufferView(leaf) => { + leaf.view.focused = focused; + Ok(()) + } + _ => Err(MuxError::invalid_input(format!( + "node {leaf_id} is not a buffer view" + ))), + } + } + + fn buffer_view_buffer_id(&self, node_id: NodeId) -> Result { + match self.node(node_id)? { + Node::BufferView(leaf) => Ok(leaf.buffer_id), + _ => Err(MuxError::invalid_input(format!( + "node {node_id} is not a buffer view" + ))), + } + } + + fn node_session_id(&self, node_id: NodeId) -> Result { + Ok(self.node(node_id)?.session_id()) + } + + fn ensure_session_exists(&self, session_id: SessionId) -> Result<()> { + let _ = self.session(session_id)?; + Ok(()) + } + + fn ensure_node_belongs_to(&self, node_id: NodeId, session_id: SessionId) -> Result<()> { + let node = self.node(node_id)?; + if node.session_id() != session_id { + return Err(MuxError::conflict(format!( + "node {node_id} belongs to session {}, not {}", + node.session_id(), + session_id + ))); + } + Ok(()) + } + + fn ensure_leaf(&self, node_id: NodeId) -> Result<()> { + if matches!(self.node(node_id)?, Node::BufferView(_)) { + Ok(()) + } else { + Err(MuxError::invalid_input(format!( + "node {node_id} is not a buffer view" + ))) + } + } + + fn ensure_leaf_belongs_to(&self, node_id: NodeId, session_id: SessionId) -> Result<()> { + self.ensure_node_belongs_to(node_id, session_id)?; + self.ensure_leaf(node_id) + } + + fn is_session_root(&self, node_id: NodeId) -> bool { + self.sessions + .values() + .any(|session| session.root_node == node_id) + } + + fn floating_id_by_root(&self, root_node: NodeId) -> Option { + self.floating + .values() + .find(|floating| floating.root_node == root_node) + .map(|floating| floating.id) + } + + fn floating_id_for_node(&self, node_id: NodeId) -> Result> { + let root = self.top_root_for_node(node_id)?; + Ok(self.floating_id_by_root(root)) + } + + fn top_root_for_node(&self, node_id: NodeId) -> Result { + let mut current = node_id; + while let Some(parent) = self.node_parent(current)? { + current = parent; + } + Ok(current) + } + + fn is_node_visible_from(&self, root_id: NodeId, node_id: NodeId) -> Result { + if !self.subtree_contains(root_id, node_id)? { + return Ok(false); + } + + let mut current = node_id; + while current != root_id { + let parent = self.node_parent(current)?.ok_or_else(|| { + MuxError::conflict(format!( + "node {node_id} is not rooted at expected root {root_id}" + )) + })?; + if let Node::Tabs(tabs) = self.node(parent)? { + let active_child = tabs.tabs.get(tabs.active).map(|tab| tab.child); + if active_child != Some(current) { + return Ok(false); + } + } + current = parent; + } + + Ok(true) + } + + fn is_node_visible_in_session(&self, session_id: SessionId, node_id: NodeId) -> Result { + self.ensure_node_belongs_to(node_id, session_id)?; + let root = self.top_root_for_node(node_id)?; + if root == self.session(session_id)?.root_node { + return self.is_node_visible_from(root, node_id); + } + if let Some(floating_id) = self.floating_id_by_root(root) { + let floating = self.floating_window(floating_id)?; + return Ok(floating.visible && self.is_node_visible_from(root, node_id)?); + } + Ok(false) + } + + fn subtree_contains(&self, root_id: NodeId, needle: NodeId) -> Result { + if root_id == needle { + return Ok(true); + } + + for child in self.node(root_id)?.child_ids() { + if self.subtree_contains(child, needle)? { + return Ok(true); + } + } + + Ok(false) + } + + fn collect_visible_leaf_ids(&self, node_id: NodeId, leaves: &mut Vec) -> Result<()> { + match self.node(node_id)? { + Node::BufferView(_) => leaves.push(node_id), + Node::Split(split) => { + for child in &split.children { + self.collect_visible_leaf_ids(*child, leaves)?; + } + } + Node::Tabs(tabs) => { + if let Some(child) = tabs + .tabs + .get(tabs.active) + .or_else(|| tabs.tabs.first()) + .map(|tab| tab.child) + { + self.collect_visible_leaf_ids(child, leaves)?; + } + } + } + Ok(()) + } + + fn collect_subtree_nodes(&self, root_id: NodeId, seen: &mut BTreeSet) -> Result<()> { + if !seen.insert(root_id) { + return Ok(()); + } + + for child in self.node(root_id)?.child_ids() { + self.collect_subtree_nodes(child, seen)?; + } + + Ok(()) + } + + fn repoint_owner_reference( + &mut self, + session_id: SessionId, + owner: Option, + old_node: NodeId, + new_node: NodeId, + ) -> Result<()> { + if let Some(parent_id) = owner { + match self.node_mut(parent_id)? { + Node::Split(split) => { + let index = split + .children + .iter() + .position(|child| *child == old_node) + .ok_or_else(|| { + MuxError::not_found(format!( + "node {old_node} is not a child of split {parent_id}" + )) + })?; + split.children[index] = new_node; + } + Node::Tabs(tabs) => { + let tab = tabs + .tabs + .iter_mut() + .find(|tab| tab.child == old_node) + .ok_or_else(|| { + MuxError::not_found(format!( + "node {old_node} is not a tab child of {parent_id}" + )) + })?; + tab.child = new_node; + } + Node::BufferView(_) => { + return Err(MuxError::invalid_input( + "buffer views cannot own child nodes".to_owned(), + )); + } + } + self.set_parent(new_node, Some(parent_id))?; + return Ok(()); + } + + if self.is_session_root(old_node) { + self.session_mut(session_id)?.root_node = new_node; + self.set_parent(new_node, None)?; + return Ok(()); + } + + if let Some(floating_id) = self.floating_id_by_root(old_node) { + self.floating_mut(floating_id)?.root_node = new_node; + self.set_parent(new_node, None)?; + return Ok(()); + } + + Err(MuxError::conflict(format!( + "node {old_node} does not have a replaceable owner" + ))) + } + + fn replace_node_in_owner(&mut self, old_node: NodeId, new_node: NodeId) -> Result<()> { + let session_id = self.node_session_id(old_node)?; + let owner = self.node_parent(old_node)?; + let replacement_focus = self.resolve_focus_candidate(new_node)?; + if let Some(parent_id) = owner { + let should_update_focus = match self.node(parent_id)?.last_focused_descendant() { + Some(leaf_id) if self.nodes.contains_key(&leaf_id) => { + self.subtree_contains(old_node, leaf_id)? + } + Some(_) => true, + None => false, + }; + self.replace_child(parent_id, old_node, new_node)?; + if should_update_focus { + self.node_mut(parent_id)? + .set_last_focused_descendant(replacement_focus); + } + return Ok(()); + } + + if self.is_session_root(old_node) { + self.session_mut(session_id)?.root_node = new_node; + self.set_parent(new_node, None)?; + self.set_parent(old_node, None)?; + return Ok(()); + } + + if let Some(floating_id) = self.floating_id_by_root(old_node) { + self.floating_mut(floating_id)?.root_node = new_node; + self.set_parent(new_node, None)?; + self.set_parent(old_node, None)?; + return Ok(()); + } + + Err(MuxError::conflict(format!( + "node {old_node} does not have a replaceable owner" + ))) + } + + fn normalize_split_node(&mut self, node_id: NodeId) -> Result> { + let (children_len, parent) = match self.node(node_id)? { + Node::Split(split) => (split.children.len(), split.parent), + _ => return self.node_parent(node_id), + }; + + if children_len == 0 { + return Err(MuxError::conflict(format!( + "split node {node_id} cannot be empty after mutation" + ))); + } + + if children_len == 1 { + let child = match self.node(node_id)? { + Node::Split(split) => split.children[0], + _ => unreachable!(), + }; + self.replace_node_in_owner(node_id, child)?; + self.nodes.remove(&node_id); + return Ok(Some(child)); + } + + if let Node::Split(split) = self.node_mut(node_id)? + && (split.sizes.len() != split.children.len() || split.sizes.contains(&0)) + { + split.sizes = vec![1; split.children.len()]; + } + + Ok(parent) + } + + fn normalize_tabs_node(&mut self, node_id: NodeId) -> Result> { + let (tabs_len, parent) = match self.node(node_id)? { + Node::Tabs(tabs) => (tabs.tabs.len(), tabs.parent), + _ => return self.node_parent(node_id), + }; + + let is_root = self.is_session_root(node_id); + let floating_owner = self.floating_id_by_root(node_id); + + if tabs_len == 0 { + if is_root { + if let Node::Tabs(tabs) = self.node_mut(node_id)? { + tabs.active = 0; + tabs.last_focused_descendant = None; + } + return Ok(parent); + } + + if let Some(floating_id) = floating_owner { + let floating = self.remove_floating_window(floating_id)?; + self.nodes.remove(&floating.root_node); + return Ok(None); + } + + self.nodes.remove(&node_id); + return Ok(parent); + } + + if tabs_len == 1 && floating_owner.is_none() { + let child = match self.node(node_id)? { + Node::Tabs(tabs) => tabs.tabs[0].child, + _ => unreachable!(), + }; + self.replace_node_in_owner(node_id, child)?; + self.nodes.remove(&node_id); + return Ok(Some(child)); + } + + if let Node::Tabs(tabs) = self.node_mut(node_id)? + && tabs.active >= tabs.tabs.len() + { + tabs.active = tabs.tabs.len() - 1; + } + + Ok(parent) + } + + fn remove_subtree_nodes(&mut self, node_id: NodeId) -> Result<()> { + let children = self.node(node_id)?.child_ids(); + for child in children { + self.remove_subtree_nodes(child)?; + } + + if let Node::BufferView(leaf) = self.node(node_id)? { + self.detach_buffer_raw(leaf.buffer_id)?; + } + + self.nodes.remove(&node_id); + Ok(()) + } + + fn detach_buffer_raw(&mut self, buffer_id: BufferId) -> Result<()> { + self.buffer_mut(buffer_id)?.attachment = BufferAttachment::Detached; + Ok(()) + } + + fn discard_buffer_view(&mut self, node_id: NodeId) { + if self.node_parent(node_id).ok().flatten().is_some() && self.close_node(node_id).is_ok() { + return; + } + + let buffer_id = match self.node(node_id) { + Ok(Node::BufferView(leaf)) => Some(leaf.buffer_id), + _ => None, + }; + if let Some(buffer_id) = buffer_id { + let _ = self.detach_buffer_raw(buffer_id); + } + self.nodes.remove(&node_id); + } + + fn ensure_leaf_is_focusable(&self, session_id: SessionId, leaf_id: NodeId) -> Result<()> { + let root = self.top_root_for_node(leaf_id)?; + if root == self.session(session_id)?.root_node { + return Ok(()); + } + + let Some(floating_id) = self.floating_id_by_root(root) else { + return Err(MuxError::invalid_input(format!( + "leaf {leaf_id} is not attached to session {session_id} layout" + ))); + }; + if !self.floating_window(floating_id)?.visible { + return Err(MuxError::invalid_input(format!( + "leaf {leaf_id} is inside hidden floating window {floating_id}" + ))); + } + Ok(()) + } + + fn remove_floating_window(&mut self, floating_id: FloatingId) -> Result { + let floating = self + .floating + .remove(&floating_id) + .ok_or_else(|| MuxError::not_found(format!("unknown floating window {floating_id}")))?; + if let Some(session) = self.sessions.get_mut(&floating.session_id) { + session + .floating + .retain(|candidate| *candidate != floating_id); + if session.focused_floating == Some(floating_id) { + session.focused_floating = None; + } + } + Ok(floating) + } + + fn validate_subtree( + &self, + session_id: SessionId, + node_id: NodeId, + expected_parent: Option, + is_session_root: bool, + seen: &mut BTreeSet, + ) -> Result<()> { + let node = self.node(node_id)?; + if node.session_id() != session_id { + return Err(MuxError::conflict(format!( + "node {node_id} must belong to session {session_id}" + ))); + } + if node.parent() != expected_parent { + return Err(MuxError::conflict(format!( + "node {node_id} has parent {:?}, expected {:?}", + node.parent(), + expected_parent + ))); + } + if !seen.insert(node_id) { + return Err(MuxError::conflict(format!( + "node {node_id} is reachable from multiple owners" + ))); + } + + match node { + Node::BufferView(_) => {} + Node::Split(split) => { + if split.children.len() < 2 { + return Err(MuxError::conflict(format!( + "split node {node_id} must have at least two children" + ))); + } + if split.sizes.len() != split.children.len() { + return Err(MuxError::conflict(format!( + "split node {node_id} has mismatched child weights" + ))); + } + for child in &split.children { + self.validate_subtree(session_id, *child, Some(node_id), false, seen)?; + } + } + Node::Tabs(tabs) => { + if !is_session_root && tabs.tabs.is_empty() { + return Err(MuxError::conflict(format!( + "tabs node {node_id} must not be empty" + ))); + } + if tabs.tabs.is_empty() { + if tabs.active != 0 { + return Err(MuxError::conflict(format!( + "empty tabs node {node_id} must reset active index to zero" + ))); + } + } else if tabs.active >= tabs.tabs.len() { + return Err(MuxError::conflict(format!( + "tabs node {node_id} active index is out of range" + ))); + } + for tab in &tabs.tabs { + self.validate_subtree(session_id, tab.child, Some(node_id), false, seen)?; + } + } + } + + Ok(()) + } + + fn session_mut(&mut self, session_id: SessionId) -> Result<&mut Session> { + self.sessions + .get_mut(&session_id) + .ok_or_else(|| MuxError::not_found(format!("unknown session {session_id}"))) + } + + fn buffer_mut(&mut self, buffer_id: BufferId) -> Result<&mut Buffer> { + self.buffers + .get_mut(&buffer_id) + .ok_or_else(|| MuxError::not_found(format!("unknown buffer {buffer_id}"))) + } + + fn node_mut(&mut self, node_id: NodeId) -> Result<&mut Node> { + self.nodes + .get_mut(&node_id) + .ok_or_else(|| MuxError::not_found(format!("unknown node {node_id}"))) + } + + fn floating_mut(&mut self, floating_id: FloatingId) -> Result<&mut FloatingWindow> { + self.floating + .get_mut(&floating_id) + .ok_or_else(|| MuxError::not_found(format!("unknown floating window {floating_id}"))) + } +} diff --git a/crates/embers-server/src/terminal_backend.rs b/crates/embers-server/src/terminal_backend.rs new file mode 100644 index 0000000..df25749 --- /dev/null +++ b/crates/embers-server/src/terminal_backend.rs @@ -0,0 +1,443 @@ +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use alacritty_terminal::event::{Event, EventListener}; +use alacritty_terminal::grid::Dimensions; +use alacritty_terminal::index::{Column, Line, Point}; +use alacritty_terminal::term::{Config, LineDamageBounds, Term, TermDamage, TermMode}; +use alacritty_terminal::vte::ansi::{self, CursorShape as AlacrittyCursorShape}; +use embers_core::{ + ActivityState, CursorPosition, CursorShape, CursorState, PtySize, SnapshotLine, TerminalModes, + TerminalSnapshot, +}; +use tracing::error; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BackendMetadata { + pub title: Option, + pub viewport_top_line: u64, + pub total_lines: u64, + pub alternate_screen: bool, + pub mouse_reporting: bool, + pub focus_reporting: bool, + pub bracketed_paste: bool, + pub cursor: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BackendScrollbackSlice { + pub start_line: u64, + pub total_lines: u64, + pub lines: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BackendDamage { + None, + Full, + Partial(Vec), +} + +pub trait TerminalBackend: Send { + fn ingest_bytes(&mut self, bytes: &[u8]); + fn resize(&mut self, size: PtySize); + fn visible_snapshot( + &self, + sequence: u64, + size: PtySize, + cwd: Option, + ) -> TerminalSnapshot; + fn capture_scrollback(&self) -> Vec; + fn capture_scrollback_slice(&self, start_line: u64, line_count: u32) -> BackendScrollbackSlice; + fn metadata(&self) -> BackendMetadata; + fn take_activity(&mut self) -> ActivityState; + fn take_damage(&mut self) -> BackendDamage; +} + +#[derive(Clone, Debug, Default)] +pub struct RawByteRouter; + +impl RawByteRouter { + pub fn route_input(&self, bytes: Vec) -> Vec { + bytes + } + + pub fn route_output(&mut self, backend: &mut dyn TerminalBackend, bytes: &[u8]) { + backend.ingest_bytes(bytes); + } +} + +pub struct AlacrittyTerminalBackend { + term: Term, + parser: ansi::Processor, + events: Arc>, +} + +impl std::fmt::Debug for AlacrittyTerminalBackend { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AlacrittyTerminalBackend") + .field("metadata", &self.metadata()) + .finish() + } +} + +#[derive(Clone, Debug)] +struct BackendEventProxy { + state: Arc>, +} + +#[derive(Clone, Debug, Default)] +struct BackendEventState { + title: Option, + bell_pending: bool, +} + +impl BackendEventProxy { + fn new(state: Arc>) -> Self { + Self { state } + } +} + +impl EventListener for BackendEventProxy { + fn send_event(&self, event: Event) { + let Ok(mut state) = self.state.lock() else { + error!(?event, "backend event lock poisoned"); + return; + }; + + match event { + Event::Title(title) => state.title = Some(title), + Event::ResetTitle => state.title = None, + Event::Bell => state.bell_pending = true, + _ => {} + } + } +} + +#[derive(Clone, Copy, Debug)] +struct BackendSize { + columns: usize, + screen_lines: usize, +} + +impl Dimensions for BackendSize { + fn total_lines(&self) -> usize { + self.screen_lines + } + + fn screen_lines(&self) -> usize { + self.screen_lines + } + + fn columns(&self) -> usize { + self.columns + } +} + +impl AlacrittyTerminalBackend { + pub fn new(size: PtySize) -> Self { + let events = Arc::new(Mutex::new(BackendEventState::default())); + let dimensions = BackendSize { + columns: size.cols as usize, + screen_lines: size.rows as usize, + }; + let config = Config { + scrolling_history: 10_000, + ..Config::default() + }; + + Self { + term: Term::new(config, &dimensions, BackendEventProxy::new(events.clone())), + parser: ansi::Processor::new(), + events, + } + } + + fn visible_lines(&self) -> Vec { + let grid = self.term.grid(); + let display_offset = grid.display_offset() as i32; + let top = Line(-display_offset); + let bottom = Line(grid.screen_lines() as i32 - display_offset - 1); + self.collect_lines(top, bottom, false) + } + + fn all_lines(&self) -> Vec { + let grid = self.term.grid(); + let top = Line(-(grid.history_size() as i32)); + let bottom = Line(grid.screen_lines() as i32 - 1); + self.collect_lines(top, bottom, false) + } + + fn collect_lines(&self, start: Line, end: Line, trim_trailing_empty: bool) -> Vec { + let grid = self.term.grid(); + if grid.columns() == 0 || end < start { + return Vec::new(); + } + + let mut lines = Vec::new(); + let mut line = start; + while line <= end { + let text = self.term.bounds_to_string( + Point::new(line, Column(0)), + Point::new(line, Column(grid.columns() - 1)), + ); + lines.push(text.trim_end_matches('\n').to_owned()); + line += 1; + } + + if trim_trailing_empty { + while matches!(lines.last(), Some(last) if last.is_empty()) { + lines.pop(); + } + } + + lines + } + + fn cursor_state(&self) -> Option { + let cursor = self.term.renderable_content().cursor; + let shape = match cursor.shape { + AlacrittyCursorShape::Hidden => return None, + AlacrittyCursorShape::Block | AlacrittyCursorShape::HollowBlock => CursorShape::Block, + AlacrittyCursorShape::Underline => CursorShape::Underline, + AlacrittyCursorShape::Beam => CursorShape::Beam, + }; + let row = u16::try_from(cursor.point.line.0).ok()?; + let col = u16::try_from(cursor.point.column.0).ok()?; + Some(CursorState { + position: CursorPosition { row, col }, + shape, + }) + } + + fn terminal_modes(&self) -> TerminalModes { + let mode = *self.term.mode(); + TerminalModes { + alternate_screen: mode.contains(TermMode::ALT_SCREEN), + mouse_reporting: mode.intersects( + TermMode::MOUSE_REPORT_CLICK + | TermMode::MOUSE_DRAG + | TermMode::MOUSE_MOTION + | TermMode::SGR_MOUSE + | TermMode::UTF8_MOUSE, + ), + focus_reporting: mode.contains(TermMode::FOCUS_IN_OUT), + bracketed_paste: mode.contains(TermMode::BRACKETED_PASTE), + } + } + + fn viewport_top_line(&self) -> u64 { + let grid = self.term.grid(); + grid.history_size().saturating_sub(grid.display_offset()) as u64 + } + + fn total_lines(&self) -> u64 { + let grid = self.term.grid(); + (grid.history_size() + grid.screen_lines()) as u64 + } +} + +impl TerminalBackend for AlacrittyTerminalBackend { + fn ingest_bytes(&mut self, bytes: &[u8]) { + self.parser.advance(&mut self.term, bytes); + } + + fn resize(&mut self, size: PtySize) { + self.term.resize(BackendSize { + columns: size.cols as usize, + screen_lines: size.rows as usize, + }); + } + + fn visible_snapshot( + &self, + sequence: u64, + size: PtySize, + cwd: Option, + ) -> TerminalSnapshot { + let metadata = self.metadata(); + TerminalSnapshot { + sequence, + size, + cursor: metadata.cursor, + lines: self + .visible_lines() + .into_iter() + .map(|text| SnapshotLine { text }) + .collect(), + title: metadata.title, + cwd, + viewport_top_line: metadata.viewport_top_line, + total_lines: metadata.total_lines, + modes: TerminalModes { + alternate_screen: metadata.alternate_screen, + mouse_reporting: metadata.mouse_reporting, + focus_reporting: metadata.focus_reporting, + bracketed_paste: metadata.bracketed_paste, + }, + } + } + + fn capture_scrollback(&self) -> Vec { + let mut lines = self.all_lines(); + while matches!(lines.last(), Some(last) if last.is_empty()) { + lines.pop(); + } + lines + } + + fn capture_scrollback_slice(&self, start_line: u64, line_count: u32) -> BackendScrollbackSlice { + let lines = self.all_lines(); + let total_lines = lines.len() as u64; + let start_line = start_line.min(total_lines); + let end_line = start_line + .saturating_add(u64::from(line_count)) + .min(total_lines); + let lines = lines[start_line as usize..end_line as usize].to_vec(); + + BackendScrollbackSlice { + start_line, + total_lines, + lines, + } + } + + fn metadata(&self) -> BackendMetadata { + let state = self.events.lock().expect("backend event lock"); + let modes = self.terminal_modes(); + BackendMetadata { + title: state.title.clone(), + viewport_top_line: self.viewport_top_line(), + total_lines: self.total_lines(), + alternate_screen: modes.alternate_screen, + mouse_reporting: modes.mouse_reporting, + focus_reporting: modes.focus_reporting, + bracketed_paste: modes.bracketed_paste, + cursor: self.cursor_state(), + } + } + + fn take_activity(&mut self) -> ActivityState { + let mut state = self.events.lock().expect("backend event lock"); + if std::mem::take(&mut state.bell_pending) { + ActivityState::Bell + } else { + ActivityState::Activity + } + } + + fn take_damage(&mut self) -> BackendDamage { + let damage = match self.term.damage() { + TermDamage::Full => BackendDamage::Full, + TermDamage::Partial(iter) => { + let lines: Vec<_> = iter.collect(); + if lines.is_empty() { + BackendDamage::None + } else { + BackendDamage::Partial(lines) + } + } + }; + self.term.reset_damage(); + damage + } +} + +#[cfg(test)] +mod tests { + use super::{AlacrittyTerminalBackend, BackendDamage, TerminalBackend}; + use embers_core::{ActivityState, CursorShape, PtySize}; + + #[test] + fn visible_snapshot_extracts_plain_text_lines() { + let mut backend = AlacrittyTerminalBackend::new(PtySize::new(8, 3)); + let _ = backend.take_damage(); + + backend.ingest_bytes(b"hello\r\nworld"); + let snapshot = backend.visible_snapshot(3, PtySize::new(8, 3), None); + + let lines: Vec<_> = snapshot.lines.into_iter().map(|line| line.text).collect(); + assert_eq!(lines, vec!["hello", "world", ""]); + assert_eq!(snapshot.total_lines, 3); + assert_eq!(snapshot.viewport_top_line, 0); + assert!(matches!( + snapshot.cursor.as_ref().map(|cursor| cursor.shape), + Some(CursorShape::Block) | Some(CursorShape::Underline) | Some(CursorShape::Beam) + )); + } + + #[test] + fn scrollback_capture_preserves_history_beyond_viewport() { + let mut backend = AlacrittyTerminalBackend::new(PtySize::new(6, 2)); + let _ = backend.take_damage(); + + backend.ingest_bytes(b"one\r\ntwo\r\nthree\r\nfour"); + + let visible = backend.visible_snapshot(4, PtySize::new(6, 2), None); + let visible_lines: Vec<_> = visible.lines.into_iter().map(|line| line.text).collect(); + assert_eq!(visible_lines, vec!["three", "four"]); + assert_eq!(visible.viewport_top_line, 2); + assert_eq!(visible.total_lines, 4); + + let history = backend.capture_scrollback(); + assert!(history.iter().any(|line| line == "one")); + assert!(history.iter().any(|line| line == "four")); + } + + #[test] + fn scrollback_slice_returns_requested_window() { + let mut backend = AlacrittyTerminalBackend::new(PtySize::new(6, 2)); + let _ = backend.take_damage(); + + backend.ingest_bytes(b"one\r\ntwo\r\nthree\r\nfour"); + + let slice = backend.capture_scrollback_slice(1, 2); + assert_eq!(slice.start_line, 1); + assert_eq!(slice.total_lines, 4); + assert_eq!(slice.lines, vec!["two", "three"]); + } + + #[test] + fn damage_can_be_read_and_reset() { + let mut backend = AlacrittyTerminalBackend::new(PtySize::new(6, 2)); + + assert!(matches!(backend.take_damage(), BackendDamage::Full)); + assert!(!matches!(backend.take_damage(), BackendDamage::Full)); + + backend.ingest_bytes(b"hello"); + assert!(!matches!(backend.take_damage(), BackendDamage::None)); + assert!(!matches!(backend.take_damage(), BackendDamage::Full)); + } + + #[test] + fn metadata_surfaces_terminal_modes_and_title() { + let mut backend = AlacrittyTerminalBackend::new(PtySize::new(10, 2)); + let _ = backend.take_damage(); + + backend.ingest_bytes(b"\x1b]0;embers\x07\x1b[?1049h\x1b[?1000h\x1b[?1004h\x1b[?2004h"); + + let metadata = backend.metadata(); + assert_eq!(metadata.title.as_deref(), Some("embers")); + assert!(metadata.alternate_screen); + assert!(metadata.mouse_reporting); + assert!(metadata.focus_reporting); + assert!(metadata.bracketed_paste); + } + + #[test] + fn bell_activity_is_consumed_separately_from_metadata() { + let mut backend = AlacrittyTerminalBackend::new(PtySize::new(10, 2)); + let _ = backend.take_damage(); + + backend.ingest_bytes(b"\x1b]0;embers\x07\x07"); + + let metadata = backend.metadata(); + assert_eq!(metadata.title.as_deref(), Some("embers")); + assert_eq!(backend.take_activity(), ActivityState::Bell); + + let metadata = backend.metadata(); + assert_eq!(metadata.title.as_deref(), Some("embers")); + assert_eq!(backend.take_activity(), ActivityState::Activity); + } +} diff --git a/crates/embers-server/tests/buffer_lifecycle.rs b/crates/embers-server/tests/buffer_lifecycle.rs new file mode 100644 index 0000000..5da155f --- /dev/null +++ b/crates/embers-server/tests/buffer_lifecycle.rs @@ -0,0 +1,137 @@ +use embers_core::{ActivityState, MuxError, PtySize}; +use embers_server::{BufferAttachment, BufferState, ServerState}; + +#[test] +fn running_buffers_transition_to_exited_and_track_metadata() { + let mut state = ServerState::new(); + let buffer_id = state.create_buffer("shell", vec!["/bin/sh".to_owned()], None); + + state + .mark_buffer_running(buffer_id, Some(42)) + .expect("mark running"); + let sequence = state.note_buffer_output(buffer_id).expect("note output"); + assert_eq!(sequence, 1); + state + .set_buffer_size(buffer_id, PtySize::new(120, 40)) + .expect("resize buffer"); + state + .mark_buffer_exited(buffer_id, Some(0)) + .expect("mark exited"); + + let buffer = state.buffer(buffer_id).expect("buffer exists"); + assert_eq!(buffer.pty_size, PtySize::new(120, 40)); + assert_eq!(buffer.activity, ActivityState::Activity); + assert_eq!(buffer.last_snapshot_seq, 1); + assert!(matches!( + buffer.state, + BufferState::Exited(ref exited) if exited.exit_code == Some(0) + )); +} + +#[test] +fn single_attachment_requires_detach_before_reattach() { + let mut state = ServerState::new(); + let session_id = state.create_session("main"); + let first_buffer = state.create_buffer("first", vec!["first".to_owned()], None); + let second_buffer = state.create_buffer("second", vec!["second".to_owned()], None); + let first_view = state + .create_buffer_view(session_id, first_buffer) + .expect("create first view"); + let second_view = state + .create_buffer_view(session_id, second_buffer) + .expect("create second view"); + state + .add_root_tab(session_id, "first", first_view) + .expect("attach first view to root"); + state + .add_root_tab(session_id, "second", second_view) + .expect("attach second view to root"); + + let error = state + .attach_buffer(first_buffer, second_view) + .expect_err("reattach without detach should fail"); + assert!(matches!(error, MuxError::Conflict(_))); + + state.close_node(first_view).expect("close original view"); + state + .attach_buffer(first_buffer, second_view) + .expect("reattach detached buffer"); + + assert!(matches!( + state.buffer(first_buffer).expect("first buffer").attachment, + BufferAttachment::Attached(node_id) if node_id == second_view + )); + assert!(matches!( + state + .buffer(second_buffer) + .expect("second buffer") + .attachment, + BufferAttachment::Detached + )); + assert!(matches!(state.node(first_view), Err(MuxError::NotFound(_)))); + assert_eq!( + state + .node(second_view) + .expect("second view still exists") + .as_buffer_view() + .expect("second node is a buffer view") + .buffer_id, + first_buffer + ); + state.validate().expect("state stays valid after reattach"); +} + +#[test] +fn closing_a_view_detaches_but_preserves_running_buffer() { + let mut state = ServerState::new(); + let session_id = state.create_session("main"); + let buffer_id = state.create_buffer("shell", vec!["/bin/sh".to_owned()], None); + state + .mark_buffer_running(buffer_id, Some(7)) + .expect("mark running"); + let view_id = state + .create_buffer_view(session_id, buffer_id) + .expect("create buffer view"); + state + .add_root_tab(session_id, "shell", view_id) + .expect("attach view to root tabs"); + + state.close_node(view_id).expect("close buffer view"); + + let buffer = state.buffer(buffer_id).expect("buffer still exists"); + assert!(matches!(buffer.attachment, BufferAttachment::Detached)); + assert!(matches!( + buffer.state, + BufferState::Running(ref running) if running.pid == Some(7) + )); +} + +#[test] +fn resize_updates_buffer_size_for_attached_and_detached_buffers() { + let mut state = ServerState::new(); + let session_id = state.create_session("main"); + let buffer_id = state.create_buffer("shell", vec!["/bin/sh".to_owned()], None); + let view_id = state + .create_buffer_view(session_id, buffer_id) + .expect("create buffer view"); + state + .add_root_tab(session_id, "shell", view_id) + .expect("attach view to root tabs"); + + state + .set_buffer_size(buffer_id, PtySize::new(100, 30)) + .expect("resize attached buffer"); + assert_eq!( + state.buffer(buffer_id).expect("buffer exists").pty_size, + PtySize::new(100, 30) + ); + + state.close_node(view_id).expect("close buffer view"); + state + .set_buffer_size(buffer_id, PtySize::new(90, 20)) + .expect("resize detached buffer"); + assert_eq!( + state.buffer(buffer_id).expect("buffer exists").pty_size, + PtySize::new(90, 20) + ); +} diff --git a/crates/embers-server/tests/buffer_move.rs b/crates/embers-server/tests/buffer_move.rs new file mode 100644 index 0000000..7ab353f --- /dev/null +++ b/crates/embers-server/tests/buffer_move.rs @@ -0,0 +1,163 @@ +use embers_server::{BufferAttachment, Node, ServerState}; + +#[test] +fn moving_buffer_between_leaves_replaces_target_and_closes_source_view() { + let mut state = ServerState::new(); + let session_id = state.create_session("main"); + + let first_buffer = state.create_buffer("one", vec!["/bin/sh".to_owned()], None); + state + .add_root_tab_from_buffer(session_id, "one", first_buffer) + .expect("add root tab"); + let first_leaf = state + .session(session_id) + .expect("session exists") + .focused_leaf + .expect("root tab focuses first leaf"); + + let second_buffer = state.create_buffer("two", vec!["/bin/sh".to_owned()], None); + state + .split_leaf_with_new_buffer( + first_leaf, + embers_core::SplitDirection::Horizontal, + second_buffer, + ) + .expect("split root leaf"); + let second_leaf = state + .session(session_id) + .expect("session exists") + .focused_leaf + .expect("split focuses new leaf"); + + state + .move_buffer_to_leaf(first_buffer, second_leaf) + .expect("move buffer into target leaf"); + + assert_eq!( + state.session(session_id).expect("session exists").root_node, + second_leaf + ); + assert_eq!(state.node_parent(second_leaf).expect("target parent"), None); + match state.node(second_leaf).expect("target leaf exists") { + Node::BufferView(view) => assert_eq!(view.buffer_id, first_buffer), + other => panic!("expected target buffer view, got {other:?}"), + } + assert!(matches!( + &state.buffer(first_buffer).expect("buffer exists").attachment, + BufferAttachment::Attached(node_id) if *node_id == second_leaf + )); + assert!(matches!( + &state + .buffer(second_buffer) + .expect("buffer exists") + .attachment, + BufferAttachment::Detached + )); + assert_eq!( + state + .session(session_id) + .expect("session exists") + .focused_leaf, + Some(second_leaf) + ); + + state.validate().expect("move keeps state valid"); +} + +#[test] +fn detached_buffer_can_reattach_to_existing_leaf() { + let mut state = ServerState::new(); + let session_id = state.create_session("main"); + + let first_buffer = state.create_buffer("one", vec!["/bin/sh".to_owned()], None); + state + .add_root_tab_from_buffer(session_id, "one", first_buffer) + .expect("add root tab"); + let first_leaf = state + .session(session_id) + .expect("session exists") + .focused_leaf + .expect("root tab focuses first leaf"); + state.close_node(first_leaf).expect("close source view"); + + let second_buffer = state.create_buffer("two", vec!["/bin/sh".to_owned()], None); + state + .add_root_tab_from_buffer(session_id, "two", second_buffer) + .expect("add replacement root tab"); + let second_leaf = state + .session(session_id) + .expect("session exists") + .focused_leaf + .expect("replacement root tab focuses second leaf"); + + state + .move_buffer_to_leaf(first_buffer, second_leaf) + .expect("reattach detached buffer"); + + match state.node(second_leaf).expect("target leaf exists") { + Node::BufferView(view) => assert_eq!(view.buffer_id, first_buffer), + other => panic!("expected target buffer view, got {other:?}"), + } + assert!(matches!( + &state.buffer(first_buffer).expect("buffer exists").attachment, + BufferAttachment::Attached(node_id) if *node_id == second_leaf + )); + assert!(matches!( + &state + .buffer(second_buffer) + .expect("buffer exists") + .attachment, + BufferAttachment::Detached + )); + + state.validate().expect("reattach keeps state valid"); +} + +#[test] +fn attached_buffers_must_detach_before_cross_session_move() { + let mut state = ServerState::new(); + let source_session = state.create_session("source"); + let target_session = state.create_session("target"); + + let source_buffer = state.create_buffer("one", vec!["/bin/sh".to_owned()], None); + state + .add_root_tab_from_buffer(source_session, "one", source_buffer) + .expect("add source tab"); + let source_leaf = state + .session(source_session) + .expect("source session exists") + .focused_leaf + .expect("source tab focuses source leaf"); + + let target_buffer = state.create_buffer("two", vec!["/bin/sh".to_owned()], None); + state + .add_root_tab_from_buffer(target_session, "two", target_buffer) + .expect("add target tab"); + let target_leaf = state + .session(target_session) + .expect("target session exists") + .focused_leaf + .expect("target tab focuses target leaf"); + + let error = state + .move_buffer_to_leaf(source_buffer, target_leaf) + .expect_err("attached cross-session move should be rejected"); + assert!( + error + .to_string() + .contains("detached before moving across sessions") + ); + + state.close_node(source_leaf).expect("detach source view"); + state + .move_buffer_to_leaf(source_buffer, target_leaf) + .expect("detached buffer can move across sessions"); + match state.node(target_leaf).expect("target leaf exists") { + Node::BufferView(view) => assert_eq!(view.buffer_id, source_buffer), + other => panic!("expected target buffer view, got {other:?}"), + } + + state + .validate() + .expect("cross-session reattach keeps state valid"); +} diff --git a/crates/embers-server/tests/floating_windows.rs b/crates/embers-server/tests/floating_windows.rs new file mode 100644 index 0000000..5263e17 --- /dev/null +++ b/crates/embers-server/tests/floating_windows.rs @@ -0,0 +1,161 @@ +use embers_core::{FloatGeometry, SessionId}; +use embers_server::{BufferAttachment, Node, ServerState, TabEntry}; + +fn root_leaf(state: &ServerState, session_id: SessionId) -> embers_core::NodeId { + state + .session(session_id) + .expect("session exists") + .focused_leaf + .expect("session has focused leaf") +} + +#[test] +fn buffer_backed_floating_tracks_focus_geometry_and_detach_on_close() { + let mut state = ServerState::new(); + let session_id = state.create_session("main"); + + let root_buffer = state.create_buffer("root", vec!["/bin/sh".to_owned()], None); + state + .add_root_tab_from_buffer(session_id, "root", root_buffer) + .expect("add root tab"); + let root_leaf = root_leaf(&state, session_id); + + let popup_buffer = state.create_buffer("popup", vec!["/bin/sh".to_owned()], None); + let floating_id = state + .create_floating_from_buffer( + session_id, + popup_buffer, + FloatGeometry::new(4, 3, 40, 12), + Some("popup".to_owned()), + ) + .expect("create floating from buffer"); + + let floating_root = state + .floating_window(floating_id) + .expect("floating exists") + .root_node; + assert!(matches!( + &state.buffer(popup_buffer).expect("buffer exists").attachment, + BufferAttachment::Attached(node_id) if *node_id == floating_root + )); + + state + .focus_floating(floating_id) + .expect("focus floating window"); + assert_eq!( + state + .session(session_id) + .expect("session exists") + .focused_floating, + Some(floating_id) + ); + + state + .focus_leaf(session_id, root_leaf) + .expect("focus back to tiled root"); + assert_eq!( + state + .session(session_id) + .expect("session exists") + .focused_floating, + None + ); + assert!( + !state + .floating_window(floating_id) + .expect("floating exists") + .focused + ); + + let new_geometry = FloatGeometry::new(10, 6, 60, 18); + state + .move_floating(floating_id, new_geometry) + .expect("move floating window"); + assert_eq!( + state + .floating_window(floating_id) + .expect("floating exists") + .geometry, + new_geometry + ); + + state + .close_floating(floating_id) + .expect("close floating window"); + assert!(matches!( + &state + .buffer(popup_buffer) + .expect("buffer exists") + .attachment, + BufferAttachment::Detached + )); + assert_eq!( + state + .session(session_id) + .expect("session exists") + .focused_leaf, + Some(root_leaf) + ); + assert!(state.floating_window(floating_id).is_err()); + + state.validate().expect("floating lifecycle remains valid"); +} + +#[test] +fn floating_tabs_close_on_empty_and_restore_root_focus() { + let mut state = ServerState::new(); + let session_id = state.create_session("main"); + + let root_buffer = state.create_buffer("root", vec!["/bin/sh".to_owned()], None); + state + .add_root_tab_from_buffer(session_id, "root", root_buffer) + .expect("add root tab"); + let root_leaf = root_leaf(&state, session_id); + + let popup_buffer = state.create_buffer("popup", vec!["/bin/sh".to_owned()], None); + let popup_leaf = state + .create_buffer_view(session_id, popup_buffer) + .expect("create detached popup leaf"); + let popup_tabs = state + .create_tabs_node(session_id, vec![TabEntry::new("popup", popup_leaf)], 0) + .expect("create popup tabs root"); + let floating_id = state + .create_floating_window( + session_id, + popup_tabs, + FloatGeometry::new(2, 2, 30, 10), + Some("popup".to_owned()), + ) + .expect("create floating tabs"); + + state + .focus_floating(floating_id) + .expect("focus floating tabs"); + state.close_node(popup_leaf).expect("close only popup tab"); + + assert!(state.floating_window(floating_id).is_err()); + assert_eq!( + state + .session(session_id) + .expect("session exists") + .focused_leaf, + Some(root_leaf) + ); + assert!(matches!( + &state + .buffer(popup_buffer) + .expect("buffer exists") + .attachment, + BufferAttachment::Detached + )); + + match state + .node(state.root_tabs(session_id).expect("root tabs")) + .expect("root exists") + { + Node::Tabs(_) => {} + other => panic!("expected root tabs, got {other:?}"), + } + + state.validate().expect("close-on-empty keeps state valid"); +} diff --git a/crates/embers-server/tests/model_state.rs b/crates/embers-server/tests/model_state.rs new file mode 100644 index 0000000..64d1c31 --- /dev/null +++ b/crates/embers-server/tests/model_state.rs @@ -0,0 +1,577 @@ +use std::collections::BTreeSet; + +use embers_core::{BufferId, FloatGeometry, NodeId, SessionId, SplitDirection}; +use embers_server::{BufferAttachment, Node, ServerState}; +use proptest::prelude::*; + +fn seed_single_leaf_session(state: &mut ServerState, name: &str) -> (SessionId, BufferId, NodeId) { + let session_id = state.create_session(name); + let buffer_id = state.create_buffer("shell", vec!["sh".to_owned()], None); + let leaf_id = state + .create_buffer_view(session_id, buffer_id) + .expect("create root leaf"); + state + .add_root_tab(session_id, "main", leaf_id) + .expect("insert root tab"); + (session_id, buffer_id, leaf_id) +} + +fn new_leaf(state: &mut ServerState, session_id: SessionId, label: &str) -> (BufferId, NodeId) { + let buffer_id = state.create_buffer(label, vec!["sh".to_owned()], None); + let leaf_id = state + .create_buffer_view(session_id, buffer_id) + .expect("create detached leaf"); + (buffer_id, leaf_id) +} + +fn new_buffer(state: &mut ServerState, label: &str) -> BufferId { + state.create_buffer(label, vec!["sh".to_owned()], None) +} + +fn attached_view(state: &ServerState, buffer_id: BufferId) -> NodeId { + match state.buffer(buffer_id).expect("buffer exists").attachment { + BufferAttachment::Attached(node_id) => node_id, + BufferAttachment::Detached => panic!("buffer {buffer_id} is detached"), + } +} + +fn session_root(state: &ServerState, session_id: SessionId) -> NodeId { + state.session(session_id).expect("session exists").root_node +} + +fn root_tab_child(state: &ServerState, session_id: SessionId, index: usize) -> NodeId { + let root = state.root_tabs(session_id).expect("root tabs"); + match state.node(root).expect("root node") { + Node::Tabs(tabs) => tabs.tabs[index].child, + other => panic!("expected root tabs, got {other:?}"), + } +} + +fn reachable_nodes(state: &ServerState, session_id: SessionId) -> BTreeSet { + fn visit(state: &ServerState, node_id: NodeId, seen: &mut BTreeSet) { + if !seen.insert(node_id) { + return; + } + for child in state.node(node_id).expect("node exists").child_ids() { + visit(state, child, seen); + } + } + + let mut seen = BTreeSet::new(); + let session = state.session(session_id).expect("session exists"); + visit(state, session.root_node, &mut seen); + for floating_id in &session.floating { + let floating = state + .floating_window(*floating_id) + .expect("floating exists"); + visit(state, floating.root_node, &mut seen); + } + seen +} + +fn apply_random_op(state: &mut ServerState, session_id: SessionId, selector: u8, arg: u8) { + let root_tabs = state.root_tabs(session_id).ok(); + let tab_count = root_tabs + .and_then(|root| match state.node(root).expect("root node") { + Node::Tabs(tabs) => Some(tabs.tabs.len()), + _ => None, + }) + .unwrap_or(0); + + match selector % 5 { + 0 => { + if state.root_tabs(session_id).is_ok() { + let (_, leaf_id) = new_leaf(state, session_id, &format!("tab-{arg}")); + let _ = state.add_root_tab(session_id, format!("tab-{arg}"), leaf_id); + } + } + 1 => { + if let Some(focused_leaf) = state.session(session_id).expect("session").focused_leaf { + let buffer_id = new_buffer(state, &format!("split-{arg}")); + let direction = if arg.is_multiple_of(2) { + SplitDirection::Horizontal + } else { + SplitDirection::Vertical + }; + let _ = state.split_leaf_with_new_buffer(focused_leaf, direction, buffer_id); + } + } + 2 => { + if let Some(focused_leaf) = state.session(session_id).expect("session").focused_leaf { + let _ = state.close_node(focused_leaf); + } + } + 3 => { + if let Some(root) = root_tabs + && tab_count > 0 + { + let _ = state.switch_tab(root, usize::from(arg) % tab_count); + } + } + 4 => { + if let Some(root) = root_tabs + && tab_count > 0 + { + let _ = state.close_tab(root, usize::from(arg) % tab_count); + } + } + _ => unreachable!(), + } +} + +#[test] +fn node_creation_and_parent_ownership_are_consistent() { + let mut state = ServerState::new(); + let (session_id, _, leaf_id) = seed_single_leaf_session(&mut state, "alpha"); + let buffer_id = new_buffer(&mut state, "beta"); + + let split_id = state + .split_leaf_with_new_buffer(leaf_id, SplitDirection::Horizontal, buffer_id) + .expect("split root leaf"); + let new_leaf = attached_view(&state, buffer_id); + + assert_eq!( + state.node_parent(leaf_id).expect("old leaf parent"), + Some(split_id) + ); + assert_eq!( + state.node_parent(new_leaf).expect("new leaf parent"), + Some(split_id) + ); + assert_eq!(root_tab_child(&state, session_id, 0), split_id); + state.validate().expect("state should validate"); +} + +#[test] +fn split_normalization_collapses_single_child_split() { + let mut state = ServerState::new(); + let (session_id, _, leaf_id) = seed_single_leaf_session(&mut state, "alpha"); + let buffer_id = new_buffer(&mut state, "beta"); + let split_id = state + .split_leaf_with_new_buffer(leaf_id, SplitDirection::Vertical, buffer_id) + .expect("split root leaf"); + let new_leaf = attached_view(&state, buffer_id); + + state.close_node(new_leaf).expect("close new leaf"); + + assert!(!state.nodes.contains_key(&split_id)); + assert_eq!(session_root(&state, session_id), leaf_id); + assert_eq!(state.node_parent(leaf_id).expect("leaf parent"), None); + state.validate().expect("state should validate"); +} + +#[test] +fn tabs_normalization_collapses_nested_singleton_tabs() { + let mut state = ServerState::new(); + let (session_id, _, leaf_id) = seed_single_leaf_session(&mut state, "alpha"); + + let wrapped = state + .wrap_node_in_tabs(leaf_id, "inner") + .expect("wrap leaf in nested tabs"); + state + .normalize_upwards(wrapped) + .expect("normalize wrapped tabs"); + + assert!(!state.nodes.contains_key(&wrapped)); + assert_eq!(session_root(&state, session_id), leaf_id); + assert_eq!(state.node_parent(leaf_id).expect("leaf parent"), None); + state.validate().expect("state should validate"); +} + +#[test] +fn focus_heals_deterministically_after_close() { + let mut state = ServerState::new(); + let (session_id, _, leaf_id) = seed_single_leaf_session(&mut state, "alpha"); + let buffer_id = new_buffer(&mut state, "beta"); + state + .split_leaf_with_new_buffer(leaf_id, SplitDirection::Horizontal, buffer_id) + .expect("split root leaf"); + let new_leaf = attached_view(&state, buffer_id); + + state.close_node(new_leaf).expect("close focused leaf"); + + assert_eq!( + state.session(session_id).expect("session").focused_leaf, + Some(leaf_id) + ); + let leaf = state.node(leaf_id).expect("leaf exists"); + assert!(matches!(leaf, Node::BufferView(view) if view.view.focused)); + state.validate().expect("state should validate"); +} + +#[test] +fn create_split_rejects_duplicate_children_without_mutating_state() { + let mut state = ServerState::new(); + let (session_id, _, _) = seed_single_leaf_session(&mut state, "alpha"); + let (_, leaf_id) = new_leaf(&mut state, session_id, "dup"); + state + .create_floating_window( + session_id, + leaf_id, + FloatGeometry::new(1, 1, 10, 6), + Some("popup".to_owned()), + ) + .expect("create floating window"); + + let error = state + .create_split_node( + session_id, + SplitDirection::Horizontal, + vec![leaf_id, leaf_id], + ) + .expect_err("duplicate split children should fail"); + + assert!(matches!(error, embers_core::MuxError::InvalidInput(_))); + assert_eq!(state.node_parent(leaf_id).expect("leaf parent"), None); + state.validate().expect("state remains valid"); +} + +#[test] +fn create_tabs_rejects_children_with_existing_parents() { + let mut state = ServerState::new(); + let (session_id, _, leaf_id) = seed_single_leaf_session(&mut state, "alpha"); + + let error = state + .create_tabs_node( + session_id, + vec![embers_server::TabEntry::new("main", leaf_id)], + 0, + ) + .expect_err("attached child should be rejected"); + + assert!(matches!(error, embers_core::MuxError::InvalidInput(_))); + state.validate().expect("state remains valid"); +} + +#[test] +fn floating_root_cannot_be_reused() { + let mut state = ServerState::new(); + let (session_id, _, _) = seed_single_leaf_session(&mut state, "alpha"); + let (_, floating_leaf) = new_leaf(&mut state, session_id, "popup"); + + state + .create_floating_window( + session_id, + floating_leaf, + FloatGeometry::new(2, 2, 15, 8), + Some("popup".to_owned()), + ) + .expect("create floating window"); + + let error = state + .create_floating_window( + session_id, + floating_leaf, + FloatGeometry::new(4, 4, 20, 10), + Some("duplicate".to_owned()), + ) + .expect_err("floating root reuse should fail"); + + assert!(matches!(error, embers_core::MuxError::InvalidInput(_))); + state.validate().expect("state remains valid"); +} + +#[test] +fn add_tab_sibling_rejects_self_parenting() { + let mut state = ServerState::new(); + let (session_id, _, leaf_id) = seed_single_leaf_session(&mut state, "alpha"); + let wrapped = state + .wrap_node_in_tabs(leaf_id, "nested") + .expect("wrap node in tabs"); + + let error = state + .add_tab_sibling(wrapped, "self", wrapped) + .expect_err("tabs should not be able to contain themselves"); + + assert!(matches!(error, embers_core::MuxError::InvalidInput(_))); + state.validate().expect("state remains valid"); + assert_eq!( + state.node_parent(wrapped).expect("wrapped parent"), + Some(state.root_tabs(session_id).expect("root tabs")) + ); +} + +#[test] +fn public_detach_buffer_closes_live_views() { + let mut state = ServerState::new(); + let (session_id, buffer_id, leaf_id) = seed_single_leaf_session(&mut state, "alpha"); + + state.detach_buffer(buffer_id).expect("detach buffer"); + + assert!(matches!( + state.node(leaf_id), + Err(embers_core::MuxError::NotFound(_)) + )); + assert!(matches!( + state.buffer(buffer_id).expect("buffer exists").attachment, + BufferAttachment::Detached + )); + assert_eq!( + state.session(session_id).expect("session").focused_leaf, + None + ); + state.validate().expect("state remains valid"); +} + +#[test] +fn focused_floating_transfers_back_to_root_when_closed() { + let mut state = ServerState::new(); + let (session_id, _, root_leaf) = seed_single_leaf_session(&mut state, "alpha"); + let (_, floating_leaf) = new_leaf(&mut state, session_id, "popup"); + + state + .create_floating_window( + session_id, + floating_leaf, + FloatGeometry::new(5, 5, 20, 10), + Some("popup".to_owned()), + ) + .expect("create floating window"); + state + .focus_leaf(session_id, floating_leaf) + .expect("focus popup"); + state.close_node(floating_leaf).expect("close popup root"); + + let session = state.session(session_id).expect("session exists"); + assert_eq!(session.focused_leaf, Some(root_leaf)); + assert_eq!(session.focused_floating, None); + state.validate().expect("state should validate"); +} + +#[test] +fn subtree_ownership_validation_detects_cross_session_child() { + let mut state = ServerState::new(); + let (_session_a, _, leaf_a) = seed_single_leaf_session(&mut state, "alpha"); + let (_session_b, _, leaf_b) = seed_single_leaf_session(&mut state, "beta"); + let buffer_id = new_buffer(&mut state, "gamma"); + let split_id = state + .split_leaf_with_new_buffer(leaf_a, SplitDirection::Horizontal, buffer_id) + .expect("split root leaf"); + + if let Node::Split(split) = state.nodes.get_mut(&split_id).expect("split exists") { + split.children[1] = leaf_b; + } + if let Node::BufferView(leaf) = state.nodes.get_mut(&leaf_b).expect("leaf exists") { + leaf.parent = Some(split_id); + } + + let error = state.validate().expect_err("validation should fail"); + assert!(error.to_string().contains("must belong to session")); + assert!(error.to_string().contains(&leaf_b.to_string())); +} + +#[test] +fn floating_ownership_validation_detects_parented_root() { + let mut state = ServerState::new(); + let (session_id, _, _) = seed_single_leaf_session(&mut state, "alpha"); + let (_, floating_leaf) = new_leaf(&mut state, session_id, "popup"); + let floating_id = state + .create_floating_window( + session_id, + floating_leaf, + FloatGeometry::new(2, 2, 15, 8), + Some("popup".to_owned()), + ) + .expect("create floating window"); + let root_tabs = state.root_tabs(session_id).expect("root tabs"); + + if let Node::BufferView(leaf) = state.nodes.get_mut(&floating_leaf).expect("leaf exists") { + leaf.parent = Some(root_tabs); + } + + let error = state.validate().expect_err("validation should fail"); + assert!(error.to_string().contains(&floating_id.to_string())); +} + +#[test] +fn create_buffer_view_rolls_back_when_attach_fails() { + let mut state = ServerState::new(); + let (session_id, buffer_id, _) = seed_single_leaf_session(&mut state, "alpha"); + let node_count = state.nodes.len(); + + let error = state + .create_buffer_view(session_id, buffer_id) + .expect_err("attached buffers cannot create a second view"); + + assert!(matches!(error, embers_core::MuxError::Conflict(_))); + assert_eq!(state.nodes.len(), node_count); + assert_eq!( + attached_view(&state, buffer_id), + root_tab_child(&state, session_id, 0) + ); + state.validate().expect("state remains valid"); +} + +#[test] +fn focus_leaf_rejects_detached_leaves_without_mutating_focus() { + let mut state = ServerState::new(); + let (session_id, _, root_leaf) = seed_single_leaf_session(&mut state, "alpha"); + let (_, detached_leaf) = new_leaf(&mut state, session_id, "detached"); + + let error = state + .focus_leaf(session_id, detached_leaf) + .expect_err("detached leaf should not be focusable"); + + assert!(matches!(error, embers_core::MuxError::InvalidInput(_))); + assert_eq!( + state.session(session_id).expect("session").focused_leaf, + Some(root_leaf) + ); +} + +#[test] +fn focus_leaf_rejects_hidden_floating_leaves_without_mutating_focus() { + let mut state = ServerState::new(); + let (session_id, _, root_leaf) = seed_single_leaf_session(&mut state, "alpha"); + let (_, floating_leaf) = new_leaf(&mut state, session_id, "popup"); + let floating_id = state + .create_floating_window_with_options( + session_id, + floating_leaf, + FloatGeometry::new(4, 4, 20, 10), + Some("popup".to_owned()), + false, + true, + ) + .expect("create floating window"); + state + .floating + .get_mut(&floating_id) + .expect("floating exists") + .visible = false; + + let error = state + .focus_leaf(session_id, floating_leaf) + .expect_err("hidden floating leaf should not be focusable"); + + assert!(matches!(error, embers_core::MuxError::InvalidInput(_))); + assert_eq!( + state.session(session_id).expect("session").focused_leaf, + Some(root_leaf) + ); + assert_eq!( + state.session(session_id).expect("session").focused_floating, + None + ); + state.validate().expect("state remains valid"); +} + +#[test] +fn add_tab_from_buffer_rolls_back_when_hidden_floating_cannot_focus() { + let mut state = ServerState::new(); + let (session_id, _, root_leaf) = seed_single_leaf_session(&mut state, "alpha"); + let (popup_buffer, popup_leaf) = new_leaf(&mut state, session_id, "popup"); + let floating_id = state + .create_floating_window_with_options( + session_id, + popup_leaf, + FloatGeometry::new(4, 4, 20, 10), + Some("popup".to_owned()), + false, + true, + ) + .expect("create floating window"); + let tabs_id = state + .wrap_node_in_tabs(popup_leaf, "popup") + .expect("wrap popup leaf in tabs"); + state + .floating + .get_mut(&floating_id) + .expect("floating exists") + .visible = false; + + let added_buffer = new_buffer(&mut state, "extra"); + let node_count = state.nodes.len(); + + let error = state + .add_tab_from_buffer(tabs_id, "extra", added_buffer) + .expect_err("hidden floating tabs should reject focus"); + + assert!(matches!(error, embers_core::MuxError::InvalidInput(_))); + assert_eq!(state.nodes.len(), node_count); + assert_eq!( + state + .buffer(added_buffer) + .expect("buffer exists") + .attachment, + BufferAttachment::Detached + ); + assert_eq!( + state + .buffer(popup_buffer) + .expect("buffer exists") + .attachment, + BufferAttachment::Attached(popup_leaf) + ); + assert_eq!( + state.session(session_id).expect("session").focused_leaf, + Some(root_leaf) + ); + state.validate().expect("state remains valid"); +} + +#[test] +fn root_tabs_are_preserved_when_last_tab_closes() { + let mut state = ServerState::new(); + let (session_id, _, _) = seed_single_leaf_session(&mut state, "alpha"); + let root_tabs = state.root_tabs(session_id).expect("root tabs"); + + state.close_tab(root_tabs, 0).expect("close only root tab"); + + match state.node(root_tabs).expect("root tabs still exist") { + Node::Tabs(tabs) => assert!(tabs.tabs.is_empty()), + other => panic!("expected root tabs, got {other:?}"), + } + assert_eq!( + state.session(session_id).expect("session").focused_leaf, + None + ); + state.validate().expect("state should validate"); +} + +proptest! { + #[test] + fn random_mutation_sequences_preserve_invariants( + ops in prop::collection::vec((0u8..5, any::()), 1..48) + ) { + let mut state = ServerState::new(); + let (session_id, _, _) = seed_single_leaf_session(&mut state, "prop"); + + for (selector, arg) in ops { + apply_random_op(&mut state, session_id, selector, arg); + let validation = state.validate(); + prop_assert!(validation.is_ok(), "validation failed: {:?}", validation.err()); + } + } + + #[test] + fn no_orphaned_nodes_after_random_mutations( + ops in prop::collection::vec((0u8..5, any::()), 1..48) + ) { + let mut state = ServerState::new(); + let (session_id, _, _) = seed_single_leaf_session(&mut state, "prop"); + + for (selector, arg) in ops { + apply_random_op(&mut state, session_id, selector, arg); + let reachable = reachable_nodes(&state, session_id); + prop_assert_eq!(reachable.len(), state.nodes.len()); + } + } + + #[test] + fn nested_singleton_tabs_normalize_to_a_valid_tree(depth in 1usize..6) { + let mut state = ServerState::new(); + let (session_id, _, leaf_id) = seed_single_leaf_session(&mut state, "prop"); + let mut current = leaf_id; + + for index in 0..depth { + current = state + .wrap_node_in_tabs(current, format!("nested-{index}")) + .expect("wrap node"); + } + + state.normalize_upwards(current).expect("normalize wrappers"); + prop_assert!(state.validate().is_ok()); + prop_assert_eq!(session_root(&state, session_id), leaf_id); + } +} diff --git a/crates/embers-server/tests/nested_tabs.rs b/crates/embers-server/tests/nested_tabs.rs new file mode 100644 index 0000000..540e4df --- /dev/null +++ b/crates/embers-server/tests/nested_tabs.rs @@ -0,0 +1,197 @@ +use embers_core::{SessionId, SplitDirection}; +use embers_server::{BufferAttachment, Node, ServerState, SplitNode, TabsNode}; + +fn root_tabs(state: &ServerState, session_id: SessionId) -> TabsNode { + let root_id = state.root_tabs(session_id).expect("session has root tabs"); + match state.node(root_id).expect("root node exists") { + Node::Tabs(tabs) => tabs.clone(), + other => panic!("expected root tabs node, got {other:?}"), + } +} + +fn tabs_node(state: &ServerState, node_id: embers_core::NodeId) -> TabsNode { + match state.node(node_id).expect("tabs node exists") { + Node::Tabs(tabs) => tabs.clone(), + other => panic!("expected tabs node, got {other:?}"), + } +} + +fn split_node(state: &ServerState, node_id: embers_core::NodeId) -> SplitNode { + match state.node(node_id).expect("split node exists") { + Node::Split(split) => split.clone(), + other => panic!("expected split node, got {other:?}"), + } +} + +#[test] +fn wrap_node_in_tabs_reparents_subtree_and_preserves_focus() { + let mut state = ServerState::new(); + let session_id = state.create_session("main"); + + let first_buffer = state.create_buffer("one", vec!["/bin/sh".to_owned()], None); + state + .add_root_tab_from_buffer(session_id, "one", first_buffer) + .expect("add initial tab"); + let first_leaf = state + .session(session_id) + .expect("session exists") + .focused_leaf + .expect("initial tab focuses first leaf"); + + let second_buffer = state.create_buffer("two", vec!["/bin/sh".to_owned()], None); + let outer_split = state + .split_leaf_with_new_buffer(first_leaf, SplitDirection::Vertical, second_buffer) + .expect("split leaf"); + let second_leaf = state + .session(session_id) + .expect("session exists") + .focused_leaf + .expect("split focuses second leaf"); + + let tabs_id = state + .wrap_node_in_tabs(second_leaf, "nested") + .expect("wrap leaf in tabs"); + + let root_child = root_tabs(&state, session_id).tabs[0].child; + assert_eq!(root_child, outer_split); + + let outer = split_node(&state, outer_split); + assert_eq!(outer.children, vec![first_leaf, tabs_id]); + + let wrapped = tabs_node(&state, tabs_id); + assert_eq!(wrapped.active, 0); + assert_eq!(wrapped.tabs.len(), 1); + assert_eq!(wrapped.tabs[0].title, "nested"); + assert_eq!(wrapped.tabs[0].child, second_leaf); + assert_eq!( + state + .session(session_id) + .expect("session exists") + .focused_leaf, + Some(second_leaf) + ); + assert_eq!( + state + .visible_session_leaves(session_id) + .expect("visible leaves"), + vec![first_leaf, second_leaf] + ); + + state.validate().expect("wrapped tabs remain valid"); +} + +#[test] +fn nested_tabs_restore_focus_and_collapse_when_last_sibling_closes() { + let mut state = ServerState::new(); + let session_id = state.create_session("main"); + + let first_buffer = state.create_buffer("one", vec!["/bin/sh".to_owned()], None); + state + .add_root_tab_from_buffer(session_id, "one", first_buffer) + .expect("add initial tab"); + let first_leaf = state + .session(session_id) + .expect("session exists") + .focused_leaf + .expect("initial tab focuses first leaf"); + + let second_buffer = state.create_buffer("two", vec!["/bin/sh".to_owned()], None); + let outer_split = state + .split_leaf_with_new_buffer(first_leaf, SplitDirection::Horizontal, second_buffer) + .expect("split leaf"); + let second_leaf = state + .session(session_id) + .expect("session exists") + .focused_leaf + .expect("split focuses second leaf"); + + let tabs_id = state + .wrap_node_in_tabs(second_leaf, "base") + .expect("wrap leaf in tabs"); + + let fourth_buffer = state.create_buffer("four", vec!["/bin/sh".to_owned()], None); + let inner_split = state + .split_leaf_with_new_buffer(second_leaf, SplitDirection::Vertical, fourth_buffer) + .expect("split first nested tab leaf"); + let fourth_leaf = state + .session(session_id) + .expect("session exists") + .focused_leaf + .expect("split focuses fourth leaf"); + + let third_buffer = state.create_buffer("three", vec!["/bin/sh".to_owned()], None); + let nested_index = state + .add_tab_from_buffer(tabs_id, "other", third_buffer) + .expect("add second nested tab"); + assert_eq!(nested_index, 1); + let third_leaf = state + .session(session_id) + .expect("session exists") + .focused_leaf + .expect("new nested tab focuses new leaf"); + + state + .switch_tab(tabs_id, 0) + .expect("switch back to first nested tab"); + assert_eq!( + state + .session(session_id) + .expect("session exists") + .focused_leaf, + Some(fourth_leaf) + ); + assert_eq!( + state + .visible_session_leaves(session_id) + .expect("visible leaves"), + vec![first_leaf, second_leaf, fourth_leaf] + ); + + state + .switch_tab(tabs_id, 1) + .expect("switch to second nested tab"); + assert_eq!( + state + .session(session_id) + .expect("session exists") + .focused_leaf, + Some(third_leaf) + ); + assert_eq!( + state + .visible_session_leaves(session_id) + .expect("visible leaves"), + vec![first_leaf, third_leaf] + ); + + state + .close_node(third_leaf) + .expect("close second nested tab"); + + let outer = split_node(&state, outer_split); + assert_eq!(outer.children[1], inner_split); + assert_eq!( + state + .session(session_id) + .expect("session exists") + .focused_leaf, + Some(fourth_leaf) + ); + assert!(matches!( + &state + .buffer(third_buffer) + .expect("buffer exists") + .attachment, + BufferAttachment::Detached + )); + assert_eq!( + state + .visible_session_leaves(session_id) + .expect("visible leaves"), + vec![first_leaf, second_leaf, fourth_leaf] + ); + + state + .validate() + .expect("nested tab close normalizes correctly"); +} diff --git a/crates/mux-server/tests/ping_server.rs b/crates/embers-server/tests/ping_server.rs similarity index 83% rename from crates/mux-server/tests/ping_server.rs rename to crates/embers-server/tests/ping_server.rs index 0846f20..3f3766e 100644 --- a/crates/mux-server/tests/ping_server.rs +++ b/crates/embers-server/tests/ping_server.rs @@ -1,6 +1,6 @@ -use mux_core::{RequestId, init_test_tracing}; -use mux_protocol::{ClientMessage, PingRequest, ProtocolClient, ServerResponse}; -use mux_server::{Server, ServerConfig}; +use embers_core::{RequestId, init_test_tracing}; +use embers_protocol::{ClientMessage, PingRequest, ProtocolClient, ServerResponse}; +use embers_server::{Server, ServerConfig}; use tempfile::tempdir; #[tokio::test] diff --git a/crates/embers-server/tests/session_root_tabs.rs b/crates/embers-server/tests/session_root_tabs.rs new file mode 100644 index 0000000..7b5900f --- /dev/null +++ b/crates/embers-server/tests/session_root_tabs.rs @@ -0,0 +1,183 @@ +use embers_core::SessionId; +use embers_server::{BufferAttachment, Node, ServerState, TabsNode}; + +fn root_tabs(state: &ServerState, session_id: SessionId) -> TabsNode { + let root_id = state.root_tabs(session_id).expect("session has root tabs"); + match state.node(root_id).expect("root node exists") { + Node::Tabs(tabs) => tabs.clone(), + other => panic!("expected root tabs node, got {other:?}"), + } +} + +#[test] +fn session_creation_starts_with_empty_root_tabs() { + let mut state = ServerState::new(); + + let session_id = state.create_session("main"); + let session = state.session(session_id).expect("session exists"); + let tabs = root_tabs(&state, session_id); + + assert_eq!(session.root_node, tabs.id); + assert!(tabs.tabs.is_empty()); + assert_eq!(tabs.active, 0); + assert_eq!(session.focused_leaf, None); + + state.validate().expect("new session remains valid"); +} + +#[test] +fn root_tabs_can_be_added_from_buffer_and_subtree_and_renamed() { + let mut state = ServerState::new(); + let session_id = state.create_session("main"); + + let first_buffer = state.create_buffer("shell", vec!["/bin/sh".to_owned()], None); + let first_index = state + .add_root_tab_from_buffer(session_id, "shell", first_buffer) + .expect("add root tab from buffer"); + assert_eq!(first_index, 0); + + let second_buffer = state.create_buffer("logs", vec!["/bin/sh".to_owned()], None); + let second_leaf = state + .create_buffer_view(session_id, second_buffer) + .expect("create detached subtree leaf"); + let second_index = state + .add_root_tab_from_subtree(session_id, "logs", second_leaf) + .expect("add root tab from subtree"); + assert_eq!(second_index, 1); + + state + .rename_root_tab(session_id, 0, "primary") + .expect("rename root tab"); + + let tabs = root_tabs(&state, session_id); + let first_view = match &state + .buffer(first_buffer) + .expect("buffer exists") + .attachment + { + BufferAttachment::Attached(node_id) => *node_id, + BufferAttachment::Detached => panic!("buffer should be attached"), + }; + assert_eq!(tabs.active, 1); + assert_eq!(tabs.tabs.len(), 2); + assert_eq!(tabs.tabs[0].title, "primary"); + assert_eq!(tabs.tabs[0].child, first_view); + assert_eq!(tabs.tabs[1].title, "logs"); + assert_eq!(tabs.tabs[1].child, second_leaf); + + state.validate().expect("root tab insertion stays valid"); +} + +#[test] +fn switching_root_tabs_restores_previous_focus() { + let mut state = ServerState::new(); + let session_id = state.create_session("main"); + + let first_buffer = state.create_buffer("one", vec!["/bin/sh".to_owned()], None); + state + .add_root_tab_from_buffer(session_id, "one", first_buffer) + .expect("add first tab"); + let first_leaf = state + .session(session_id) + .expect("session exists") + .focused_leaf + .expect("first tab focuses first leaf"); + + let second_buffer = state.create_buffer("two", vec!["/bin/sh".to_owned()], None); + state + .add_root_tab_from_buffer(session_id, "two", second_buffer) + .expect("add second tab"); + let second_leaf = state + .session(session_id) + .expect("session exists") + .focused_leaf + .expect("second tab focuses second leaf"); + + state + .select_root_tab(session_id, 0) + .expect("switch back to first tab"); + assert_eq!( + state + .session(session_id) + .expect("session exists") + .focused_leaf, + Some(first_leaf) + ); + + state + .select_root_tab(session_id, 1) + .expect("switch back to second tab"); + assert_eq!( + state + .session(session_id) + .expect("session exists") + .focused_leaf, + Some(second_leaf) + ); + + state.validate().expect("focus switching stays valid"); +} + +#[test] +fn closing_root_tabs_can_collapse_root_and_closing_root_resets_to_empty_tabs() { + let mut state = ServerState::new(); + let session_id = state.create_session("main"); + + let first_buffer = state.create_buffer("one", vec!["/bin/sh".to_owned()], None); + state + .add_root_tab_from_buffer(session_id, "one", first_buffer) + .expect("add first tab"); + + let second_buffer = state.create_buffer("two", vec!["/bin/sh".to_owned()], None); + state + .add_root_tab_from_buffer(session_id, "two", second_buffer) + .expect("add second tab"); + + state + .close_root_tab(session_id, 1) + .expect("close active second tab"); + assert!(matches!( + &state + .buffer(second_buffer) + .expect("buffer exists") + .attachment, + BufferAttachment::Detached + )); + let collapsed_root = state.session(session_id).expect("session exists").root_node; + let first_attachment = match &state + .buffer(first_buffer) + .expect("buffer exists") + .attachment + { + BufferAttachment::Attached(node_id) => *node_id, + BufferAttachment::Detached => panic!("buffer should remain attached"), + }; + assert_eq!(collapsed_root, first_attachment); + assert_eq!( + state.node_parent(collapsed_root).expect("root parent"), + None + ); + + state + .close_node(collapsed_root) + .expect("close collapsed root"); + let tabs = root_tabs(&state, session_id); + assert!(tabs.tabs.is_empty()); + assert_eq!(tabs.active, 0); + assert!(matches!( + &state + .buffer(first_buffer) + .expect("buffer exists") + .attachment, + BufferAttachment::Detached + )); + assert_eq!( + state + .session(session_id) + .expect("session exists") + .focused_leaf, + None + ); + + state.validate().expect("empty root tabs remain valid"); +} diff --git a/crates/embers-server/tests/split_layout.rs b/crates/embers-server/tests/split_layout.rs new file mode 100644 index 0000000..0eb2781 --- /dev/null +++ b/crates/embers-server/tests/split_layout.rs @@ -0,0 +1,166 @@ +use embers_core::{SessionId, SplitDirection}; +use embers_server::{BufferAttachment, Node, ServerState, SplitNode, TabsNode}; + +fn root_tabs(state: &ServerState, session_id: SessionId) -> TabsNode { + let root_id = state.root_tabs(session_id).expect("session has root tabs"); + match state.node(root_id).expect("root node exists") { + Node::Tabs(tabs) => tabs.clone(), + other => panic!("expected root tabs node, got {other:?}"), + } +} + +fn split_node(state: &ServerState, node_id: embers_core::NodeId) -> SplitNode { + match state.node(node_id).expect("split node exists") { + Node::Split(split) => split.clone(), + other => panic!("expected split node, got {other:?}"), + } +} + +#[test] +fn repeated_nested_splits_preserve_leaf_order_and_focus() { + let mut state = ServerState::new(); + let session_id = state.create_session("main"); + + let first_buffer = state.create_buffer("one", vec!["/bin/sh".to_owned()], None); + state + .add_root_tab_from_buffer(session_id, "one", first_buffer) + .expect("add initial tab"); + let first_leaf = state + .session(session_id) + .expect("session exists") + .focused_leaf + .expect("initial tab focuses first leaf"); + + let second_buffer = state.create_buffer("two", vec!["/bin/sh".to_owned()], None); + let outer_split = state + .split_leaf_with_new_buffer(first_leaf, SplitDirection::Vertical, second_buffer) + .expect("split first leaf"); + let second_leaf = state + .session(session_id) + .expect("session exists") + .focused_leaf + .expect("split focuses new leaf"); + + let third_buffer = state.create_buffer("three", vec!["/bin/sh".to_owned()], None); + let inner_split = state + .split_leaf_with_new_buffer(second_leaf, SplitDirection::Horizontal, third_buffer) + .expect("split nested leaf"); + let third_leaf = state + .session(session_id) + .expect("session exists") + .focused_leaf + .expect("nested split focuses newest leaf"); + + let tabs = root_tabs(&state, session_id); + assert_eq!(tabs.tabs.len(), 1); + assert_eq!(tabs.tabs[0].child, outer_split); + + let outer = split_node(&state, outer_split); + assert_eq!(outer.direction, SplitDirection::Vertical); + assert_eq!(outer.children, vec![first_leaf, inner_split]); + assert_eq!(outer.sizes, vec![1, 1]); + + let inner = split_node(&state, inner_split); + assert_eq!(inner.direction, SplitDirection::Horizontal); + assert_eq!(inner.children, vec![second_leaf, third_leaf]); + assert_eq!(inner.sizes, vec![1, 1]); + + assert_eq!( + state + .visible_session_leaves(session_id) + .expect("visible leaves"), + vec![first_leaf, second_leaf, third_leaf] + ); + + state.validate().expect("nested splits remain valid"); +} + +#[test] +fn resize_updates_split_weights_and_rejects_invalid_sizes() { + let mut state = ServerState::new(); + let session_id = state.create_session("main"); + + let first_buffer = state.create_buffer("one", vec!["/bin/sh".to_owned()], None); + state + .add_root_tab_from_buffer(session_id, "one", first_buffer) + .expect("add initial tab"); + let first_leaf = state + .session(session_id) + .expect("session exists") + .focused_leaf + .expect("initial tab focuses first leaf"); + + let second_buffer = state.create_buffer("two", vec!["/bin/sh".to_owned()], None); + let split_id = state + .split_leaf_with_new_buffer(first_leaf, SplitDirection::Vertical, second_buffer) + .expect("split leaf"); + + state + .resize_split_children(split_id, vec![3, 2]) + .expect("resize split"); + assert_eq!(split_node(&state, split_id).sizes, vec![3, 2]); + + assert!(state.resize_split_children(split_id, vec![5]).is_err()); + assert!(state.resize_split_children(split_id, vec![5, 0]).is_err()); + + state.validate().expect("resized split remains valid"); +} + +#[test] +fn closing_leaf_normalizes_split_and_detaches_buffer() { + let mut state = ServerState::new(); + let session_id = state.create_session("main"); + + let first_buffer = state.create_buffer("one", vec!["/bin/sh".to_owned()], None); + state + .add_root_tab_from_buffer(session_id, "one", first_buffer) + .expect("add initial tab"); + let first_leaf = state + .session(session_id) + .expect("session exists") + .focused_leaf + .expect("initial tab focuses first leaf"); + + let second_buffer = state.create_buffer("two", vec!["/bin/sh".to_owned()], None); + state + .split_leaf_with_new_buffer(first_leaf, SplitDirection::Horizontal, second_buffer) + .expect("split leaf"); + let second_leaf = state + .session(session_id) + .expect("session exists") + .focused_leaf + .expect("split focuses new leaf"); + + state.close_node(second_leaf).expect("close focused leaf"); + + assert_eq!( + state.session(session_id).expect("session exists").root_node, + first_leaf + ); + assert_eq!( + state.node_parent(first_leaf).expect("first leaf parent"), + None + ); + assert_eq!( + state + .session(session_id) + .expect("session exists") + .focused_leaf, + Some(first_leaf) + ); + assert!(matches!( + &state + .buffer(second_buffer) + .expect("buffer exists") + .attachment, + BufferAttachment::Detached + )); + assert_eq!( + state + .visible_session_leaves(session_id) + .expect("visible leaves"), + vec![first_leaf] + ); + + state.validate().expect("close normalizes split"); +} diff --git a/crates/mux-test-support/Cargo.toml b/crates/embers-test-support/Cargo.toml similarity index 58% rename from crates/mux-test-support/Cargo.toml rename to crates/embers-test-support/Cargo.toml index 1ef673a..478f749 100644 --- a/crates/mux-test-support/Cargo.toml +++ b/crates/embers-test-support/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "mux-test-support" +name = "embers-test-support" edition.workspace = true license.workspace = true rust-version.workspace = true @@ -7,9 +7,9 @@ version.workspace = true [dependencies] assert_cmd.workspace = true -mux-core = { path = "../mux-core" } -mux-protocol = { path = "../mux-protocol" } -mux-server = { path = "../mux-server" } +embers-core = { path = "../embers-core" } +embers-protocol = { path = "../embers-protocol" } +embers-server = { path = "../embers-server" } portable-pty.workspace = true tempfile.workspace = true tokio.workspace = true diff --git a/crates/mux-test-support/src/cli.rs b/crates/embers-test-support/src/cli.rs similarity index 60% rename from crates/mux-test-support/src/cli.rs rename to crates/embers-test-support/src/cli.rs index 3ccac3f..f60d72a 100644 --- a/crates/mux-test-support/src/cli.rs +++ b/crates/embers-test-support/src/cli.rs @@ -1,4 +1,10 @@ +use std::path::PathBuf; + pub fn cargo_bin(name: &str) -> assert_cmd::Command { assert_cmd::Command::cargo_bin(name) .unwrap_or_else(|error| panic!("failed to load binary {name}: {error}")) } + +pub fn cargo_bin_path(name: &str) -> PathBuf { + assert_cmd::cargo::cargo_bin(name) +} diff --git a/crates/mux-test-support/src/lib.rs b/crates/embers-test-support/src/lib.rs similarity index 75% rename from crates/mux-test-support/src/lib.rs rename to crates/embers-test-support/src/lib.rs index d804613..dab8150 100644 --- a/crates/mux-test-support/src/lib.rs +++ b/crates/embers-test-support/src/lib.rs @@ -3,7 +3,7 @@ mod protocol; mod pty; mod server; -pub use cli::cargo_bin; +pub use cli::{cargo_bin, cargo_bin_path}; pub use protocol::TestConnection; pub use pty::PtyHarness; pub use server::TestServer; diff --git a/crates/embers-test-support/src/protocol.rs b/crates/embers-test-support/src/protocol.rs new file mode 100644 index 0000000..b43e7ef --- /dev/null +++ b/crates/embers-test-support/src/protocol.rs @@ -0,0 +1,231 @@ +use std::path::Path; +use std::time::Duration; + +use embers_core::{BufferId, MuxError, Result, SessionId, new_request_id}; +use embers_protocol::{ + BufferRequest, ClientMessage, PingRequest, ProtocolClient, ScrollbackSliceResponse, + ServerEnvelope, ServerEvent, ServerResponse, SessionRequest, SessionSnapshot, SnapshotResponse, + SubscribeRequest, UnsubscribeRequest, VisibleSnapshotResponse, +}; + +#[derive(Debug)] +pub struct TestConnection { + client: ProtocolClient, +} + +impl TestConnection { + pub async fn connect(path: impl AsRef) -> Result { + let client = ProtocolClient::connect(path) + .await + .map_err(|error| MuxError::transport(error.to_string()))?; + Ok(Self { client }) + } + + pub async fn send(&mut self, message: &ClientMessage) -> Result<()> { + self.client + .send(message) + .await + .map_err(|error| MuxError::transport(error.to_string())) + } + + pub async fn recv(&mut self) -> Result> { + self.client + .recv() + .await + .map_err(|error| MuxError::transport(error.to_string())) + } + + pub async fn request(&mut self, message: &ClientMessage) -> Result { + self.client + .request(message) + .await + .map_err(|error| MuxError::transport(error.to_string())) + } + + pub async fn recv_event(&mut self) -> Result { + match self.recv().await? { + Some(ServerEnvelope::Event(event)) => Ok(event), + Some(ServerEnvelope::Response(response)) => Err(MuxError::protocol(format!( + "expected event, got response: {response:?}" + ))), + None => Err(MuxError::transport( + "connection closed while waiting for an event", + )), + } + } + + pub async fn wait_for_event( + &mut self, + timeout: Duration, + mut predicate: F, + ) -> Result + where + F: FnMut(&ServerEvent) -> bool, + { + tokio::time::timeout(timeout, async { + loop { + let event = self.recv_event().await?; + if predicate(&event) { + return Ok(event); + } + } + }) + .await + .map_err(|_| MuxError::timeout(format!("timed out waiting for event after {timeout:?}")))? + } + + pub async fn subscribe(&mut self, session_id: Option) -> Result { + let response = self + .request(&ClientMessage::Subscribe(SubscribeRequest { + request_id: new_request_id(), + session_id, + })) + .await?; + + match response { + ServerResponse::SubscriptionAck(ack) => Ok(ack.subscription_id), + ServerResponse::Error(error) => Err(error.error.into()), + other => Err(MuxError::protocol(format!( + "unexpected response to subscribe request: {other:?}" + ))), + } + } + + pub async fn unsubscribe(&mut self, subscription_id: u64) -> Result<()> { + let response = self + .request(&ClientMessage::Unsubscribe(UnsubscribeRequest { + request_id: new_request_id(), + subscription_id, + })) + .await?; + + match response { + ServerResponse::Ok(_) => Ok(()), + ServerResponse::Error(error) => Err(error.error.into()), + other => Err(MuxError::protocol(format!( + "unexpected response to unsubscribe request: {other:?}" + ))), + } + } + + pub async fn ping(&mut self, payload: impl Into) -> Result { + let response = self + .request(&ClientMessage::Ping(PingRequest { + request_id: new_request_id(), + payload: payload.into(), + })) + .await?; + + match response { + ServerResponse::Pong(pong) => Ok(pong.payload), + ServerResponse::Error(error) => Err(error.error.into()), + other => Err(MuxError::protocol(format!( + "unexpected response to ping request: {other:?}" + ))), + } + } + + pub async fn session_snapshot(&mut self, session_id: SessionId) -> Result { + let response = self + .request(&ClientMessage::Session(SessionRequest::Get { + request_id: new_request_id(), + session_id, + })) + .await?; + + match response { + ServerResponse::SessionSnapshot(response) => Ok(response.snapshot), + ServerResponse::Error(error) => Err(error.error.into()), + other => Err(MuxError::protocol(format!( + "unexpected response to session snapshot request: {other:?}" + ))), + } + } + + pub async fn capture_buffer(&mut self, buffer_id: BufferId) -> Result { + let response = self + .request(&ClientMessage::Buffer(BufferRequest::Capture { + request_id: new_request_id(), + buffer_id, + })) + .await?; + + match response { + ServerResponse::Snapshot(snapshot) => Ok(snapshot), + ServerResponse::Error(error) => Err(error.error.into()), + other => Err(MuxError::protocol(format!( + "unexpected response to capture request: {other:?}" + ))), + } + } + + pub async fn capture_visible_buffer( + &mut self, + buffer_id: BufferId, + ) -> Result { + let response = self + .request(&ClientMessage::Buffer(BufferRequest::CaptureVisible { + request_id: new_request_id(), + buffer_id, + })) + .await?; + + match response { + ServerResponse::VisibleSnapshot(snapshot) => Ok(snapshot), + ServerResponse::Error(error) => Err(error.error.into()), + other => Err(MuxError::protocol(format!( + "unexpected response to visible capture request: {other:?}" + ))), + } + } + + pub async fn capture_scrollback_slice( + &mut self, + buffer_id: BufferId, + start_line: u64, + line_count: u32, + ) -> Result { + let response = self + .request(&ClientMessage::Buffer(BufferRequest::ScrollbackSlice { + request_id: new_request_id(), + buffer_id, + start_line, + line_count, + })) + .await?; + + match response { + ServerResponse::ScrollbackSlice(snapshot) => Ok(snapshot), + ServerResponse::Error(error) => Err(error.error.into()), + other => Err(MuxError::protocol(format!( + "unexpected response to scrollback slice request: {other:?}" + ))), + } + } + + pub async fn wait_for_capture_contains( + &mut self, + buffer_id: BufferId, + needle: &str, + timeout: Duration, + ) -> Result { + let deadline = tokio::time::Instant::now() + timeout; + + loop { + let snapshot = self.capture_buffer(buffer_id).await?; + let capture = snapshot.lines.join("\n"); + if capture.contains(needle) { + return Ok(snapshot); + } + + if tokio::time::Instant::now() >= deadline { + return Err(MuxError::timeout(format!( + "timed out waiting for buffer {buffer_id} to contain {needle:?}; last capture: {:?}", + capture + ))); + } + + tokio::time::sleep(Duration::from_millis(50)).await; + } + } +} diff --git a/crates/mux-test-support/src/pty.rs b/crates/embers-test-support/src/pty.rs similarity index 72% rename from crates/mux-test-support/src/pty.rs rename to crates/embers-test-support/src/pty.rs index 751477c..6093b45 100644 --- a/crates/mux-test-support/src/pty.rs +++ b/crates/embers-test-support/src/pty.rs @@ -3,11 +3,13 @@ use std::sync::mpsc::{self, Receiver}; use std::thread; use std::time::{Duration, Instant}; -use mux_core::{MuxError, PtySize, Result}; +use embers_core::{MuxError, PtySize, Result}; use portable_pty::{ CommandBuilder, MasterPty, NativePtySystem, PtySize as PortableSize, PtySystem, }; +const OUTPUT_TAIL_CHARS: usize = 2000; + pub struct PtyHarness { master: Box, child: Box, @@ -95,7 +97,34 @@ impl PtyHarness { } Err(MuxError::timeout(format!( - "timed out waiting for output containing {needle:?}; got {output:?}" + "timed out waiting for output containing {needle:?}; recent output: {:?}", + tail_excerpt(&output) + ))) + } + + pub fn wait_for_quiet(&mut self, quiet_for: Duration, timeout: Duration) -> Result { + let start = Instant::now(); + let mut output = String::new(); + let mut last_activity = Instant::now(); + + while start.elapsed() < timeout { + match self.output_rx.recv_timeout(Duration::from_millis(50)) { + Ok(chunk) => { + output.push_str(&String::from_utf8_lossy(&chunk)); + last_activity = Instant::now(); + } + Err(mpsc::RecvTimeoutError::Timeout) => { + if last_activity.elapsed() >= quiet_for { + return Ok(output); + } + } + Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(output), + } + } + + Err(MuxError::timeout(format!( + "timed out waiting for quiet PTY output; recent output: {:?}", + tail_excerpt(&output) ))) } @@ -125,3 +154,16 @@ impl Drop for PtyHarness { } } } + +fn tail_excerpt(output: &str) -> String { + let total = output.chars().count(); + if total <= OUTPUT_TAIL_CHARS { + return output.to_owned(); + } + + let tail: String = output + .chars() + .skip(total.saturating_sub(OUTPUT_TAIL_CHARS)) + .collect(); + format!("...[truncated {} chars]{}", total - OUTPUT_TAIL_CHARS, tail) +} diff --git a/crates/mux-test-support/src/server.rs b/crates/embers-test-support/src/server.rs similarity index 89% rename from crates/mux-test-support/src/server.rs rename to crates/embers-test-support/src/server.rs index 0948541..9935586 100644 --- a/crates/mux-test-support/src/server.rs +++ b/crates/embers-test-support/src/server.rs @@ -1,7 +1,7 @@ use std::path::Path; -use mux_core::{Result, init_test_tracing}; -use mux_server::{Server, ServerConfig, ServerHandle}; +use embers_core::{Result, init_test_tracing}; +use embers_server::{Server, ServerConfig, ServerHandle}; use tempfile::TempDir; #[derive(Debug)] diff --git a/crates/embers-test-support/tests/buffer_runtime.rs b/crates/embers-test-support/tests/buffer_runtime.rs new file mode 100644 index 0000000..0b2a688 --- /dev/null +++ b/crates/embers-test-support/tests/buffer_runtime.rs @@ -0,0 +1,344 @@ +use std::time::{Duration, Instant}; + +use embers_core::{PtySize, new_request_id}; +use embers_protocol::{ + BufferRecord, BufferRecordState, BufferRequest, ClientMessage, InputRequest, OkResponse, + ServerResponse, SnapshotResponse, +}; +use embers_test_support::{TestConnection, TestServer}; +use tokio::time::sleep; + +async fn create_buffer(connection: &mut TestConnection, command: &[&str]) -> BufferRecord { + let response = connection + .request(&ClientMessage::Buffer(BufferRequest::Create { + request_id: new_request_id(), + title: Some("buffer".to_owned()), + command: command.iter().map(|part| (*part).to_owned()).collect(), + cwd: None, + env: Default::default(), + })) + .await + .expect("create buffer request succeeds"); + + match response { + ServerResponse::Buffer(buffer) => buffer.buffer, + other => panic!("expected buffer response, got {other:?}"), + } +} + +async fn get_buffer( + connection: &mut TestConnection, + buffer_id: embers_core::BufferId, +) -> BufferRecord { + let response = connection + .request(&ClientMessage::Buffer(BufferRequest::Get { + request_id: new_request_id(), + buffer_id, + })) + .await + .expect("get buffer request succeeds"); + + match response { + ServerResponse::Buffer(buffer) => buffer.buffer, + other => panic!("expected buffer response, got {other:?}"), + } +} + +async fn list_detached_buffers(connection: &mut TestConnection) -> Vec { + let response = connection + .request(&ClientMessage::Buffer(BufferRequest::List { + request_id: new_request_id(), + session_id: None, + attached_only: false, + detached_only: true, + })) + .await + .expect("list buffers request succeeds"); + + match response { + ServerResponse::Buffers(buffers) => buffers.buffers, + other => panic!("expected buffers response, got {other:?}"), + } +} + +async fn capture_buffer( + connection: &mut TestConnection, + buffer_id: embers_core::BufferId, +) -> SnapshotResponse { + let response = connection + .request(&ClientMessage::Buffer(BufferRequest::Capture { + request_id: new_request_id(), + buffer_id, + })) + .await + .expect("capture request succeeds"); + + match response { + ServerResponse::Snapshot(snapshot) => snapshot, + other => panic!("expected snapshot response, got {other:?}"), + } +} + +async fn send_input( + connection: &mut TestConnection, + buffer_id: embers_core::BufferId, + input: &str, +) { + let response = connection + .request(&ClientMessage::Input(InputRequest::Send { + request_id: new_request_id(), + buffer_id, + bytes: input.as_bytes().to_vec(), + })) + .await + .expect("send input request succeeds"); + + assert!(matches!(response, ServerResponse::Ok(OkResponse { .. }))); +} + +async fn resize_buffer( + connection: &mut TestConnection, + buffer_id: embers_core::BufferId, + cols: u16, + rows: u16, +) { + let response = connection + .request(&ClientMessage::Input(InputRequest::Resize { + request_id: new_request_id(), + buffer_id, + cols, + rows, + })) + .await + .expect("resize request succeeds"); + + assert!(matches!(response, ServerResponse::Ok(OkResponse { .. }))); +} + +async fn detach_buffer(connection: &mut TestConnection, buffer_id: embers_core::BufferId) { + let response = connection + .request(&ClientMessage::Buffer(BufferRequest::Detach { + request_id: new_request_id(), + buffer_id, + })) + .await + .expect("detach request succeeds"); + + assert!(matches!(response, ServerResponse::Ok(OkResponse { .. }))); +} + +async fn kill_buffer(connection: &mut TestConnection, buffer_id: embers_core::BufferId) { + let response = connection + .request(&ClientMessage::Buffer(BufferRequest::Kill { + request_id: new_request_id(), + buffer_id, + force: true, + })) + .await + .expect("kill request succeeds"); + + assert!(matches!(response, ServerResponse::Ok(OkResponse { .. }))); +} + +async fn wait_for_capture_contains( + connection: &mut TestConnection, + buffer_id: embers_core::BufferId, + needle: &str, +) -> SnapshotResponse { + let deadline = Instant::now() + Duration::from_secs(3); + loop { + let snapshot = capture_buffer(connection, buffer_id).await; + let text = snapshot.lines.join("\n"); + if text.contains(needle) { + return snapshot; + } + if Instant::now() >= deadline { + panic!("timed out waiting for capture containing {needle:?}; got {text:?}"); + } + sleep(Duration::from_millis(25)).await; + } +} + +async fn wait_for_exit( + connection: &mut TestConnection, + buffer_id: embers_core::BufferId, +) -> BufferRecord { + let deadline = Instant::now() + Duration::from_secs(3); + loop { + let buffer = get_buffer(connection, buffer_id).await; + if matches!(buffer.state, BufferRecordState::Exited) { + return buffer; + } + if Instant::now() >= deadline { + panic!("timed out waiting for buffer {buffer_id} to exit"); + } + sleep(Duration::from_millis(25)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn detached_buffers_accept_input_and_keep_running_after_detach_requests() { + let server = TestServer::start().await.expect("start server"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("connect protocol client"); + + let buffer = create_buffer( + &mut connection, + &[ + "/bin/sh", + "-lc", + "printf 'ready\\n'; while IFS= read -r line; do printf 'seen:%s\\n' \"$line\"; done", + ], + ) + .await; + assert_eq!(buffer.state, BufferRecordState::Running); + assert_eq!(buffer.attachment_node_id, None); + + let detached = list_detached_buffers(&mut connection).await; + assert!(detached.iter().any(|candidate| candidate.id == buffer.id)); + + wait_for_capture_contains(&mut connection, buffer.id, "ready").await; + send_input(&mut connection, buffer.id, "hello\n").await; + wait_for_capture_contains(&mut connection, buffer.id, "seen:hello").await; + + detach_buffer(&mut connection, buffer.id).await; + let detached_buffer = get_buffer(&mut connection, buffer.id).await; + assert_eq!(detached_buffer.attachment_node_id, None); + assert_eq!(detached_buffer.state, BufferRecordState::Running); + + send_input(&mut connection, buffer.id, "again\n").await; + wait_for_capture_contains(&mut connection, buffer.id, "seen:again").await; + + server.shutdown().await.expect("shutdown server"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resize_and_kill_requests_update_buffer_state_and_preserve_capture() { + let server = TestServer::start().await.expect("start server"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("connect protocol client"); + + let buffer = create_buffer( + &mut connection, + &["/bin/sh", "-lc", "printf 'alive\\n'; cat"], + ) + .await; + wait_for_capture_contains(&mut connection, buffer.id, "alive").await; + + resize_buffer(&mut connection, buffer.id, 100, 30).await; + let resized = get_buffer(&mut connection, buffer.id).await; + assert_eq!(resized.pty_size, PtySize::new(100, 30)); + let resized_snapshot = capture_buffer(&mut connection, buffer.id).await; + assert_eq!(resized_snapshot.size, PtySize::new(100, 30)); + + kill_buffer(&mut connection, buffer.id).await; + let exited = wait_for_exit(&mut connection, buffer.id).await; + assert_eq!(exited.state, BufferRecordState::Exited); + + let captured = capture_buffer(&mut connection, buffer.id).await; + assert!(captured.lines.join("\n").contains("alive")); + + server.shutdown().await.expect("shutdown server"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn capture_preserves_scrollback_for_long_output() { + let server = TestServer::start().await.expect("start server"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("connect protocol client"); + + let buffer = create_buffer( + &mut connection, + &[ + "/bin/sh", + "-lc", + "i=1; while [ $i -le 40 ]; do printf 'line-%02d\\n' \"$i\"; i=$((i+1)); done", + ], + ) + .await; + + let snapshot = wait_for_capture_contains(&mut connection, buffer.id, "line-40").await; + let text = snapshot.lines.join("\n"); + assert!(text.contains("line-01")); + assert!(text.contains("line-40")); + + server.shutdown().await.expect("shutdown server"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn visible_snapshot_surfaces_terminal_modes_and_cursor_metadata() { + let server = TestServer::start().await.expect("start server"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("connect protocol client"); + + let buffer = create_buffer( + &mut connection, + &[ + "/bin/sh", + "-lc", + "printf '\\033]0;embers\\007\\033[?1049h\\033[?1000h\\033[?1004h\\033[?2004hhello'", + ], + ) + .await; + wait_for_capture_contains(&mut connection, buffer.id, "hello").await; + + let snapshot = connection + .capture_visible_buffer(buffer.id) + .await + .expect("visible capture succeeds"); + let text = snapshot.lines.join("\n"); + assert!(text.contains("hello")); + assert_eq!(snapshot.title.as_deref(), Some("embers")); + assert!(snapshot.alternate_screen); + assert!(snapshot.mouse_reporting); + assert!(snapshot.focus_reporting); + assert!(snapshot.bracketed_paste); + assert!(snapshot.total_lines >= u64::from(snapshot.size.rows)); + assert!(snapshot.cursor.is_some()); + + server.shutdown().await.expect("shutdown server"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn scrollback_slice_returns_history_while_full_capture_stays_available() { + let server = TestServer::start().await.expect("start server"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("connect protocol client"); + + let buffer = create_buffer( + &mut connection, + &[ + "/bin/sh", + "-lc", + "i=1; while [ $i -le 40 ]; do printf 'line-%02d\\n' \"$i\"; i=$((i+1)); done", + ], + ) + .await; + + let captured = wait_for_capture_contains(&mut connection, buffer.id, "line-40").await; + wait_for_exit(&mut connection, buffer.id).await; + let visible = connection + .capture_visible_buffer(buffer.id) + .await + .expect("visible capture succeeds"); + let slice = connection + .capture_scrollback_slice(buffer.id, 0, 3) + .await + .expect("scrollback slice succeeds"); + let expected_prefix = captured.lines.iter().take(3).cloned().collect::>(); + + assert!(captured.lines.join("\n").contains("line-01")); + assert!(captured.lines.join("\n").contains("line-40")); + assert!(visible.total_lines >= 40); + assert!(visible.viewport_top_line > 0); + assert_eq!(slice.lines, expected_prefix); + assert_eq!(slice.start_line, 0); + assert_eq!(slice.total_lines, visible.total_lines); + + server.shutdown().await.expect("shutdown server"); +} diff --git a/crates/embers-test-support/tests/detach_move.rs b/crates/embers-test-support/tests/detach_move.rs new file mode 100644 index 0000000..b67fea2 --- /dev/null +++ b/crates/embers-test-support/tests/detach_move.rs @@ -0,0 +1,303 @@ +use std::time::{Duration, Instant}; + +use embers_core::{SplitDirection, new_request_id}; +use embers_protocol::{ + BufferRecord, BufferRequest, BuffersResponse, ClientMessage, InputRequest, NodeRequest, + ServerResponse, SessionRequest, SessionSnapshot, SessionSnapshotResponse, SnapshotResponse, +}; +use embers_test_support::{TestConnection, TestServer}; +use tokio::time::sleep; + +async fn create_session(connection: &mut TestConnection, name: &str) -> SessionSnapshotResponse { + let response = connection + .request(&ClientMessage::Session(SessionRequest::Create { + request_id: new_request_id(), + name: name.to_owned(), + })) + .await + .expect("create session request succeeds"); + + match response { + ServerResponse::SessionSnapshot(snapshot) => snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + } +} + +async fn get_session( + connection: &mut TestConnection, + session_id: embers_core::SessionId, +) -> SessionSnapshot { + let response = connection + .request(&ClientMessage::Session(SessionRequest::Get { + request_id: new_request_id(), + session_id, + })) + .await + .expect("get session request succeeds"); + + match response { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + } +} + +async fn create_echo_buffer(connection: &mut TestConnection, title: &str) -> BufferRecord { + let response = connection + .request(&ClientMessage::Buffer(BufferRequest::Create { + request_id: new_request_id(), + title: Some(title.to_owned()), + command: vec![ + "/bin/sh".to_owned(), + "-lc".to_owned(), + "printf 'ready\\n'; while IFS= read -r line; do printf 'seen:%s\\n' \"$line\"; done" + .to_owned(), + ], + cwd: None, + env: Default::default(), + })) + .await + .expect("create buffer request succeeds"); + + match response { + ServerResponse::Buffer(buffer) => buffer.buffer, + other => panic!("expected buffer response, got {other:?}"), + } +} + +async fn add_root_tab( + connection: &mut TestConnection, + session_id: embers_core::SessionId, + title: &str, + buffer_id: embers_core::BufferId, +) -> SessionSnapshot { + let response = connection + .request(&ClientMessage::Session(SessionRequest::AddRootTab { + request_id: new_request_id(), + session_id, + title: title.to_owned(), + buffer_id: Some(buffer_id), + child_node_id: None, + })) + .await + .expect("add root tab request succeeds"); + + match response { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + } +} + +async fn capture_buffer( + connection: &mut TestConnection, + buffer_id: embers_core::BufferId, +) -> SnapshotResponse { + let response = connection + .request(&ClientMessage::Buffer(BufferRequest::Capture { + request_id: new_request_id(), + buffer_id, + })) + .await + .expect("capture request succeeds"); + + match response { + ServerResponse::Snapshot(snapshot) => snapshot, + other => panic!("expected snapshot response, got {other:?}"), + } +} + +async fn get_buffer( + connection: &mut TestConnection, + buffer_id: embers_core::BufferId, +) -> BufferRecord { + let response = connection + .request(&ClientMessage::Buffer(BufferRequest::Get { + request_id: new_request_id(), + buffer_id, + })) + .await + .expect("get buffer request succeeds"); + + match response { + ServerResponse::Buffer(buffer) => buffer.buffer, + other => panic!("expected buffer response, got {other:?}"), + } +} + +async fn send_input( + connection: &mut TestConnection, + buffer_id: embers_core::BufferId, + input: &str, +) { + let response = connection + .request(&ClientMessage::Input(InputRequest::Send { + request_id: new_request_id(), + buffer_id, + bytes: input.as_bytes().to_vec(), + })) + .await + .expect("send input request succeeds"); + + assert!(matches!(response, ServerResponse::Ok(_))); +} + +async fn wait_for_capture_contains( + connection: &mut TestConnection, + buffer_id: embers_core::BufferId, + needle: &str, +) -> SnapshotResponse { + let deadline = Instant::now() + Duration::from_secs(3); + loop { + let snapshot = capture_buffer(connection, buffer_id).await; + let text = snapshot.lines.join("\n"); + if text.contains(needle) { + return snapshot; + } + if Instant::now() >= deadline { + panic!("timed out waiting for capture containing {needle:?}; got {text:?}"); + } + sleep(Duration::from_millis(25)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn detach_list_capture_and_reattach_buffer_via_socket() { + let server = TestServer::start().await.expect("start server"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("connect protocol client"); + + let session = create_session(&mut connection, "main").await; + let session_id = session.snapshot.session.id; + + let primary = create_echo_buffer(&mut connection, "primary").await; + add_root_tab(&mut connection, session_id, "primary", primary.id).await; + wait_for_capture_contains(&mut connection, primary.id, "ready").await; + + let detach = connection + .request(&ClientMessage::Buffer(BufferRequest::Detach { + request_id: new_request_id(), + buffer_id: primary.id, + })) + .await + .expect("detach request succeeds"); + assert!(matches!(detach, ServerResponse::Ok(_))); + + let detached = connection + .request(&ClientMessage::Buffer(BufferRequest::List { + request_id: new_request_id(), + session_id: None, + attached_only: false, + detached_only: true, + })) + .await + .expect("list detached buffers request succeeds"); + let detached = match detached { + ServerResponse::Buffers(BuffersResponse { buffers, .. }) => buffers, + other => panic!("expected buffers response, got {other:?}"), + }; + assert!(detached.iter().any(|buffer| buffer.id == primary.id)); + + send_input(&mut connection, primary.id, "detached\n").await; + wait_for_capture_contains(&mut connection, primary.id, "seen:detached").await; + + let replacement = create_echo_buffer(&mut connection, "replacement").await; + let replacement_snapshot = + add_root_tab(&mut connection, session_id, "replacement", replacement.id).await; + let target_leaf = replacement_snapshot + .session + .focused_leaf_id + .expect("replacement tab focuses target leaf"); + + let moved = connection + .request(&ClientMessage::Node(NodeRequest::MoveBufferToNode { + request_id: new_request_id(), + buffer_id: primary.id, + target_leaf_node_id: target_leaf, + })) + .await + .expect("move request succeeds"); + let moved = match moved { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + assert_eq!(moved.session.focused_leaf_id, Some(target_leaf)); + let target_view = moved + .nodes + .iter() + .find(|node| node.id == target_leaf) + .and_then(|node| node.buffer_view.clone()) + .expect("target leaf remains a buffer view"); + assert_eq!(target_view.buffer_id, primary.id); + + server.shutdown().await.expect("shutdown server"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn move_request_replaces_target_leaf_without_killing_buffer() { + let server = TestServer::start().await.expect("start server"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("connect protocol client"); + + let session = create_session(&mut connection, "main").await; + let session_id = session.snapshot.session.id; + + let first = create_echo_buffer(&mut connection, "first").await; + let root_snapshot = add_root_tab(&mut connection, session_id, "first", first.id).await; + let first_leaf = root_snapshot + .session + .focused_leaf_id + .expect("root tab focuses first leaf"); + wait_for_capture_contains(&mut connection, first.id, "ready").await; + + let second = create_echo_buffer(&mut connection, "second").await; + let split = connection + .request(&ClientMessage::Node(NodeRequest::Split { + request_id: new_request_id(), + leaf_node_id: first_leaf, + direction: SplitDirection::Horizontal, + new_buffer_id: second.id, + })) + .await + .expect("split request succeeds"); + let split = match split { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let second_leaf = split + .session + .focused_leaf_id + .expect("split focuses second leaf"); + + let moved = connection + .request(&ClientMessage::Node(NodeRequest::MoveBufferToNode { + request_id: new_request_id(), + buffer_id: first.id, + target_leaf_node_id: second_leaf, + })) + .await + .expect("move request succeeds"); + let moved = match moved { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + assert_eq!(moved.session.focused_leaf_id, Some(second_leaf)); + assert_eq!( + get_buffer(&mut connection, second.id) + .await + .attachment_node_id, + None + ); + + send_input(&mut connection, first.id, "moved\n").await; + wait_for_capture_contains(&mut connection, first.id, "seen:moved").await; + assert_eq!( + get_session(&mut connection, session_id) + .await + .session + .focused_leaf_id, + Some(second_leaf) + ); + + server.shutdown().await.expect("shutdown server"); +} diff --git a/crates/embers-test-support/tests/floating_windows.rs b/crates/embers-test-support/tests/floating_windows.rs new file mode 100644 index 0000000..00d0863 --- /dev/null +++ b/crates/embers-test-support/tests/floating_windows.rs @@ -0,0 +1,276 @@ +use embers_core::{FloatGeometry, new_request_id}; +use embers_protocol::{ + BufferRecord, BufferRequest, ClientMessage, FloatingRequest, NodeRequest, ServerResponse, + SessionRequest, SessionSnapshot, SessionSnapshotResponse, +}; +use embers_test_support::{TestConnection, TestServer}; + +async fn create_session(connection: &mut TestConnection, name: &str) -> SessionSnapshotResponse { + let response = connection + .request(&ClientMessage::Session(SessionRequest::Create { + request_id: new_request_id(), + name: name.to_owned(), + })) + .await + .expect("create session request succeeds"); + + match response { + ServerResponse::SessionSnapshot(snapshot) => snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + } +} + +async fn get_session( + connection: &mut TestConnection, + session_id: embers_core::SessionId, +) -> SessionSnapshot { + let response = connection + .request(&ClientMessage::Session(SessionRequest::Get { + request_id: new_request_id(), + session_id, + })) + .await + .expect("get session request succeeds"); + + match response { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + } +} + +async fn create_buffer(connection: &mut TestConnection, title: &str) -> BufferRecord { + let response = connection + .request(&ClientMessage::Buffer(BufferRequest::Create { + request_id: new_request_id(), + title: Some(title.to_owned()), + command: vec!["/bin/sh".to_owned(), "-lc".to_owned(), "cat".to_owned()], + cwd: None, + env: Default::default(), + })) + .await + .expect("create buffer request succeeds"); + + match response { + ServerResponse::Buffer(buffer) => buffer.buffer, + other => panic!("expected buffer response, got {other:?}"), + } +} + +async fn add_root_tab( + connection: &mut TestConnection, + session_id: embers_core::SessionId, + title: &str, + buffer_id: embers_core::BufferId, +) -> SessionSnapshot { + let response = connection + .request(&ClientMessage::Session(SessionRequest::AddRootTab { + request_id: new_request_id(), + session_id, + title: title.to_owned(), + buffer_id: Some(buffer_id), + child_node_id: None, + })) + .await + .expect("add root tab request succeeds"); + + match response { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + } +} + +async fn get_buffer( + connection: &mut TestConnection, + buffer_id: embers_core::BufferId, +) -> BufferRecord { + let response = connection + .request(&ClientMessage::Buffer(BufferRequest::Get { + request_id: new_request_id(), + buffer_id, + })) + .await + .expect("get buffer request succeeds"); + + match response { + ServerResponse::Buffer(buffer) => buffer.buffer, + other => panic!("expected buffer response, got {other:?}"), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn create_focus_move_and_close_floating_window_via_socket() { + let server = TestServer::start().await.expect("start server"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("connect protocol client"); + + let session = create_session(&mut connection, "main").await; + let session_id = session.snapshot.session.id; + + let root_buffer = create_buffer(&mut connection, "root").await; + let root_snapshot = add_root_tab(&mut connection, session_id, "root", root_buffer.id).await; + let root_leaf = root_snapshot + .session + .focused_leaf_id + .expect("root tab focuses root leaf"); + + let popup_buffer = create_buffer(&mut connection, "popup").await; + let created = connection + .request(&ClientMessage::Floating(FloatingRequest::Create { + request_id: new_request_id(), + session_id, + root_node_id: None, + buffer_id: Some(popup_buffer.id), + geometry: FloatGeometry::new(4, 2, 40, 12), + title: Some("popup".to_owned()), + focus: true, + close_on_empty: true, + })) + .await + .expect("create floating request succeeds"); + let floating = match created { + ServerResponse::Floating(response) => response.floating, + other => panic!("expected floating response, got {other:?}"), + }; + assert!(floating.focused); + assert_eq!(floating.geometry, FloatGeometry::new(4, 2, 40, 12)); + + connection + .request(&ClientMessage::Node(NodeRequest::Focus { + request_id: new_request_id(), + session_id, + node_id: root_leaf, + })) + .await + .expect("focus root leaf request succeeds"); + + let focused = connection + .request(&ClientMessage::Floating(FloatingRequest::Focus { + request_id: new_request_id(), + floating_id: floating.id, + })) + .await + .expect("focus floating request succeeds"); + let focused = match focused { + ServerResponse::Floating(response) => response.floating, + other => panic!("expected floating response, got {other:?}"), + }; + assert!(focused.focused); + + let moved = connection + .request(&ClientMessage::Floating(FloatingRequest::Move { + request_id: new_request_id(), + floating_id: floating.id, + geometry: FloatGeometry::new(10, 6, 60, 18), + })) + .await + .expect("move floating request succeeds"); + let moved = match moved { + ServerResponse::Floating(response) => response.floating, + other => panic!("expected floating response, got {other:?}"), + }; + assert_eq!(moved.geometry, FloatGeometry::new(10, 6, 60, 18)); + + let session_snapshot = get_session(&mut connection, session_id).await; + assert_eq!( + session_snapshot.session.focused_floating_id, + Some(floating.id) + ); + + let closed = connection + .request(&ClientMessage::Floating(FloatingRequest::Close { + request_id: new_request_id(), + floating_id: floating.id, + })) + .await + .expect("close floating request succeeds"); + assert!(matches!(closed, ServerResponse::Ok(_))); + assert_eq!( + get_buffer(&mut connection, popup_buffer.id) + .await + .attachment_node_id, + None + ); + assert!( + get_session(&mut connection, session_id) + .await + .floating + .is_empty() + ); + + server.shutdown().await.expect("shutdown server"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn closing_last_tab_in_floating_tabs_removes_popup() { + let server = TestServer::start().await.expect("start server"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("connect protocol client"); + + let session = create_session(&mut connection, "main").await; + let session_id = session.snapshot.session.id; + + let root_buffer = create_buffer(&mut connection, "root").await; + let root_snapshot = add_root_tab(&mut connection, session_id, "root", root_buffer.id).await; + let root_leaf = root_snapshot + .session + .focused_leaf_id + .expect("root tab focuses root leaf"); + + let popup_buffer = create_buffer(&mut connection, "popup").await; + let created = connection + .request(&ClientMessage::Floating(FloatingRequest::Create { + request_id: new_request_id(), + session_id, + root_node_id: None, + buffer_id: Some(popup_buffer.id), + geometry: FloatGeometry::new(2, 2, 30, 10), + title: Some("popup".to_owned()), + focus: true, + close_on_empty: true, + })) + .await + .expect("create floating request succeeds"); + let floating = match created { + ServerResponse::Floating(response) => response.floating, + other => panic!("expected floating response, got {other:?}"), + }; + let popup_leaf = floating.root_node_id; + + let wrapped = connection + .request(&ClientMessage::Node(NodeRequest::WrapInTabs { + request_id: new_request_id(), + node_id: popup_leaf, + title: "popup".to_owned(), + })) + .await + .expect("wrap floating root request succeeds"); + let wrapped = match wrapped { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + assert_eq!(wrapped.floating.len(), 1); + + let closed = connection + .request(&ClientMessage::Node(NodeRequest::Close { + request_id: new_request_id(), + node_id: popup_leaf, + })) + .await + .expect("close popup leaf request succeeds"); + let closed = match closed { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + assert!(closed.floating.is_empty()); + assert_eq!(closed.session.focused_leaf_id, Some(root_leaf)); + assert_eq!( + get_buffer(&mut connection, popup_buffer.id) + .await + .attachment_node_id, + None + ); + + server.shutdown().await.expect("shutdown server"); +} diff --git a/crates/embers-test-support/tests/nested_tabs.rs b/crates/embers-test-support/tests/nested_tabs.rs new file mode 100644 index 0000000..10ceeb2 --- /dev/null +++ b/crates/embers-test-support/tests/nested_tabs.rs @@ -0,0 +1,333 @@ +use embers_core::{SplitDirection, new_request_id}; +use embers_protocol::{ + BufferRecord, BufferRequest, ClientMessage, NodeRequest, ServerResponse, SessionRequest, + SessionSnapshot, SessionSnapshotResponse, SplitRecord, TabsRecord, +}; +use embers_test_support::{TestConnection, TestServer}; + +async fn create_session(connection: &mut TestConnection, name: &str) -> SessionSnapshotResponse { + let response = connection + .request(&ClientMessage::Session(SessionRequest::Create { + request_id: new_request_id(), + name: name.to_owned(), + })) + .await + .expect("create session request succeeds"); + + match response { + ServerResponse::SessionSnapshot(snapshot) => snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + } +} + +async fn create_buffer(connection: &mut TestConnection, title: &str) -> BufferRecord { + let response = connection + .request(&ClientMessage::Buffer(BufferRequest::Create { + request_id: new_request_id(), + title: Some(title.to_owned()), + command: vec!["/bin/sh".to_owned(), "-lc".to_owned(), "cat".to_owned()], + cwd: None, + env: Default::default(), + })) + .await + .expect("create buffer request succeeds"); + + match response { + ServerResponse::Buffer(buffer) => buffer.buffer, + other => panic!("expected buffer response, got {other:?}"), + } +} + +async fn add_root_tab( + connection: &mut TestConnection, + session_id: embers_core::SessionId, + title: &str, + buffer_id: embers_core::BufferId, +) -> SessionSnapshot { + let response = connection + .request(&ClientMessage::Session(SessionRequest::AddRootTab { + request_id: new_request_id(), + session_id, + title: title.to_owned(), + buffer_id: Some(buffer_id), + child_node_id: None, + })) + .await + .expect("add root tab request succeeds"); + + match response { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + } +} + +async fn get_buffer( + connection: &mut TestConnection, + buffer_id: embers_core::BufferId, +) -> BufferRecord { + let response = connection + .request(&ClientMessage::Buffer(BufferRequest::Get { + request_id: new_request_id(), + buffer_id, + })) + .await + .expect("get buffer request succeeds"); + + match response { + ServerResponse::Buffer(buffer) => buffer.buffer, + other => panic!("expected buffer response, got {other:?}"), + } +} + +fn root_tabs(snapshot: &SessionSnapshot) -> TabsRecord { + snapshot + .nodes + .iter() + .find(|node| node.id == snapshot.session.root_node_id) + .and_then(|node| node.tabs.clone()) + .expect("session root snapshot includes tabs record") +} + +fn tabs_record(snapshot: &SessionSnapshot, node_id: embers_core::NodeId) -> TabsRecord { + snapshot + .nodes + .iter() + .find(|node| node.id == node_id) + .and_then(|node| node.tabs.clone()) + .expect("snapshot includes tabs node") +} + +fn split_record(snapshot: &SessionSnapshot, node_id: embers_core::NodeId) -> SplitRecord { + snapshot + .nodes + .iter() + .find(|node| node.id == node_id) + .and_then(|node| node.split.clone()) + .expect("snapshot includes split node") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn nested_tab_mutations_round_trip_through_socket() { + let server = TestServer::start().await.expect("start server"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("connect protocol client"); + + let session = create_session(&mut connection, "main").await; + let session_id = session.snapshot.session.id; + + let first_buffer = create_buffer(&mut connection, "one").await; + let first_snapshot = add_root_tab(&mut connection, session_id, "one", first_buffer.id).await; + let first_leaf = root_tabs(&first_snapshot).tabs[0].child_id; + + let second_buffer = create_buffer(&mut connection, "two").await; + let split_snapshot = connection + .request(&ClientMessage::Node(NodeRequest::Split { + request_id: new_request_id(), + leaf_node_id: first_leaf, + direction: SplitDirection::Horizontal, + new_buffer_id: second_buffer.id, + })) + .await + .expect("split request succeeds"); + let split_snapshot = match split_snapshot { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let outer_split_id = root_tabs(&split_snapshot).tabs[0].child_id; + let second_leaf = split_snapshot + .session + .focused_leaf_id + .expect("split focuses second leaf"); + + let wrapped = connection + .request(&ClientMessage::Node(NodeRequest::WrapInTabs { + request_id: new_request_id(), + node_id: second_leaf, + title: "nested".to_owned(), + })) + .await + .expect("wrap request succeeds"); + let wrapped = match wrapped { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let tabs_id = split_record(&wrapped, outer_split_id).child_ids[1]; + + let fourth_buffer = create_buffer(&mut connection, "four").await; + let split_inner = connection + .request(&ClientMessage::Node(NodeRequest::Split { + request_id: new_request_id(), + leaf_node_id: second_leaf, + direction: SplitDirection::Vertical, + new_buffer_id: fourth_buffer.id, + })) + .await + .expect("inner split request succeeds"); + let split_inner = match split_inner { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let inner_split_id = tabs_record(&split_inner, tabs_id).tabs[0].child_id; + let fourth_leaf = split_inner + .session + .focused_leaf_id + .expect("inner split focuses newest leaf"); + + let third_buffer = create_buffer(&mut connection, "three").await; + let added = connection + .request(&ClientMessage::Node(NodeRequest::AddTab { + request_id: new_request_id(), + tabs_node_id: tabs_id, + title: "other".to_owned(), + buffer_id: Some(third_buffer.id), + child_node_id: None, + index: 1, + })) + .await + .expect("add nested tab request succeeds"); + let added = match added { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let third_leaf = added + .session + .focused_leaf_id + .expect("new nested tab focuses new leaf"); + + let selected_first = connection + .request(&ClientMessage::Node(NodeRequest::SelectTab { + request_id: new_request_id(), + tabs_node_id: tabs_id, + index: 0, + })) + .await + .expect("select first nested tab request succeeds"); + let selected_first = match selected_first { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + assert_eq!(selected_first.session.focused_leaf_id, Some(fourth_leaf)); + + let selected_second = connection + .request(&ClientMessage::Node(NodeRequest::SelectTab { + request_id: new_request_id(), + tabs_node_id: tabs_id, + index: 1, + })) + .await + .expect("select second nested tab request succeeds"); + let selected_second = match selected_second { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + assert_eq!(selected_second.session.focused_leaf_id, Some(third_leaf)); + + let closed = connection + .request(&ClientMessage::Node(NodeRequest::Close { + request_id: new_request_id(), + node_id: third_leaf, + })) + .await + .expect("close nested tab node request succeeds"); + let closed = match closed { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + assert_eq!(closed.session.focused_leaf_id, Some(fourth_leaf)); + assert_eq!( + split_record(&closed, outer_split_id).child_ids[1], + inner_split_id + ); + assert_eq!( + get_buffer(&mut connection, third_buffer.id) + .await + .attachment_node_id, + None + ); + + server.shutdown().await.expect("shutdown server"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_tree_returns_nested_tab_structure() { + let server = TestServer::start().await.expect("start server"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("connect protocol client"); + + let session = create_session(&mut connection, "main").await; + let session_id = session.snapshot.session.id; + + let first_buffer = create_buffer(&mut connection, "one").await; + let first_snapshot = add_root_tab(&mut connection, session_id, "one", first_buffer.id).await; + let first_leaf = root_tabs(&first_snapshot).tabs[0].child_id; + + let second_buffer = create_buffer(&mut connection, "two").await; + let split_snapshot = connection + .request(&ClientMessage::Node(NodeRequest::Split { + request_id: new_request_id(), + leaf_node_id: first_leaf, + direction: SplitDirection::Vertical, + new_buffer_id: second_buffer.id, + })) + .await + .expect("split request succeeds"); + let split_snapshot = match split_snapshot { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let outer_split_id = root_tabs(&split_snapshot).tabs[0].child_id; + let second_leaf = split_snapshot + .session + .focused_leaf_id + .expect("split focuses second leaf"); + + let wrapped = connection + .request(&ClientMessage::Node(NodeRequest::WrapInTabs { + request_id: new_request_id(), + node_id: second_leaf, + title: "nested".to_owned(), + })) + .await + .expect("wrap request succeeds"); + let wrapped = match wrapped { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let tabs_id = split_record(&wrapped, outer_split_id).child_ids[1]; + + let third_buffer = create_buffer(&mut connection, "three").await; + connection + .request(&ClientMessage::Node(NodeRequest::AddTab { + request_id: new_request_id(), + tabs_node_id: tabs_id, + title: "other".to_owned(), + buffer_id: Some(third_buffer.id), + child_node_id: None, + index: 1, + })) + .await + .expect("add nested tab request succeeds"); + + let tree = connection + .request(&ClientMessage::Node(NodeRequest::GetTree { + request_id: new_request_id(), + session_id, + })) + .await + .expect("get tree request succeeds"); + let tree = match tree { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + + let outer_split = split_record(&tree, outer_split_id); + assert_eq!(outer_split.child_ids[1], tabs_id); + let nested_tabs = tabs_record(&tree, tabs_id); + assert_eq!(nested_tabs.tabs.len(), 2); + assert_eq!(nested_tabs.tabs[0].title, "nested"); + assert_eq!(nested_tabs.tabs[1].title, "other"); + + server.shutdown().await.expect("shutdown server"); +} diff --git a/crates/embers-test-support/tests/protocol_server.rs b/crates/embers-test-support/tests/protocol_server.rs new file mode 100644 index 0000000..2bbf836 --- /dev/null +++ b/crates/embers-test-support/tests/protocol_server.rs @@ -0,0 +1,324 @@ +use std::time::Duration; + +use embers_core::{ErrorCode, MuxError, RequestId, SessionId, new_request_id}; +use embers_protocol::{ + ClientMessage, FrameType, NodeRequest, PingRequest, RawFrame, ServerEnvelope, ServerEvent, + ServerResponse, SessionRequest, decode_server_envelope, encode_client_message, read_frame, + write_frame, +}; +use embers_test_support::{TestConnection, TestServer}; +use tokio::io::AsyncWriteExt; +use tokio::net::UnixStream; +use tokio::time::sleep; + +async fn create_session( + connection: &mut TestConnection, + name: &str, +) -> embers_protocol::SessionSnapshotResponse { + let response = connection + .request(&ClientMessage::Session(SessionRequest::Create { + request_id: new_request_id(), + name: name.to_owned(), + })) + .await + .expect("create session request succeeds"); + + match response { + ServerResponse::SessionSnapshot(snapshot) => snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + } +} + +fn expect_error( + response: ServerResponse, + request_id: Option, + code: ErrorCode, +) -> String { + match response { + ServerResponse::Error(error) => { + assert_eq!(error.request_id, request_id); + assert_eq!(error.error.code, code); + error.error.message + } + other => panic!("expected error response, got {other:?}"), + } +} + +fn encode_frame_bytes(frame: &RawFrame) -> Vec { + let mut bytes = Vec::with_capacity(13 + frame.payload.len()); + bytes.extend_from_slice(&(frame.payload.len() as u32).to_le_bytes()); + bytes.push(frame.frame_type as u8); + bytes.extend_from_slice(&u64::from(frame.request_id).to_le_bytes()); + bytes.extend_from_slice(&frame.payload); + bytes +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn subscriptions_fan_out_to_multiple_clients_with_session_filters() { + let server = TestServer::start().await.expect("start server"); + let mut actor = TestConnection::connect(server.socket_path()) + .await + .expect("connect actor"); + let mut global = TestConnection::connect(server.socket_path()) + .await + .expect("connect global subscriber"); + let mut scoped = TestConnection::connect(server.socket_path()) + .await + .expect("connect scoped subscriber"); + let mut other_scope = TestConnection::connect(server.socket_path()) + .await + .expect("connect other scoped subscriber"); + + let main_session = create_session(&mut actor, "main").await; + let other_session = create_session(&mut actor, "other").await; + let main_session_id = main_session.snapshot.session.id; + let other_session_id = other_session.snapshot.session.id; + + global.subscribe(None).await.expect("subscribe globally"); + scoped + .subscribe(Some(main_session_id)) + .await + .expect("subscribe to main session"); + other_scope + .subscribe(Some(other_session_id)) + .await + .expect("subscribe to other session"); + + let close_request_id = RequestId(41); + let close_response = actor + .request(&ClientMessage::Session(SessionRequest::Close { + request_id: close_request_id, + session_id: main_session_id, + force: false, + })) + .await + .expect("close session request succeeds"); + assert!(matches!(close_response, ServerResponse::Ok(_))); + + let global_event = global + .wait_for_event(Duration::from_secs(1), |event| { + matches!( + event, + ServerEvent::SessionClosed(closed) if closed.session_id == main_session_id + ) + }) + .await + .expect("global subscriber receives session close"); + assert!(matches!( + global_event, + ServerEvent::SessionClosed(closed) if closed.session_id == main_session_id + )); + + let scoped_event = scoped + .wait_for_event(Duration::from_secs(1), |event| { + matches!( + event, + ServerEvent::SessionClosed(closed) if closed.session_id == main_session_id + ) + }) + .await + .expect("scoped subscriber receives matching session close"); + assert!(matches!( + scoped_event, + ServerEvent::SessionClosed(closed) if closed.session_id == main_session_id + )); + + let other_scope_error = other_scope + .wait_for_event(Duration::from_millis(200), |event| { + matches!( + event, + ServerEvent::SessionClosed(closed) if closed.session_id == main_session_id + ) + }) + .await + .expect_err("non-matching scoped subscriber should not receive the event"); + assert!(matches!(other_scope_error, MuxError::Timeout(_))); + + server.shutdown().await.expect("shutdown server"); +} + +#[tokio::test] +async fn fragmented_request_frames_round_trip_and_preserve_correlation_id() { + let server = TestServer::start().await.expect("start server"); + let mut stream = UnixStream::connect(server.socket_path()) + .await + .expect("connect raw client"); + + let request_id = RequestId(52); + let payload = encode_client_message(&ClientMessage::Ping(PingRequest { + request_id, + payload: "fragmented".to_owned(), + })) + .expect("encode ping request"); + let frame = RawFrame::new(FrameType::Request, request_id, payload); + + for chunk in encode_frame_bytes(&frame).chunks(3) { + stream.write_all(chunk).await.expect("write request chunk"); + tokio::task::yield_now().await; + } + + let response_frame = read_frame(&mut stream) + .await + .expect("read response frame") + .expect("response frame"); + assert_eq!(response_frame.frame_type, FrameType::Response); + assert_eq!(response_frame.request_id, request_id); + + match decode_server_envelope(&response_frame.payload).expect("decode response payload") { + ServerEnvelope::Response(ServerResponse::Pong(pong)) => { + assert_eq!(pong.request_id, request_id); + assert_eq!(pong.payload, "fragmented"); + } + other => panic!("expected pong response, got {other:?}"), + } + + server.shutdown().await.expect("shutdown server"); +} + +#[tokio::test] +async fn malformed_payloads_return_protocol_violation_errors() { + let server = TestServer::start().await.expect("start server"); + let mut stream = UnixStream::connect(server.socket_path()) + .await + .expect("connect raw client"); + + let request_id = RequestId(61); + let malformed = RawFrame::new(FrameType::Request, request_id, vec![0, 1, 2, 3, 4]); + write_frame(&mut stream, &malformed) + .await + .expect("write malformed request"); + + let response_frame = read_frame(&mut stream) + .await + .expect("read response frame") + .expect("response frame"); + assert_eq!(response_frame.frame_type, FrameType::Response); + assert_eq!(response_frame.request_id, request_id); + + match decode_server_envelope(&response_frame.payload).expect("decode response payload") { + ServerEnvelope::Response(ServerResponse::Error(error)) => { + assert_eq!(error.request_id, Some(request_id)); + assert_eq!(error.error.code, ErrorCode::ProtocolViolation); + } + other => panic!("expected protocol violation response, got {other:?}"), + } + + server.shutdown().await.expect("shutdown server"); +} + +#[tokio::test] +async fn typed_errors_cover_invalid_ids_and_impossible_mutations() { + let server = TestServer::start().await.expect("start server"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("connect client"); + + let missing_request_id = RequestId(71); + let missing_response = connection + .request(&ClientMessage::Session(SessionRequest::Get { + request_id: missing_request_id, + session_id: SessionId(999), + })) + .await + .expect("missing session request returns response"); + expect_error( + missing_response, + Some(missing_request_id), + ErrorCode::NotFound, + ); + + let session = create_session(&mut connection, "empty").await; + let invalid_focus_request_id = RequestId(72); + let invalid_focus_response = connection + .request(&ClientMessage::Node(NodeRequest::Focus { + request_id: invalid_focus_request_id, + session_id: session.snapshot.session.id, + node_id: session.snapshot.session.root_node_id, + })) + .await + .expect("invalid focus request returns response"); + let message = expect_error( + invalid_focus_response, + Some(invalid_focus_request_id), + ErrorCode::InvalidRequest, + ); + assert!(message.contains("no focusable leaf")); + + let invalid_move_request_id = RequestId(73); + let invalid_move_response = connection + .request(&ClientMessage::Node(NodeRequest::MoveBufferToNode { + request_id: invalid_move_request_id, + buffer_id: embers_core::BufferId(1), + target_leaf_node_id: session.snapshot.session.root_node_id, + })) + .await + .expect("invalid move request returns response"); + expect_error( + invalid_move_response, + Some(invalid_move_request_id), + ErrorCode::InvalidRequest, + ); + + server.shutdown().await.expect("shutdown server"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn disconnected_subscribers_are_cleaned_up_without_breaking_remaining_clients() { + let server = TestServer::start().await.expect("start server"); + let mut actor = TestConnection::connect(server.socket_path()) + .await + .expect("connect actor"); + let mut surviving_subscriber = TestConnection::connect(server.socket_path()) + .await + .expect("connect surviving subscriber"); + let mut disconnected_subscriber = TestConnection::connect(server.socket_path()) + .await + .expect("connect subscriber to disconnect"); + + let session = create_session(&mut actor, "cleanup").await; + let session_id = session.snapshot.session.id; + + surviving_subscriber + .subscribe(Some(session_id)) + .await + .expect("subscribe surviving client"); + disconnected_subscriber + .subscribe(Some(session_id)) + .await + .expect("subscribe client to disconnect"); + + drop(disconnected_subscriber); + sleep(Duration::from_millis(50)).await; + + let close_response = actor + .request(&ClientMessage::Session(SessionRequest::Close { + request_id: RequestId(81), + session_id, + force: false, + })) + .await + .expect("close session succeeds"); + assert!(matches!(close_response, ServerResponse::Ok(_))); + + let event = surviving_subscriber + .wait_for_event(Duration::from_secs(1), |server_event| { + matches!( + server_event, + ServerEvent::SessionClosed(closed) if closed.session_id == session_id + ) + }) + .await + .expect("surviving subscriber receives session close"); + assert!(matches!( + event, + ServerEvent::SessionClosed(closed) if closed.session_id == session_id + )); + + let ping = actor + .ping("still-alive") + .await + .expect("server stays usable"); + assert_eq!(ping, "still-alive"); + + server.shutdown().await.expect("shutdown server"); +} diff --git a/crates/mux-test-support/tests/pty_smoke.rs b/crates/embers-test-support/tests/pty_smoke.rs similarity index 90% rename from crates/mux-test-support/tests/pty_smoke.rs rename to crates/embers-test-support/tests/pty_smoke.rs index 1dcbb1e..7004a98 100644 --- a/crates/mux-test-support/tests/pty_smoke.rs +++ b/crates/embers-test-support/tests/pty_smoke.rs @@ -1,7 +1,7 @@ use std::time::Duration; -use mux_core::PtySize; -use mux_test_support::PtyHarness; +use embers_core::PtySize; +use embers_test_support::PtyHarness; #[test] #[ignore = "exercises the PTY smoke harness in CI and later end-to-end runs"] diff --git a/crates/mux-test-support/tests/server_harness.rs b/crates/embers-test-support/tests/server_harness.rs similarity index 88% rename from crates/mux-test-support/tests/server_harness.rs rename to crates/embers-test-support/tests/server_harness.rs index f151237..e12cb79 100644 --- a/crates/mux-test-support/tests/server_harness.rs +++ b/crates/embers-test-support/tests/server_harness.rs @@ -1,4 +1,4 @@ -use mux_test_support::{TestConnection, TestServer}; +use embers_test_support::{TestConnection, TestServer}; #[tokio::test] async fn harness_starts_server_and_pings_it() { diff --git a/crates/embers-test-support/tests/session_root_tabs.rs b/crates/embers-test-support/tests/session_root_tabs.rs new file mode 100644 index 0000000..73b633a --- /dev/null +++ b/crates/embers-test-support/tests/session_root_tabs.rs @@ -0,0 +1,292 @@ +use embers_core::{ErrorCode, new_request_id}; +use embers_protocol::{ + BufferRecord, BufferRequest, ClientMessage, ServerResponse, SessionRequest, + SessionSnapshotResponse, SessionsResponse, TabsRecord, +}; +use embers_test_support::{TestConnection, TestServer}; + +async fn create_session(connection: &mut TestConnection, name: &str) -> SessionSnapshotResponse { + let response = connection + .request(&ClientMessage::Session(SessionRequest::Create { + request_id: new_request_id(), + name: name.to_owned(), + })) + .await + .expect("create session request succeeds"); + + match response { + ServerResponse::SessionSnapshot(snapshot) => snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + } +} + +async fn get_session( + connection: &mut TestConnection, + session_id: embers_core::SessionId, +) -> ServerResponse { + connection + .request(&ClientMessage::Session(SessionRequest::Get { + request_id: new_request_id(), + session_id, + })) + .await + .expect("get session request succeeds") +} + +async fn create_buffer(connection: &mut TestConnection, title: &str) -> BufferRecord { + let response = connection + .request(&ClientMessage::Buffer(BufferRequest::Create { + request_id: new_request_id(), + title: Some(title.to_owned()), + command: vec!["/bin/sh".to_owned(), "-lc".to_owned(), "cat".to_owned()], + cwd: None, + env: Default::default(), + })) + .await + .expect("create buffer request succeeds"); + + match response { + ServerResponse::Buffer(buffer) => buffer.buffer, + other => panic!("expected buffer response, got {other:?}"), + } +} + +async fn get_buffer( + connection: &mut TestConnection, + buffer_id: embers_core::BufferId, +) -> BufferRecord { + let response = connection + .request(&ClientMessage::Buffer(BufferRequest::Get { + request_id: new_request_id(), + buffer_id, + })) + .await + .expect("get buffer request succeeds"); + + match response { + ServerResponse::Buffer(buffer) => buffer.buffer, + other => panic!("expected buffer response, got {other:?}"), + } +} + +fn root_node(snapshot: &embers_protocol::SessionSnapshot) -> &embers_protocol::NodeRecord { + snapshot + .nodes + .iter() + .find(|node| node.id == snapshot.session.root_node_id) + .expect("session root snapshot includes root node") +} + +fn root_tabs(snapshot: &embers_protocol::SessionSnapshot) -> Option { + root_node(snapshot).tabs.clone() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn create_list_get_and_close_sessions_via_socket() { + let server = TestServer::start().await.expect("start server"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("connect protocol client"); + + let alpha = create_session(&mut connection, "alpha").await; + let beta = create_session(&mut connection, "beta").await; + + let list = connection + .request(&ClientMessage::Session(SessionRequest::List { + request_id: new_request_id(), + })) + .await + .expect("list sessions request succeeds"); + let sessions = match list { + ServerResponse::Sessions(SessionsResponse { sessions, .. }) => sessions, + other => panic!("expected sessions response, got {other:?}"), + }; + assert_eq!(sessions.len(), 2); + assert!( + sessions + .iter() + .any(|session| session.id == alpha.snapshot.session.id) + ); + assert!( + sessions + .iter() + .any(|session| session.id == beta.snapshot.session.id) + ); + + let fetched = get_session(&mut connection, alpha.snapshot.session.id).await; + let fetched = match fetched { + ServerResponse::SessionSnapshot(snapshot) => snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + assert_eq!(fetched.snapshot.session.name, "alpha"); + assert!( + root_tabs(&fetched.snapshot) + .expect("new sessions start with empty root tabs") + .tabs + .is_empty() + ); + + let close = connection + .request(&ClientMessage::Session(SessionRequest::Close { + request_id: new_request_id(), + session_id: alpha.snapshot.session.id, + force: false, + })) + .await + .expect("close session request succeeds"); + assert!(matches!(close, ServerResponse::Ok(_))); + + let missing = get_session(&mut connection, alpha.snapshot.session.id).await; + match missing { + ServerResponse::Error(error) => assert_eq!(error.error.code, ErrorCode::NotFound), + other => panic!("expected not found error, got {other:?}"), + } + + server.shutdown().await.expect("shutdown server"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn create_select_rename_and_close_root_tabs_via_socket() { + let server = TestServer::start().await.expect("start server"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("connect protocol client"); + + let session = create_session(&mut connection, "main").await; + let session_id = session.snapshot.session.id; + let root_node_id = session.snapshot.session.root_node_id; + + let first_buffer = create_buffer(&mut connection, "shell").await; + let first_added = connection + .request(&ClientMessage::Session(SessionRequest::AddRootTab { + request_id: new_request_id(), + session_id, + title: "shell".to_owned(), + buffer_id: Some(first_buffer.id), + child_node_id: None, + })) + .await + .expect("add first root tab request succeeds"); + let first_added = match first_added { + ServerResponse::SessionSnapshot(snapshot) => snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let first_tabs = root_tabs(&first_added.snapshot).expect("first root tab keeps tabs root"); + let first_leaf = first_tabs.tabs[0].child_id; + assert_eq!(first_added.snapshot.session.root_node_id, root_node_id); + assert_eq!(first_tabs.active, 0); + assert_eq!(first_tabs.tabs.len(), 1); + + let second_buffer = create_buffer(&mut connection, "logs").await; + let second_added = connection + .request(&ClientMessage::Session(SessionRequest::AddRootTab { + request_id: new_request_id(), + session_id, + title: "logs".to_owned(), + buffer_id: Some(second_buffer.id), + child_node_id: None, + })) + .await + .expect("add second root tab request succeeds"); + let second_added = match second_added { + ServerResponse::SessionSnapshot(snapshot) => snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let second_tabs = root_tabs(&second_added.snapshot).expect("second root tab keeps tabs root"); + assert_eq!(second_added.snapshot.session.root_node_id, root_node_id); + assert_eq!(second_tabs.active, 1); + assert_eq!(second_tabs.tabs.len(), 2); + + let selected = connection + .request(&ClientMessage::Session(SessionRequest::SelectRootTab { + request_id: new_request_id(), + session_id, + index: 0, + })) + .await + .expect("select root tab request succeeds"); + let selected = match selected { + ServerResponse::SessionSnapshot(snapshot) => snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + assert_eq!( + root_tabs(&selected.snapshot) + .expect("selecting between root windows keeps tabs root") + .active, + 0 + ); + assert_eq!(selected.snapshot.session.focused_leaf_id, Some(first_leaf)); + + let renamed = connection + .request(&ClientMessage::Session(SessionRequest::RenameRootTab { + request_id: new_request_id(), + session_id, + index: 0, + title: "editor".to_owned(), + })) + .await + .expect("rename root tab request succeeds"); + let renamed = match renamed { + ServerResponse::SessionSnapshot(snapshot) => snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + assert_eq!( + root_tabs(&renamed.snapshot) + .expect("renaming root windows keeps tabs root") + .tabs[0] + .title, + "editor" + ); + + let closed_second = connection + .request(&ClientMessage::Session(SessionRequest::CloseRootTab { + request_id: new_request_id(), + session_id, + index: 1, + })) + .await + .expect("close second root tab request succeeds"); + let closed_second = match closed_second { + ServerResponse::SessionSnapshot(snapshot) => snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + assert!(root_tabs(&closed_second.snapshot).is_none()); + let root = root_node(&closed_second.snapshot); + let root_buffer = root + .buffer_view + .as_ref() + .expect("single remaining root window collapses to a buffer view"); + assert_eq!(root.id, first_leaf); + assert_eq!(root_buffer.buffer_id, first_buffer.id); + assert_eq!( + get_buffer(&mut connection, second_buffer.id) + .await + .attachment_node_id, + None + ); + + let closed_last = connection + .request(&ClientMessage::Session(SessionRequest::CloseRootTab { + request_id: new_request_id(), + session_id, + index: 0, + })) + .await + .expect("close last root tab request succeeds"); + let closed_last = match closed_last { + ServerResponse::SessionSnapshot(snapshot) => snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let final_tabs = root_tabs(&closed_last.snapshot) + .expect("closing the last implicit root window resets the empty root tabs"); + assert!(final_tabs.tabs.is_empty()); + assert_eq!(closed_last.snapshot.session.focused_leaf_id, None); + assert_eq!( + get_buffer(&mut connection, first_buffer.id) + .await + .attachment_node_id, + None + ); + + server.shutdown().await.expect("shutdown server"); +} diff --git a/crates/embers-test-support/tests/split_layout.rs b/crates/embers-test-support/tests/split_layout.rs new file mode 100644 index 0000000..8268572 --- /dev/null +++ b/crates/embers-test-support/tests/split_layout.rs @@ -0,0 +1,263 @@ +use embers_core::{SplitDirection, new_request_id}; +use embers_protocol::{ + BufferRecord, BufferRequest, ClientMessage, NodeRequest, ServerResponse, SessionRequest, + SessionSnapshot, SessionSnapshotResponse, SplitRecord, TabsRecord, +}; +use embers_test_support::{TestConnection, TestServer}; + +async fn create_session(connection: &mut TestConnection, name: &str) -> SessionSnapshotResponse { + let response = connection + .request(&ClientMessage::Session(SessionRequest::Create { + request_id: new_request_id(), + name: name.to_owned(), + })) + .await + .expect("create session request succeeds"); + + match response { + ServerResponse::SessionSnapshot(snapshot) => snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + } +} + +async fn create_buffer(connection: &mut TestConnection, title: &str) -> BufferRecord { + let response = connection + .request(&ClientMessage::Buffer(BufferRequest::Create { + request_id: new_request_id(), + title: Some(title.to_owned()), + command: vec!["/bin/sh".to_owned(), "-lc".to_owned(), "cat".to_owned()], + cwd: None, + env: Default::default(), + })) + .await + .expect("create buffer request succeeds"); + + match response { + ServerResponse::Buffer(buffer) => buffer.buffer, + other => panic!("expected buffer response, got {other:?}"), + } +} + +async fn add_root_tab( + connection: &mut TestConnection, + session_id: embers_core::SessionId, + title: &str, + buffer_id: embers_core::BufferId, +) -> SessionSnapshot { + let response = connection + .request(&ClientMessage::Session(SessionRequest::AddRootTab { + request_id: new_request_id(), + session_id, + title: title.to_owned(), + buffer_id: Some(buffer_id), + child_node_id: None, + })) + .await + .expect("add root tab request succeeds"); + + match response { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + } +} + +async fn get_buffer( + connection: &mut TestConnection, + buffer_id: embers_core::BufferId, +) -> BufferRecord { + let response = connection + .request(&ClientMessage::Buffer(BufferRequest::Get { + request_id: new_request_id(), + buffer_id, + })) + .await + .expect("get buffer request succeeds"); + + match response { + ServerResponse::Buffer(buffer) => buffer.buffer, + other => panic!("expected buffer response, got {other:?}"), + } +} + +fn root_tabs(snapshot: &SessionSnapshot) -> TabsRecord { + snapshot + .nodes + .iter() + .find(|node| node.id == snapshot.session.root_node_id) + .and_then(|node| node.tabs.clone()) + .expect("session root snapshot includes tabs record") +} + +fn root_node(snapshot: &SessionSnapshot) -> &embers_protocol::NodeRecord { + snapshot + .nodes + .iter() + .find(|node| node.id == snapshot.session.root_node_id) + .expect("session root snapshot includes root node") +} + +fn split_record(snapshot: &SessionSnapshot, node_id: embers_core::NodeId) -> SplitRecord { + snapshot + .nodes + .iter() + .find(|node| node.id == node_id) + .and_then(|node| node.split.clone()) + .expect("snapshot includes split node") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn split_and_resize_requests_build_nested_layouts_via_socket() { + let server = TestServer::start().await.expect("start server"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("connect protocol client"); + + let session = create_session(&mut connection, "main").await; + let session_id = session.snapshot.session.id; + + let first_buffer = create_buffer(&mut connection, "one").await; + let first_snapshot = add_root_tab(&mut connection, session_id, "one", first_buffer.id).await; + let first_leaf = root_tabs(&first_snapshot).tabs[0].child_id; + + let second_buffer = create_buffer(&mut connection, "two").await; + let split_snapshot = connection + .request(&ClientMessage::Node(NodeRequest::Split { + request_id: new_request_id(), + leaf_node_id: first_leaf, + direction: SplitDirection::Vertical, + new_buffer_id: second_buffer.id, + })) + .await + .expect("split request succeeds"); + let split_snapshot = match split_snapshot { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let outer_split_id = root_tabs(&split_snapshot).tabs[0].child_id; + let outer_split = split_record(&split_snapshot, outer_split_id); + let second_leaf = split_snapshot + .session + .focused_leaf_id + .expect("split focuses new leaf"); + assert_eq!(outer_split.direction, SplitDirection::Vertical); + assert_eq!(outer_split.child_ids, vec![first_leaf, second_leaf]); + assert_eq!(outer_split.sizes, vec![1, 1]); + + let third_buffer = create_buffer(&mut connection, "three").await; + let nested_snapshot = connection + .request(&ClientMessage::Node(NodeRequest::Split { + request_id: new_request_id(), + leaf_node_id: second_leaf, + direction: SplitDirection::Horizontal, + new_buffer_id: third_buffer.id, + })) + .await + .expect("nested split request succeeds"); + let nested_snapshot = match nested_snapshot { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let updated_outer = split_record(&nested_snapshot, outer_split_id); + let inner_split_id = updated_outer.child_ids[1]; + let inner_split = split_record(&nested_snapshot, inner_split_id); + let third_leaf = nested_snapshot + .session + .focused_leaf_id + .expect("nested split focuses newest leaf"); + assert_eq!(updated_outer.child_ids[0], first_leaf); + assert_eq!(inner_split.direction, SplitDirection::Horizontal); + assert_eq!(inner_split.child_ids, vec![second_leaf, third_leaf]); + assert_eq!(inner_split.sizes, vec![1, 1]); + + let resized = connection + .request(&ClientMessage::Node(NodeRequest::Resize { + request_id: new_request_id(), + node_id: outer_split_id, + sizes: vec![4, 1], + })) + .await + .expect("resize request succeeds"); + let resized = match resized { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + assert_eq!(split_record(&resized, outer_split_id).sizes, vec![4, 1]); + + server.shutdown().await.expect("shutdown server"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn focus_and_close_requests_normalize_layout_and_detach_buffers() { + let server = TestServer::start().await.expect("start server"); + let mut connection = TestConnection::connect(server.socket_path()) + .await + .expect("connect protocol client"); + + let session = create_session(&mut connection, "main").await; + let session_id = session.snapshot.session.id; + + let first_buffer = create_buffer(&mut connection, "one").await; + let first_snapshot = add_root_tab(&mut connection, session_id, "one", first_buffer.id).await; + let first_leaf = root_tabs(&first_snapshot).tabs[0].child_id; + + let second_buffer = create_buffer(&mut connection, "two").await; + let split_snapshot = connection + .request(&ClientMessage::Node(NodeRequest::Split { + request_id: new_request_id(), + leaf_node_id: first_leaf, + direction: SplitDirection::Horizontal, + new_buffer_id: second_buffer.id, + })) + .await + .expect("split request succeeds"); + let split_snapshot = match split_snapshot { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let second_leaf = split_snapshot + .session + .focused_leaf_id + .expect("split focuses new leaf"); + + let focused = connection + .request(&ClientMessage::Node(NodeRequest::Focus { + request_id: new_request_id(), + session_id, + node_id: first_leaf, + })) + .await + .expect("focus request succeeds"); + let focused = match focused { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + assert_eq!(focused.session.focused_leaf_id, Some(first_leaf)); + + let closed = connection + .request(&ClientMessage::Node(NodeRequest::Close { + request_id: new_request_id(), + node_id: first_leaf, + })) + .await + .expect("close request succeeds"); + let closed = match closed { + ServerResponse::SessionSnapshot(snapshot) => snapshot.snapshot, + other => panic!("expected session snapshot response, got {other:?}"), + }; + let root = root_node(&closed); + let root_buffer = root + .buffer_view + .as_ref() + .expect("single remaining pane collapses to the root buffer view"); + assert_eq!(root.id, second_leaf); + assert_eq!(root_buffer.buffer_id, second_buffer.id); + assert_eq!(closed.session.focused_leaf_id, Some(second_leaf)); + assert_eq!( + get_buffer(&mut connection, first_buffer.id) + .await + .attachment_node_id, + None + ); + + server.shutdown().await.expect("shutdown server"); +} diff --git a/crates/mux-cli/Cargo.toml b/crates/mux-cli/Cargo.toml deleted file mode 100644 index 4d13bb7..0000000 --- a/crates/mux-cli/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "mux-cli" -edition.workspace = true -license.workspace = true -rust-version.workspace = true -version.workspace = true - -[dependencies] -clap.workspace = true -mux-core = { path = "../mux-core" } -mux-protocol = { path = "../mux-protocol" } -tokio.workspace = true - -[dev-dependencies] -mux-test-support = { path = "../mux-test-support" } -predicates.workspace = true -tokio.workspace = true diff --git a/crates/mux-cli/src/lib.rs b/crates/mux-cli/src/lib.rs deleted file mode 100644 index 78eca1f..0000000 --- a/crates/mux-cli/src/lib.rs +++ /dev/null @@ -1,56 +0,0 @@ -use std::path::PathBuf; - -use clap::{Parser, Subcommand}; -use mux_core::{MuxError, Result, new_request_id}; -use mux_protocol::{ClientMessage, PingRequest, ProtocolClient, ServerResponse}; - -#[derive(Debug, Parser)] -#[command( - name = "mux-cli", - about = "Phase-0 control surface for the embers workspace" -)] -pub struct Cli { - #[command(subcommand)] - pub command: Command, -} - -#[derive(Debug, Subcommand)] -pub enum Command { - Ping { - #[arg(long)] - socket: PathBuf, - #[arg(default_value = "phase0")] - payload: String, - }, -} - -pub async fn execute(cli: Cli) -> Result { - match cli.command { - Command::Ping { socket, payload } => ping(socket, payload).await, - } -} - -pub async fn run(cli: Cli) -> Result<()> { - let output = execute(cli).await?; - println!("{output}"); - Ok(()) -} - -async fn ping(socket: PathBuf, payload: String) -> Result { - let mut client = ProtocolClient::connect(&socket) - .await - .map_err(|error| MuxError::transport(error.to_string()))?; - let request = ClientMessage::Ping(PingRequest { - request_id: new_request_id(), - payload: payload.clone(), - }); - - match client - .request(&request) - .await - .map_err(|error| MuxError::transport(error.to_string()))? - { - ServerResponse::Pong(response) => Ok(format!("pong {}", response.payload)), - ServerResponse::Error(response) => Err(response.error.into()), - } -} diff --git a/crates/mux-client/Cargo.toml b/crates/mux-client/Cargo.toml deleted file mode 100644 index 1c3871f..0000000 --- a/crates/mux-client/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "mux-client" -edition.workspace = true -license.workspace = true -rust-version.workspace = true -version.workspace = true - -[dependencies] -async-trait.workspace = true -mux-core = { path = "../mux-core" } -mux-protocol = { path = "../mux-protocol" } - -[dev-dependencies] -tokio.workspace = true diff --git a/crates/mux-client/src/lib.rs b/crates/mux-client/src/lib.rs deleted file mode 100644 index 98b1fa9..0000000 --- a/crates/mux-client/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod testing; -pub mod transport; - -pub use testing::{FakeTransport, ScriptedTransport, TestGrid}; -pub use transport::Transport; diff --git a/crates/mux-protocol/schema/embers.fbs b/crates/mux-protocol/schema/embers.fbs deleted file mode 100644 index 6c01ac9..0000000 --- a/crates/mux-protocol/schema/embers.fbs +++ /dev/null @@ -1,50 +0,0 @@ -namespace mux.protocol; - -enum MessageKind : ubyte { - None = 0, - PingRequest = 1, - PingResponse = 2, - ErrorResponse = 3, - HeartbeatEvent = 4, -} - -enum ErrorCodeWire : ubyte { - Unknown = 0, - InvalidRequest = 1, - ProtocolViolation = 2, - Transport = 3, - NotFound = 4, - Conflict = 5, - Unsupported = 6, - Timeout = 7, - Internal = 8, -} - -table PingRequest { - payload:string; -} - -table PingResponse { - payload:string; -} - -table ErrorResponse { - code:ErrorCodeWire = Unknown; - message:string; -} - -table HeartbeatEvent { - message:string; -} - -table Envelope { - request_id:ulong = 0; - kind:MessageKind = None; - ping_request:PingRequest; - ping_response:PingResponse; - error_response:ErrorResponse; - heartbeat_event:HeartbeatEvent; -} - -root_type Envelope; -file_identifier "EMBR"; diff --git a/crates/mux-protocol/src/client.rs b/crates/mux-protocol/src/client.rs deleted file mode 100644 index 91ca4eb..0000000 --- a/crates/mux-protocol/src/client.rs +++ /dev/null @@ -1,60 +0,0 @@ -use std::path::Path; - -use tokio::net::UnixStream; - -use crate::codec::{ProtocolError, decode_server_envelope, encode_client_message}; -use crate::framing::{read_frame, write_frame}; -use crate::types::{ClientMessage, ServerEnvelope, ServerResponse}; - -#[derive(Debug)] -pub struct ProtocolClient { - stream: UnixStream, -} - -impl ProtocolClient { - pub async fn connect(path: impl AsRef) -> Result { - let stream = UnixStream::connect(path).await?; - Ok(Self { stream }) - } - - pub async fn send(&mut self, message: &ClientMessage) -> Result<(), ProtocolError> { - let payload = encode_client_message(message)?; - write_frame(&mut self.stream, &payload).await - } - - pub async fn recv(&mut self) -> Result, ProtocolError> { - let Some(frame) = read_frame(&mut self.stream).await? else { - return Ok(None); - }; - - Ok(Some(decode_server_envelope(&frame)?)) - } - - pub async fn request( - &mut self, - message: &ClientMessage, - ) -> Result { - let request_id = message.request_id(); - self.send(message).await?; - - loop { - match self.recv().await? { - Some(ServerEnvelope::Response(response)) => { - let matches_request = response - .request_id() - .map(|response_id| response_id == request_id) - .unwrap_or(true); - if matches_request { - return Ok(response); - } - } - Some(ServerEnvelope::Event(_)) => continue, - None => { - return Err(ProtocolError::InvalidMessage( - "connection closed before response", - )); - } - } - } - } -} diff --git a/crates/mux-protocol/src/codec.rs b/crates/mux-protocol/src/codec.rs deleted file mode 100644 index d3712ad..0000000 --- a/crates/mux-protocol/src/codec.rs +++ /dev/null @@ -1,216 +0,0 @@ -use flatbuffers::FlatBufferBuilder; -use mux_core::{ErrorCode, RequestId, WireError}; -use thiserror::Error; - -use crate::generated::mux::protocol as fb; -use crate::types::{ - ClientMessage, ErrorResponse, HeartbeatEvent, PingRequest, PingResponse, ServerEnvelope, - ServerEvent, ServerResponse, -}; - -#[derive(Debug, Error)] -pub enum ProtocolError { - #[error("io error: {0}")] - Io(#[from] std::io::Error), - #[error("flatbuffer decode error: {0}")] - InvalidFlatbuffer(#[from] flatbuffers::InvalidFlatbuffer), - #[error("invalid message: {0}")] - InvalidMessage(&'static str), - #[error("invalid message: {0}")] - InvalidMessageOwned(String), - #[error("frame exceeds max length: {0}")] - FrameTooLarge(usize), -} - -pub fn encode_client_message(message: &ClientMessage) -> Result, ProtocolError> { - let mut builder = FlatBufferBuilder::new(); - - let envelope = match message { - ClientMessage::Ping(request) => { - let payload = builder.create_string(&request.payload); - let ping_request = fb::PingRequest::create( - &mut builder, - &fb::PingRequestArgs { - payload: Some(payload), - }, - ); - fb::Envelope::create( - &mut builder, - &fb::EnvelopeArgs { - request_id: u64::from(request.request_id), - kind: fb::MessageKind::PingRequest, - ping_request: Some(ping_request), - ping_response: None, - error_response: None, - heartbeat_event: None, - }, - ) - } - }; - - builder.finish(envelope, Some("EMBR")); - Ok(builder.finished_data().to_vec()) -} - -pub fn decode_client_message(bytes: &[u8]) -> Result { - let envelope = fb::root_as_envelope(bytes)?; - match envelope.kind() { - fb::MessageKind::PingRequest => { - let ping = required(envelope.ping_request(), "ping_request")?; - let payload = required(ping.payload(), "ping_request.payload")?; - Ok(ClientMessage::Ping(PingRequest { - request_id: RequestId(envelope.request_id()), - payload: payload.to_owned(), - })) - } - other => Err(ProtocolError::InvalidMessageOwned(format!( - "unexpected client message kind: {other:?}" - ))), - } -} - -pub fn encode_server_envelope(message: &ServerEnvelope) -> Result, ProtocolError> { - let mut builder = FlatBufferBuilder::new(); - - let envelope = match message { - ServerEnvelope::Response(ServerResponse::Pong(response)) => { - let payload = builder.create_string(&response.payload); - let pong = fb::PingResponse::create( - &mut builder, - &fb::PingResponseArgs { - payload: Some(payload), - }, - ); - fb::Envelope::create( - &mut builder, - &fb::EnvelopeArgs { - request_id: u64::from(response.request_id), - kind: fb::MessageKind::PingResponse, - ping_request: None, - ping_response: Some(pong), - error_response: None, - heartbeat_event: None, - }, - ) - } - ServerEnvelope::Response(ServerResponse::Error(response)) => { - let message = builder.create_string(&response.error.message); - let error_response = fb::ErrorResponse::create( - &mut builder, - &fb::ErrorResponseArgs { - code: encode_error_code(response.error.code), - message: Some(message), - }, - ); - fb::Envelope::create( - &mut builder, - &fb::EnvelopeArgs { - request_id: response.request_id.map_or(0, u64::from), - kind: fb::MessageKind::ErrorResponse, - ping_request: None, - ping_response: None, - error_response: Some(error_response), - heartbeat_event: None, - }, - ) - } - ServerEnvelope::Event(ServerEvent::Heartbeat(event)) => { - let message = builder.create_string(&event.message); - let heartbeat = fb::HeartbeatEvent::create( - &mut builder, - &fb::HeartbeatEventArgs { - message: Some(message), - }, - ); - fb::Envelope::create( - &mut builder, - &fb::EnvelopeArgs { - request_id: 0, - kind: fb::MessageKind::HeartbeatEvent, - ping_request: None, - ping_response: None, - error_response: None, - heartbeat_event: Some(heartbeat), - }, - ) - } - }; - - builder.finish(envelope, Some("EMBR")); - Ok(builder.finished_data().to_vec()) -} - -pub fn decode_server_envelope(bytes: &[u8]) -> Result { - let envelope = fb::root_as_envelope(bytes)?; - match envelope.kind() { - fb::MessageKind::PingResponse => { - let pong = required(envelope.ping_response(), "ping_response")?; - let payload = required(pong.payload(), "ping_response.payload")?; - Ok(ServerEnvelope::Response(ServerResponse::Pong( - PingResponse { - request_id: RequestId(envelope.request_id()), - payload: payload.to_owned(), - }, - ))) - } - fb::MessageKind::ErrorResponse => { - let error_response = required(envelope.error_response(), "error_response")?; - let message = required(error_response.message(), "error_response.message")?; - let request_id = match envelope.request_id() { - 0 => None, - value => Some(RequestId(value)), - }; - Ok(ServerEnvelope::Response(ServerResponse::Error( - ErrorResponse { - request_id, - error: WireError::new(decode_error_code(error_response.code()), message), - }, - ))) - } - fb::MessageKind::HeartbeatEvent => { - let heartbeat = required(envelope.heartbeat_event(), "heartbeat_event")?; - let message = required(heartbeat.message(), "heartbeat_event.message")?; - Ok(ServerEnvelope::Event(ServerEvent::Heartbeat( - HeartbeatEvent { - message: message.to_owned(), - }, - ))) - } - other => Err(ProtocolError::InvalidMessageOwned(format!( - "unexpected server message kind: {other:?}" - ))), - } -} - -fn required(value: Option, field: &'static str) -> Result { - value.ok_or(ProtocolError::InvalidMessage(field)) -} - -fn encode_error_code(code: ErrorCode) -> fb::ErrorCodeWire { - match code { - ErrorCode::Unknown => fb::ErrorCodeWire::Unknown, - ErrorCode::InvalidRequest => fb::ErrorCodeWire::InvalidRequest, - ErrorCode::ProtocolViolation => fb::ErrorCodeWire::ProtocolViolation, - ErrorCode::Transport => fb::ErrorCodeWire::Transport, - ErrorCode::NotFound => fb::ErrorCodeWire::NotFound, - ErrorCode::Conflict => fb::ErrorCodeWire::Conflict, - ErrorCode::Unsupported => fb::ErrorCodeWire::Unsupported, - ErrorCode::Timeout => fb::ErrorCodeWire::Timeout, - ErrorCode::Internal => fb::ErrorCodeWire::Internal, - } -} - -fn decode_error_code(code: fb::ErrorCodeWire) -> ErrorCode { - match code { - fb::ErrorCodeWire::Unknown => ErrorCode::Unknown, - fb::ErrorCodeWire::InvalidRequest => ErrorCode::InvalidRequest, - fb::ErrorCodeWire::ProtocolViolation => ErrorCode::ProtocolViolation, - fb::ErrorCodeWire::Transport => ErrorCode::Transport, - fb::ErrorCodeWire::NotFound => ErrorCode::NotFound, - fb::ErrorCodeWire::Conflict => ErrorCode::Conflict, - fb::ErrorCodeWire::Unsupported => ErrorCode::Unsupported, - fb::ErrorCodeWire::Timeout => ErrorCode::Timeout, - fb::ErrorCodeWire::Internal => ErrorCode::Internal, - _ => ErrorCode::Unknown, - } -} diff --git a/crates/mux-protocol/src/framing.rs b/crates/mux-protocol/src/framing.rs deleted file mode 100644 index 3e34a51..0000000 --- a/crates/mux-protocol/src/framing.rs +++ /dev/null @@ -1,42 +0,0 @@ -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; - -use crate::codec::ProtocolError; - -pub const MAX_FRAME_LEN: usize = 8 * 1024 * 1024; - -pub async fn read_frame(reader: &mut R) -> Result>, ProtocolError> -where - R: AsyncRead + Unpin, -{ - let mut length_bytes = [0_u8; 4]; - match reader.read_exact(&mut length_bytes).await { - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), - Err(error) => return Err(error.into()), - } - - let length = u32::from_le_bytes(length_bytes) as usize; - if length > MAX_FRAME_LEN { - return Err(ProtocolError::FrameTooLarge(length)); - } - - let mut payload = vec![0_u8; length]; - reader.read_exact(&mut payload).await?; - Ok(Some(payload)) -} - -pub async fn write_frame(writer: &mut W, payload: &[u8]) -> Result<(), ProtocolError> -where - W: AsyncWrite + Unpin, -{ - if payload.len() > MAX_FRAME_LEN { - return Err(ProtocolError::FrameTooLarge(payload.len())); - } - - writer - .write_all(&(payload.len() as u32).to_le_bytes()) - .await?; - writer.write_all(payload).await?; - writer.flush().await?; - Ok(()) -} diff --git a/crates/mux-protocol/src/lib.rs b/crates/mux-protocol/src/lib.rs deleted file mode 100644 index 8aad5ad..0000000 --- a/crates/mux-protocol/src/lib.rs +++ /dev/null @@ -1,27 +0,0 @@ -pub mod client; -pub mod codec; -pub mod framing; -pub mod types; - -pub mod generated { - #![allow( - clippy::all, - dead_code, - non_camel_case_types, - non_snake_case, - non_upper_case_globals, - unused_imports - )] - include!(concat!(env!("OUT_DIR"), "/embers_generated.rs")); -} - -pub use client::ProtocolClient; -pub use codec::{ - ProtocolError, decode_client_message, decode_server_envelope, encode_client_message, - encode_server_envelope, -}; -pub use framing::{MAX_FRAME_LEN, read_frame, write_frame}; -pub use types::{ - ClientMessage, ErrorResponse, HeartbeatEvent, PingRequest, PingResponse, ServerEnvelope, - ServerEvent, ServerResponse, -}; diff --git a/crates/mux-protocol/src/types.rs b/crates/mux-protocol/src/types.rs deleted file mode 100644 index 58ee521..0000000 --- a/crates/mux-protocol/src/types.rs +++ /dev/null @@ -1,63 +0,0 @@ -use mux_core::{RequestId, WireError}; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PingRequest { - pub request_id: RequestId, - pub payload: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PingResponse { - pub request_id: RequestId, - pub payload: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ErrorResponse { - pub request_id: Option, - pub error: WireError, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct HeartbeatEvent { - pub message: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ClientMessage { - Ping(PingRequest), -} - -impl ClientMessage { - pub fn request_id(&self) -> RequestId { - match self { - Self::Ping(request) => request.request_id, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ServerResponse { - Pong(PingResponse), - Error(ErrorResponse), -} - -impl ServerResponse { - pub fn request_id(&self) -> Option { - match self { - Self::Pong(response) => Some(response.request_id), - Self::Error(response) => response.request_id, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ServerEvent { - Heartbeat(HeartbeatEvent), -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ServerEnvelope { - Response(ServerResponse), - Event(ServerEvent), -} diff --git a/crates/mux-protocol/tests/ping_round_trip.rs b/crates/mux-protocol/tests/ping_round_trip.rs deleted file mode 100644 index 50ce116..0000000 --- a/crates/mux-protocol/tests/ping_round_trip.rs +++ /dev/null @@ -1,32 +0,0 @@ -use mux_core::{RequestId, init_test_tracing}; -use mux_protocol::{ - ClientMessage, PingRequest, decode_client_message, encode_client_message, read_frame, - write_frame, -}; - -#[tokio::test] -async fn ping_round_trips_through_codec_and_frame() { - init_test_tracing(); - - let request = ClientMessage::Ping(PingRequest { - request_id: RequestId(7), - payload: "phase0".to_owned(), - }); - let encoded = encode_client_message(&request).expect("encode request"); - let (mut writer, mut reader) = tokio::io::duplex(256); - - let write_task = tokio::spawn(async move { - write_frame(&mut writer, &encoded) - .await - .expect("write frame"); - }); - - let frame = read_frame(&mut reader) - .await - .expect("read frame") - .expect("frame payload"); - write_task.await.expect("writer task joins"); - - let decoded = decode_client_message(&frame).expect("decode request"); - assert_eq!(decoded, request); -} diff --git a/crates/mux-server/src/config.rs b/crates/mux-server/src/config.rs deleted file mode 100644 index 5716993..0000000 --- a/crates/mux-server/src/config.rs +++ /dev/null @@ -1,12 +0,0 @@ -use std::path::PathBuf; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ServerConfig { - pub socket_path: PathBuf, -} - -impl ServerConfig { - pub fn new(socket_path: PathBuf) -> Self { - Self { socket_path } - } -} diff --git a/crates/mux-server/src/lib.rs b/crates/mux-server/src/lib.rs deleted file mode 100644 index 612fbb6..0000000 --- a/crates/mux-server/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod config; -mod server; - -pub use config::ServerConfig; -pub use server::{Server, ServerHandle}; diff --git a/crates/mux-server/src/server.rs b/crates/mux-server/src/server.rs deleted file mode 100644 index d68fdcf..0000000 --- a/crates/mux-server/src/server.rs +++ /dev/null @@ -1,150 +0,0 @@ -use std::path::{Path, PathBuf}; - -use mux_core::{ErrorCode, MuxError, Result, WireError, request_span}; -use mux_protocol::{ - ClientMessage, ErrorResponse, PingResponse, ProtocolError, ServerEnvelope, ServerResponse, - decode_client_message, encode_server_envelope, read_frame, write_frame, -}; -use tokio::net::{UnixListener, UnixStream}; -use tokio::sync::oneshot; -use tokio::task::JoinHandle; -use tracing::{debug, error, info}; - -use crate::ServerConfig; - -#[derive(Debug)] -pub struct Server { - config: ServerConfig, -} - -impl Server { - pub fn new(config: ServerConfig) -> Self { - Self { config } - } - - pub async fn start(self) -> Result { - if self.config.socket_path.exists() { - std::fs::remove_file(&self.config.socket_path)?; - } - - let listener = UnixListener::bind(&self.config.socket_path)?; - let socket_path = self.config.socket_path.clone(); - let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); - - let join = tokio::spawn(async move { - let _cleanup = SocketCleanup::new(socket_path.clone()); - info!(socket_path = %socket_path.display(), "mux server listening"); - - loop { - tokio::select! { - _ = &mut shutdown_rx => { - debug!("server shutdown requested"); - break; - } - result = listener.accept() => { - let (stream, _) = result?; - tokio::spawn(async move { - if let Err(error) = handle_connection(stream).await { - error!(%error, "connection failed"); - } - }); - } - } - } - - Ok(()) - }); - - Ok(ServerHandle { - socket_path: self.config.socket_path, - shutdown: Some(shutdown_tx), - join, - }) - } -} - -#[derive(Debug)] -pub struct ServerHandle { - socket_path: PathBuf, - shutdown: Option>, - join: JoinHandle>, -} - -impl ServerHandle { - pub fn socket_path(&self) -> &Path { - &self.socket_path - } - - pub async fn shutdown(mut self) -> Result<()> { - if let Some(sender) = self.shutdown.take() { - let _ = sender.send(()); - } - - self.join - .await - .map_err(|error| MuxError::internal(error.to_string()))? - } -} - -struct SocketCleanup { - socket_path: PathBuf, -} - -impl SocketCleanup { - fn new(socket_path: PathBuf) -> Self { - Self { socket_path } - } -} - -impl Drop for SocketCleanup { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.socket_path); - } -} - -async fn handle_connection(mut stream: UnixStream) -> Result<()> { - loop { - let Some(frame) = read_frame(&mut stream) - .await - .map_err(protocol_error_to_mux)? - else { - debug!("client disconnected"); - return Ok(()); - }; - - let request = decode_client_message(&frame).map_err(protocol_error_to_mux)?; - let span = request_span("handle_request", request.request_id()); - let _entered = span.enter(); - let response = handle_message(request); - let payload = encode_server_envelope(&response).map_err(protocol_error_to_mux)?; - write_frame(&mut stream, &payload) - .await - .map_err(protocol_error_to_mux)?; - } -} - -fn handle_message(message: ClientMessage) -> ServerEnvelope { - match message { - ClientMessage::Ping(request) => { - ServerEnvelope::Response(ServerResponse::Pong(PingResponse { - request_id: request.request_id, - payload: request.payload, - })) - } - } -} - -#[allow(dead_code)] -fn protocol_error_response( - request_id: Option, - error: ProtocolError, -) -> ServerEnvelope { - ServerEnvelope::Response(ServerResponse::Error(ErrorResponse { - request_id, - error: WireError::new(ErrorCode::ProtocolViolation, error.to_string()), - })) -} - -fn protocol_error_to_mux(error: ProtocolError) -> MuxError { - MuxError::protocol(error.to_string()) -} diff --git a/crates/mux-test-support/src/protocol.rs b/crates/mux-test-support/src/protocol.rs deleted file mode 100644 index 3b87583..0000000 --- a/crates/mux-test-support/src/protocol.rs +++ /dev/null @@ -1,34 +0,0 @@ -use std::path::Path; - -use mux_core::{MuxError, Result, new_request_id}; -use mux_protocol::{ClientMessage, PingRequest, ProtocolClient, ServerResponse}; - -#[derive(Debug)] -pub struct TestConnection { - client: ProtocolClient, -} - -impl TestConnection { - pub async fn connect(path: impl AsRef) -> Result { - let client = ProtocolClient::connect(path) - .await - .map_err(|error| MuxError::transport(error.to_string()))?; - Ok(Self { client }) - } - - pub async fn ping(&mut self, payload: impl Into) -> Result { - let response = self - .client - .request(&ClientMessage::Ping(PingRequest { - request_id: new_request_id(), - payload: payload.into(), - })) - .await - .map_err(|error| MuxError::transport(error.to_string()))?; - - match response { - ServerResponse::Pong(pong) => Ok(pong.payload), - ServerResponse::Error(error) => Err(error.error.into()), - } - } -}