diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c188870..1579f87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,12 +24,12 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: - toolchain: 1.90 + toolchain: 1.95.0 components: clippy, rustfmt - - name: Install tmux + - name: Install system dependencies run: | - sudo apt-get update && sudo apt-get install -y tmux + sudo apt-get update && sudo apt-get install -y flatbuffers-compiler tmux tmux new-session -d - name: Cache cargo artifacts diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 5db7894..b76784e 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -30,12 +30,12 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: - toolchain: 1.90 + toolchain: 1.95.0 components: clippy, rustfmt - - name: Install tmux + - name: Install system dependencies run: | - sudo apt-get update && sudo apt-get install -y tmux + sudo apt-get update && sudo apt-get install -y flatbuffers-compiler tmux tmux new-session -d - name: Cache cargo artifacts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dcca73e..144dbd1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,12 +25,12 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: - toolchain: 1.90 + toolchain: 1.95.0 components: clippy, rustfmt - - name: Install tmux + - name: Install system dependencies run: | - sudo apt-get update && sudo apt-get install -y tmux + sudo apt-get update && sudo apt-get install -y flatbuffers-compiler tmux tmux new-session -d - name: Cache cargo artifacts @@ -79,7 +79,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: - toolchain: 1.90 + toolchain: 1.95.0 targets: ${{ matrix.target }} - name: Upload release archive diff --git a/Cargo.lock b/Cargo.lock index da467eb..40106d6 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" @@ -60,24 +74,56 @@ dependencies = [ "serde", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[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 = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[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 = "cassowary" version = "0.3.0" @@ -171,6 +217,35 @@ dependencies = [ "static_assertions", ] +[[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 = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "criterion" version = "0.5.1" @@ -263,6 +338,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.20.11" @@ -298,12 +383,118 @@ dependencies = [ "syn", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[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 = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "embers-client" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ee63c8390cf169a412ca552c291eb6970557a7eb2a18b94010b692426909fe" +dependencies = [ + "async-trait", + "base64", + "directories", + "embers-core", + "embers-protocol", + "rhai", + "rhai-autodocs", + "thiserror", + "tokio", + "tracing", + "unicode-segmentation", + "unicode-width 0.2.0", +] + +[[package]] +name = "embers-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8f29055ee5379383325c860690b95a410ee23dfaee0f79b0b4196f6cb07c542" +dependencies = [ + "serde", + "thiserror", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "embers-protocol" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75eee9247df6290d879bc1029135c4b099b45ecdaddd94af5d6e836104652cea" +dependencies = [ + "embers-core", + "flatbuffers", + "thiserror", + "tokio", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -320,6 +511,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" +dependencies = [ + "bitflags", + "rustc_version", +] + [[package]] name = "fnv" version = "1.0.7" @@ -332,6 +533,39 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[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", + "wasip2", +] + [[package]] name = "half" version = "2.7.1" @@ -343,6 +577,22 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "handlebars" +version = "6.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b3f9296c208515b87bd915a2f5d1163d4b3f863ba83337d7713cf478055948e" +dependencies = [ + "derive_builder", + "log", + "num-order", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -455,12 +705,27 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -491,6 +756,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.0" @@ -509,6 +783,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-modular" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -523,6 +821,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 = "oorandom" @@ -530,6 +831,12 @@ 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" @@ -559,6 +866,55 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "plotters" version = "0.3.7" @@ -587,6 +943,12 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -605,6 +967,12 @@ 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 = "ratatui" version = "0.29.0" @@ -655,6 +1023,17 @@ dependencies = [ "bitflags", ] +[[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", +] + [[package]] name = "regex" version = "1.12.3" @@ -684,6 +1063,57 @@ 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", + "num-traits", + "once_cell", + "rhai_codegen", + "serde", + "serde_json", + "smallvec", + "smartstring", + "thin-vec", + "web-time", +] + +[[package]] +name = "rhai-autodocs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e455384e45e5f96662c778bc03b8a644183d1266407c4a75ec5e5fbe686a944" +dependencies = [ + "handlebars", + "rhai", + "serde", + "serde_json", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -724,6 +1154,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -786,6 +1222,26 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "signal-hook" version = "0.3.18" @@ -822,6 +1278,31 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "serde", + "static_assertions", + "version_check", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] [[package]] name = "static_assertions" @@ -868,6 +1349,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thin-vec" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144f754d318415ac792f9d69fc87abbbfc043ce2ef041c60f16ad828f638717d" +dependencies = [ + "serde", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -888,6 +1378,24 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +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" @@ -898,6 +1406,33 @@ dependencies = [ "serde_json", ] +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "0.8.23" @@ -939,6 +1474,79 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -974,6 +1582,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "valuable" +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 = "walkdir" version = "2.5.0" @@ -990,6 +1610,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -1045,6 +1674,16 @@ dependencies = [ "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" @@ -1183,6 +1822,7 @@ dependencies = [ "wisp-app", "wisp-config", "wisp-core", + "wisp-embers", "wisp-fuzzy", "wisp-preview", "wisp-status", @@ -1197,6 +1837,7 @@ version = "0.1.1" dependencies = [ "wisp-config", "wisp-core", + "wisp-embers", "wisp-tmux", "wisp-zoxide", ] @@ -1218,6 +1859,17 @@ dependencies = [ "criterion", ] +[[package]] +name = "wisp-embers" +version = "0.1.1" +dependencies = [ + "embers-client", + "embers-core", + "embers-protocol", + "thiserror", + "tokio", +] + [[package]] name = "wisp-fuzzy" version = "0.1.1" @@ -1263,6 +1915,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + [[package]] name = "zerocopy" version = "0.8.42" diff --git a/Cargo.toml b/Cargo.toml index 8622b1c..17bb345 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,11 +11,12 @@ members = [ "crates/wisp-zoxide", ] resolver = "3" +exclude = ["crates/wisp-embers"] [workspace.package] version = "0.1.1" edition = "2024" -rust-version = "1.90" +rust-version = "1.95.0" license = "MIT" repository = "https://github.com/Pajn/wisp" @@ -41,6 +42,7 @@ toml = "0.8" wisp-app = { version = "0.1.0", path = "crates/wisp-app" } wisp-config = { version = "0.1.0", path = "crates/wisp-config" } wisp-core = { version = "0.1.0", path = "crates/wisp-core" } +wisp-embers = { version = "0.1.0", path = "crates/wisp-embers" } wisp-fuzzy = { version = "0.1.0", path = "crates/wisp-fuzzy" } wisp-preview = { version = "0.1.0", path = "crates/wisp-preview" } wisp-status = { version = "0.1.0", path = "crates/wisp-status" } diff --git a/README.md b/README.md index 8deb116..e14ada8 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Wisp -Wisp is a native Rust tmux navigation tool inspired by `tmux-sessionx`. It shares one session-aware core across a popup picker, sidebar surfaces, and a compact tmux status-line renderer. +Wisp is a native Rust multiplexer navigation tool inspired by `tmux-sessionx`. It shares one session-aware core across tmux, an Embers backend, sidebar surfaces, and a compact tmux status-line renderer. ## High-level features -- tmux-aware session discovery, switching, and attachment +- tmux session discovery, switching, and attachment, with optional Embers backend support - sidebar pane and sidebar popup surfaces in addition to the main picker - git worktree-aware picker: see only sessions for the current repo, or browse all worktrees - zoxide-backed directory discovery @@ -17,6 +17,7 @@ Wisp is a native Rust tmux navigation tool inspired by `tmux-sessionx`. It share - `wisp-core`: canonical session, alert, reducer, and projection logic - `wisp-config`: config defaults, loading, merge precedence, and validation - `wisp-tmux`: tmux snapshot/actions backend plus polling fallback +- `wisp-embers`: optional Embers snapshot/actions adapter and subscription bridge - `wisp-zoxide`: zoxide provider and normalization - `wisp-preview`: preview generation and cache - `wisp-fuzzy`: matcher abstraction @@ -29,7 +30,8 @@ Wisp is a native Rust tmux navigation tool inspired by `tmux-sessionx`. It share Requirements: -- `tmux` +- `tmux` for tmux-backed flows +- an Embers checkout at `../embers` only when building with `--features embers` - `zoxide` for directory candidates - Rust toolchain new enough for edition 2024 @@ -70,6 +72,16 @@ wisp sidebar-pane wisp statusline install ``` +For Embers, build with the opt-in feature and select the backend with config or an environment override: + +```bash +cargo install --path crates/wisp-bin --features embers +WISP_BACKEND=embers wisp fullscreen +WISP_BACKEND=embers WISP_EMBERS_SOCKET=/tmp/embers.sock wisp popup +``` + +Current Embers support covers the main picker, session actions, previews, live refresh, native floating `wisp popup`, floating `wisp sidebar-popup`, and root-split `wisp sidebar-pane`. `wisp statusline ...` remains tmux-only. + Use `--worktree` (or `-w`) to start the picker in worktree mode, which shows only sessions belonging to worktrees of the current repo alongside worktrees that don't yet have sessions. Example tmux binding: @@ -121,8 +133,11 @@ Config file discovery: ```bash cargo fmt --check -cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo clippy --workspace --all-targets -- -D warnings +cargo clippy --workspace --all-targets --all-features -- -D warnings # requires ../embers cargo test --workspace --all-targets +cargo test --workspace --all-targets --all-features # requires ../embers +cargo test --manifest-path crates/wisp-embers/Cargo.toml --test integration # requires ../embers cargo test -p wisp --test smoke cargo bench -p wisp-core --bench projections --no-run cargo bench -p wisp-status --bench formatting --no-run diff --git a/crates/wisp-app/Cargo.toml b/crates/wisp-app/Cargo.toml index 4597aa4..ebaee31 100644 --- a/crates/wisp-app/Cargo.toml +++ b/crates/wisp-app/Cargo.toml @@ -10,8 +10,13 @@ description = "application state assembly for Wisp" [dependencies] wisp-config.workspace = true wisp-core.workspace = true +wisp-embers = { workspace = true, optional = true } wisp-tmux.workspace = true wisp-zoxide.workspace = true +[features] +default = [] +embers = ["dep:wisp-embers"] + [lints] workspace = true diff --git a/crates/wisp-app/src/lib.rs b/crates/wisp-app/src/lib.rs index a009736..6ebc2c9 100644 --- a/crates/wisp-app/src/lib.rs +++ b/crates/wisp-app/src/lib.rs @@ -7,6 +7,8 @@ use wisp_core::{ WindowRecord, deduplicate_candidates, derive_candidates, preview_request_for_candidate, resolve_action, sort_candidates, }; +#[cfg(feature = "embers")] +use wisp_embers::EmbersSnapshot; use wisp_tmux::TmuxSnapshot; use wisp_zoxide::DirectoryEntry; @@ -108,9 +110,16 @@ impl Default for CandidateBuildOptions { } } +#[derive(Debug, Clone, PartialEq)] +pub enum BackendSnapshot { + Tmux(TmuxSnapshot), + #[cfg(feature = "embers")] + Embers(EmbersSnapshot), +} + #[derive(Debug, Clone, PartialEq)] pub struct CandidateSources { - pub tmux: TmuxSnapshot, + pub backend: BackendSnapshot, pub zoxide: Vec, } @@ -290,13 +299,44 @@ pub fn rebuild_candidates( #[must_use] pub fn build_domain_state(sources: &CandidateSources) -> DomainState { - let sessions = sources - .tmux + let (sessions, clients, previous_session_by_client) = match &sources.backend { + BackendSnapshot::Tmux(snapshot) => build_tmux_state(snapshot), + #[cfg(feature = "embers")] + BackendSnapshot::Embers(snapshot) => build_embers_state(snapshot), + }; + let directories = sources + .zoxide + .iter() + .map(|entry| DirectoryRecord { + path: entry.path.clone(), + score: entry.score, + exists: entry.exists, + }) + .collect(); + + let mut state = DomainState { + sessions, + clients, + previous_session_by_client, + directories, + config: Default::default(), + }; + state.recompute_aggregates(); + state +} + +fn build_tmux_state( + snapshot: &TmuxSnapshot, +) -> ( + std::collections::BTreeMap, + std::collections::BTreeMap, + std::collections::BTreeMap, +) { + let sessions = snapshot .sessions .iter() .map(|session| { - let windows = sources - .tmux + let windows = snapshot .windows .iter() .filter(|window| window.session_name == session.name) @@ -338,7 +378,7 @@ pub fn build_domain_state(sources: &CandidateSources) -> DomainState { session.name.clone(), SessionRecord { id: session.name.clone(), - tmux_id: Some(session.id.clone()), + native_id: Some(session.id.clone()), name: session.name.clone(), attached: session.attached, windows, @@ -351,43 +391,137 @@ pub fn build_domain_state(sources: &CandidateSources) -> DomainState { ) }) .collect(); - let clients = sources - .tmux + let clients = snapshot .context .session_name .as_ref() - .zip(sources.tmux.context.window_index) + .zip(snapshot.context.window_index) .map(|(session_name, window_index)| { ( "default".to_string(), ClientFocus { session_id: session_name.clone(), window_id: format!("{session_name}:{window_index}"), - pane_id: sources.tmux.context.pane_id.clone(), + pane_id: snapshot.context.pane_id.clone(), }, ) }) .into_iter() .collect(); - let directories = sources - .zoxide + (sessions, clients, Default::default()) +} + +#[cfg(feature = "embers")] +fn build_embers_state( + snapshot: &EmbersSnapshot, +) -> ( + std::collections::BTreeMap, + std::collections::BTreeMap, + std::collections::BTreeMap, +) { + let sessions = snapshot + .sessions .iter() - .map(|entry| DirectoryRecord { - path: entry.path.clone(), - score: entry.score, - exists: entry.exists, + .map(|session| { + let windows = snapshot + .windows + .iter() + .filter(|window| window.session_name == session.name) + .map(|window| { + let window_id = format!("{}:{}", session.name, window.index); + let panes = snapshot + .panes + .iter() + .filter(|pane| { + pane.session_name == session.name && pane.window_index == window.index + }) + .enumerate() + .map(|(pane_index, pane)| { + ( + pane.pane_id.clone(), + PaneRecord { + id: pane.pane_id.clone(), + index: i32::try_from(pane_index + 1) + .expect("pane index should fit in i32"), + title: Some(pane.title.clone()), + current_path: pane.current_path.clone(), + current_command: pane.current_command.clone(), + is_active: pane.active, + }, + ) + }) + .collect(); + ( + window_id.clone(), + WindowRecord { + id: window_id, + index: i32::try_from(window.index) + .expect("window index should fit in i32"), + name: window.name.clone(), + active: window.active, + panes, + alerts: AlertState { + activity: window.activity, + bell: window.bell, + silence: window.silence, + unseen_output: false, + }, + has_unseen: false, + current_path: window.current_path.clone(), + active_command: window.current_command.clone(), + }, + ) + }) + .collect(); + + ( + session.name.clone(), + SessionRecord { + id: session.name.clone(), + native_id: Some(session.native_id.clone()), + name: session.name.clone(), + attached: session.attached, + windows, + aggregate_alerts: Default::default(), + has_unseen: false, + sort_key: SessionSortKey { + last_activity: session.last_activity, + }, + }, + ) }) .collect(); - - let mut state = DomainState { - sessions, - clients, - previous_session_by_client: Default::default(), - directories, - config: Default::default(), - }; - state.recompute_aggregates(); - state + let clients = snapshot + .context + .current_session_name + .as_ref() + .zip(snapshot.context.current_window_index) + .map(|(session_name, window_index)| { + let client_id = snapshot + .context + .client_id + .clone() + .unwrap_or_else(|| "default".to_string()); + ( + client_id, + ClientFocus { + session_id: session_name.clone(), + window_id: format!("{session_name}:{window_index}"), + pane_id: snapshot.context.pane_id.clone(), + }, + ) + }) + .into_iter() + .collect(); + let previous_session_by_client = snapshot + .context + .client_id + .as_ref() + .zip(snapshot.context.previous_session_name.as_ref()) + .map(|(client_id, session_name)| (client_id.clone(), session_name.clone())) + .into_iter() + .collect(); + (sessions, clients, previous_session_by_client) } #[cfg(test)] @@ -398,14 +532,18 @@ mod tests { use wisp_core::{ AttentionBadge, Candidate, DirectoryMetadata, PreviewContent, PreviewKey, SessionMetadata, }; + #[cfg(feature = "embers")] + use wisp_embers::{ + EmbersActivityState, EmbersContext, EmbersPane, EmbersSession, EmbersSnapshot, EmbersWindow, + }; use wisp_tmux::{ TmuxCapabilities, TmuxContext, TmuxSession, TmuxSnapshot, TmuxVersion, TmuxWindow, }; use wisp_zoxide::DirectoryEntry; use crate::{ - AppCommand, AppMode, AppState, CandidateBuildOptions, CandidateSources, StatusLevel, - UserIntent, build_domain_state, rebuild_candidates, + AppCommand, AppMode, AppState, BackendSnapshot, CandidateBuildOptions, CandidateSources, + StatusLevel, UserIntent, build_domain_state, rebuild_candidates, }; #[test] @@ -480,7 +618,7 @@ mod tests { #[test] fn build_domain_state_preserves_tmux_alert_flags() { let state = build_domain_state(&CandidateSources { - tmux: TmuxSnapshot { + backend: BackendSnapshot::Tmux(TmuxSnapshot { context: TmuxContext { session_name: Some("alpha".to_string()), window_index: Some(1), @@ -516,7 +654,7 @@ mod tests { current_path: Some(PathBuf::from("/tmp")), current_command: Some("bash".to_string()), }], - }, + }), zoxide: Vec::new(), }); @@ -588,7 +726,7 @@ mod tests { let existing = std::env::temp_dir().join("wisp-app-candidate-existing"); std::fs::create_dir_all(&existing).expect("existing directory"); let sources = CandidateSources { - tmux: TmuxSnapshot { + backend: BackendSnapshot::Tmux(TmuxSnapshot { context: TmuxContext::default(), capabilities: TmuxCapabilities { version: TmuxVersion { @@ -610,7 +748,7 @@ mod tests { last_activity: Some(5), }], windows: Vec::new(), - }, + }), zoxide: vec![DirectoryEntry { path: existing.clone(), score: Some(10.0), @@ -644,7 +782,7 @@ mod tests { #[test] fn omits_missing_zoxide_directories_by_default() { let sources = CandidateSources { - tmux: TmuxSnapshot { + backend: BackendSnapshot::Tmux(TmuxSnapshot { context: TmuxContext::default(), capabilities: TmuxCapabilities { version: TmuxVersion { @@ -659,7 +797,7 @@ mod tests { }, sessions: Vec::new(), windows: Vec::new(), - }, + }), zoxide: vec![DirectoryEntry { path: PathBuf::from("/path/that/does/not/exist"), score: Some(99.0), @@ -671,4 +809,86 @@ mod tests { assert!(candidates.is_empty()); } + + #[cfg(feature = "embers")] + #[test] + fn build_domain_state_projects_embers_tabs_and_panes() { + let state = build_domain_state(&CandidateSources { + backend: BackendSnapshot::Embers(EmbersSnapshot { + context: EmbersContext { + client_id: Some("client-7".to_string()), + current_session_name: Some("alpha".to_string()), + current_window_index: Some(2), + pane_id: Some("201".to_string()), + previous_session_name: None, + }, + sessions: vec![EmbersSession { + native_id: "7".to_string(), + name: "alpha".to_string(), + attached: true, + last_activity: None, + }], + windows: vec![ + EmbersWindow { + session_name: "alpha".to_string(), + index: 1, + name: "editor".to_string(), + active: false, + activity: false, + bell: false, + silence: false, + current_path: Some(PathBuf::from("/tmp/editor")), + current_command: Some("nvim".to_string()), + }, + EmbersWindow { + session_name: "alpha".to_string(), + index: 2, + name: "shell".to_string(), + active: true, + activity: true, + bell: false, + silence: false, + current_path: Some(PathBuf::from("/tmp/shell")), + current_command: Some("bash".to_string()), + }, + ], + panes: vec![ + EmbersPane { + session_name: "alpha".to_string(), + window_index: 1, + pane_id: "101".to_string(), + title: "editor".to_string(), + active: false, + current_path: Some(PathBuf::from("/tmp/editor")), + current_command: Some("nvim".to_string()), + activity: EmbersActivityState::Idle, + }, + EmbersPane { + session_name: "alpha".to_string(), + window_index: 2, + pane_id: "201".to_string(), + title: "shell".to_string(), + active: true, + current_path: Some(PathBuf::from("/tmp/shell")), + current_command: Some("bash".to_string()), + activity: EmbersActivityState::Activity, + }, + ], + }), + zoxide: Vec::new(), + }); + + assert_eq!( + state.current_session_id(Some("client-7")), + Some(&"alpha".to_string()) + ); + assert_eq!(state.sessions["alpha"].native_id.as_deref(), Some("7")); + assert_eq!(state.sessions["alpha"].windows.len(), 2); + assert_eq!(state.sessions["alpha"].windows["alpha:2"].panes.len(), 1); + assert!(state.sessions["alpha"].windows["alpha:2"].alerts.activity); + assert_eq!( + state.sessions["alpha"].windows["alpha:2"].current_path, + Some(PathBuf::from("/tmp/shell")) + ); + } } diff --git a/crates/wisp-bin/Cargo.toml b/crates/wisp-bin/Cargo.toml index 210ba0d..e7fe7ea 100644 --- a/crates/wisp-bin/Cargo.toml +++ b/crates/wisp-bin/Cargo.toml @@ -27,6 +27,7 @@ ratatui.workspace = true wisp-app.workspace = true wisp-config.workspace = true wisp-core.workspace = true +wisp-embers = { workspace = true, optional = true } wisp-fuzzy.workspace = true wisp-preview.workspace = true wisp-status.workspace = true @@ -34,5 +35,9 @@ wisp-tmux.workspace = true wisp-ui.workspace = true wisp-zoxide.workspace = true +[features] +default = [] +embers = ["dep:wisp-embers", "wisp-app/embers"] + [lints] workspace = true diff --git a/crates/wisp-bin/src/main.rs b/crates/wisp-bin/src/main.rs index af0dfc2..eb471b5 100644 --- a/crates/wisp-bin/src/main.rs +++ b/crates/wisp-bin/src/main.rs @@ -20,9 +20,11 @@ use crossterm::{ }; use ratatui::widgets::Clear; use ratatui::{Terminal, backend::CrosstermBackend}; -use wisp_app::{CandidateSources, build_domain_state}; +use wisp_app::{BackendSnapshot, CandidateSources, build_domain_state}; +#[cfg(feature = "embers")] +use wisp_config::Dimension; use wisp_config::{ - CliOverrides, KeyAction, LoadOptions, ResolvedConfig, SessionSortMode, load_config, + BackendKind, CliOverrides, KeyAction, LoadOptions, ResolvedConfig, SessionSortMode, load_config, }; use wisp_core::{ DomainState, GitBranchStatus, GitBranchSync, PickerMode, PreviewContent, PreviewKey, @@ -30,6 +32,8 @@ use wisp_core::{ derive_session_list_with_worktrees, derive_status_items, sanitize_session_name, sort_session_list_items, }; +#[cfg(feature = "embers")] +use wisp_embers::{EmbersClient, EmbersJoinPlacement}; use wisp_fuzzy::{MatchItem, Matcher, SimpleMatcher}; use wisp_preview::{ ActivePanePreviewProvider, FilesystemPreviewProvider, PreviewProvider, @@ -49,6 +53,10 @@ const PREVIEW_REFRESH_DEBOUNCE: Duration = Duration::from_millis(400); const DEFAULT_CLIENT_ID: &str = "default"; const SIDEBAR_PANE_TITLE: &str = "Wisp Sidebar"; const SIDEBAR_PANE_WIDTH: u16 = 36; +#[cfg(feature = "embers")] +const EMBERS_SURFACE_ENV: &str = "WISP_EMBERS_SURFACE"; +#[cfg(feature = "embers")] +const EMBERS_SIDEBAR_PANE_SURFACE: &str = "sidebar-pane"; const STATUSLINE_REFRESH_HOOKS: &[&str] = &[ "client-session-changed[200]", "session-created[200]", @@ -58,7 +66,7 @@ const STATUSLINE_REFRESH_HOOKS: &[&str] = &[ const STATUSLINE_REFRESH_COMMAND: &str = "refresh-client -S"; struct CombinedPreviewProvider<'a> { - pane: &'a ActivePanePreviewProvider, + session: &'a dyn PreviewProvider, filesystem: &'a FilesystemPreviewProvider, } @@ -81,7 +89,99 @@ impl PreviewProvider for CombinedPreviewProvider<'_> { PreviewRequest::Directory { .. } | PreviewRequest::File { .. } | PreviewRequest::Metadata { .. } => self.filesystem.generate(request), - PreviewRequest::SessionSummary { .. } => self.pane.generate(request), + PreviewRequest::SessionSummary { .. } => self.session.generate(request), + } + } +} + +struct TerminalTeardown { + active: bool, +} + +impl TerminalTeardown { + fn active() -> Self { + Self { active: true } + } + + fn restore( + &mut self, + terminal: &mut Terminal>, + ) -> Result<(), Box> { + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + self.active = false; + Ok(()) + } +} + +impl Drop for TerminalTeardown { + fn drop(&mut self) { + if self.active { + let _ = disable_raw_mode(); + let _ = execute!(stdout(), LeaveAlternateScreen); + } + } +} + +#[derive(Clone)] +enum RuntimeBackend { + Tmux, + #[cfg(feature = "embers")] + Embers(Arc), +} + +#[cfg(feature = "embers")] +struct EmbersPanePreviewProvider { + embers: Arc, + max_lines: usize, +} + +#[cfg(feature = "embers")] +impl EmbersPanePreviewProvider { + fn new(embers: Arc) -> Self { + Self { + embers, + max_lines: 40, + } + } +} + +#[cfg(feature = "embers")] +impl PreviewProvider for EmbersPanePreviewProvider { + fn can_preview(&self, request: &PreviewRequest) -> bool { + matches!(request, PreviewRequest::SessionSummary { .. }) + } + + fn generate( + &self, + request: &PreviewRequest, + ) -> Result { + let PreviewRequest::SessionSummary { session_name, .. } = request else { + return Err(wisp_preview::PreviewError::Unsupported); + }; + let captured = self + .embers + .capture_session_preview(session_name, self.max_lines) + .map_err(|error| wisp_preview::PreviewError::SessionCapture { + session_name: session_name.clone(), + message: error.to_string(), + })?; + Ok(PreviewContent::from_text_tail( + format!("Pane {session_name}"), + captured, + self.max_lines, + )) + } +} + +#[cfg(feature = "embers")] +impl RuntimeBackend { + fn poll_updates(&self) -> Result> { + match self { + Self::Tmux => Ok(false), + #[cfg(feature = "embers")] + Self::Embers(client) => client.poll_updates().map_err(|error| error.into()), } } } @@ -270,8 +370,43 @@ struct SidebarUiState { #[derive(Debug, Clone, PartialEq, Eq)] struct SidebarRuntime { session_name: String, - home_window_index: u32, - pane_id: Option, + target: SidebarTarget, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SidebarTarget { + Tmux { + home_window_index: u32, + pane_id: Option, + }, + #[cfg(feature = "embers")] + Embers { buffer_id: String }, +} + +impl SidebarRuntime { + fn session_name(&self) -> &str { + &self.session_name + } + + fn tmux_pane_id(&self) -> Option<&str> { + match &self.target { + SidebarTarget::Tmux { pane_id, .. } => pane_id.as_deref(), + #[cfg(feature = "embers")] + SidebarTarget::Embers { .. } => None, + } + } + + #[cfg(feature = "embers")] + fn embers_buffer_id(&self) -> Option<&str> { + match &self.target { + SidebarTarget::Tmux { .. } => None, + SidebarTarget::Embers { buffer_id } => Some(buffer_id.as_str()), + } + } + + fn rename_session(&mut self, new_name: String) { + self.session_name = new_name; + } } fn main() -> ExitCode { @@ -374,13 +509,11 @@ fn execute_cli(cli: ParsedCli) -> Result<(), Box> { match cli { ParsedCli::Ui(mode) => { let config = load_runtime_config()?; - run_surface(mode.surface_kind(), &config, mode.picker_mode()) + let backend = load_runtime_backend(&config)?; + run_surface(mode.surface_kind(), &config, mode.picker_mode(), &backend) } ParsedCli::Public(cli) => match cli.command { - Command::Doctor(_) => { - doctor(); - Ok(()) - } + Command::Doctor(_) => doctor(), Command::PrintConfig(_) => { let config = load_runtime_config()?; println!("{config:#?}"); @@ -388,31 +521,42 @@ fn execute_cli(cli: ParsedCli) -> Result<(), Box> { } Command::Fullscreen(fullscreen_cmd) => { let config = load_runtime_config()?; + let backend = load_runtime_backend(&config)?; let mode = if fullscreen_cmd.worktree { PickerMode::Worktree } else { PickerMode::AllSessions }; - run_surface(SurfaceKind::Picker, &config, mode) + run_surface(SurfaceKind::Picker, &config, mode, &backend) } Command::Popup(popup_cmd) => { let config = load_runtime_config()?; + let backend = load_runtime_backend(&config)?; let mode = if popup_cmd.worktree { PickerMode::Worktree } else { PickerMode::AllSessions }; - open_popup_or_run_inline(SurfaceKind::Picker, &config, mode) + open_popup_or_run_inline(SurfaceKind::Picker, &config, mode, &backend) } Command::SidebarPopup(_) => { let config = load_runtime_config()?; - open_sidebar_popup_or_run_inline(&config) + let backend = load_runtime_backend(&config)?; + open_sidebar_popup_or_run_inline(&config, &backend) + } + Command::SidebarPane(_) => { + let config = load_runtime_config()?; + let backend = load_runtime_backend(&config)?; + open_sidebar_pane(&backend) } - Command::SidebarPane(_) => open_sidebar_pane(), Command::Statusline(statusline) => { validate_statusline_flags(&statusline)?; let config = load_runtime_config()?; - run_statusline_group(&config, statusline) + if selected_backend_kind(&config) == BackendKind::Embers { + return Err(embers_unsupported("wisp statusline")); + } + let backend = load_runtime_backend(&config)?; + run_statusline_group(&config, statusline, &backend) } }, } @@ -426,24 +570,103 @@ fn load_runtime_config() -> Result> { .map_err(|error| Box::new(error) as Box) } -fn doctor() { +fn selected_backend_kind(config: &ResolvedConfig) -> BackendKind { + match config.backend.kind { + BackendKind::Auto => { + #[cfg(feature = "embers")] + if resolve_embers_socket_path(config).is_some() { + return BackendKind::Embers; + } + BackendKind::Tmux + } + kind => kind, + } +} + +#[cfg(feature = "embers")] +fn resolve_embers_socket_path(config: &ResolvedConfig) -> Option { + config + .embers + .socket_path + .clone() + .or_else(|| env::var_os("EMBERS_SOCKET").map(PathBuf::from)) +} + +fn load_runtime_backend(config: &ResolvedConfig) -> Result> { + match config.backend.kind { + BackendKind::Tmux => Ok(RuntimeBackend::Tmux), + #[cfg(feature = "embers")] + BackendKind::Embers => { + let socket_path = resolve_embers_socket_path(config).ok_or_else(|| { + "embers backend selected, but no socket path was configured".to_string() + })?; + Ok(RuntimeBackend::Embers(Arc::new(EmbersClient::connect( + socket_path, + )?))) + } + #[cfg(not(feature = "embers"))] + BackendKind::Embers => Err(embers_feature_disabled()), + #[cfg(feature = "embers")] + BackendKind::Auto => match resolve_embers_socket_path(config) { + Some(socket_path) => Ok(RuntimeBackend::Embers(Arc::new(EmbersClient::connect( + socket_path, + )?))), + None => Ok(RuntimeBackend::Tmux), + }, + #[cfg(not(feature = "embers"))] + BackendKind::Auto => Ok(RuntimeBackend::Tmux), + } +} + +#[cfg(not(feature = "embers"))] +fn embers_feature_disabled() -> Box { + "embers backend support was not compiled in; rebuild with `--features embers`".into() +} + +fn embers_unsupported(feature: &str) -> Box { + format!("{feature} is not supported on the embers backend yet").into() +} + +fn doctor() -> Result<(), Box> { + let config = load_runtime_config()?; + let backend_kind = selected_backend_kind(&config); let tmux = CommandTmuxClient::new(); let zoxide = CommandZoxideProvider::new(); println!("wisp doctor"); println!(); - match tmux.capabilities() { - Ok(capabilities) => { - println!( - "tmux: {}.{} (popup: {}, status clicks: {}, mouse: {})", - capabilities.version.major, - capabilities.version.minor, - capabilities.supports_popup, - capabilities.supports_status_mouse_ranges, - capabilities.mouse_enabled - ); + println!( + "backend: {}", + match backend_kind { + BackendKind::Tmux => "tmux", + BackendKind::Embers => "embers", + BackendKind::Auto => unreachable!("auto backend should resolve before doctor output"), + } + ); + match backend_kind { + BackendKind::Tmux => match tmux.capabilities() { + Ok(capabilities) => { + println!( + "tmux: {}.{} (popup: {}, status clicks: {}, mouse: {})", + capabilities.version.major, + capabilities.version.minor, + capabilities.supports_popup, + capabilities.supports_status_mouse_ranges, + capabilities.mouse_enabled + ); + } + Err(error) => println!("tmux: unavailable ({error})"), + }, + BackendKind::Embers => { + #[cfg(feature = "embers")] + match resolve_embers_socket_path(&config) { + Some(socket_path) => println!("embers: available ({})", socket_path.display()), + None => println!("embers: socket not configured"), + } + #[cfg(not(feature = "embers"))] + println!("embers: support not compiled in"); } - Err(error) => println!("tmux: unavailable ({error})"), + BackendKind::Auto => unreachable!("auto backend should resolve before doctor output"), } match zoxide.load_entries(5) { @@ -451,8 +674,18 @@ fn doctor() { Err(error) => println!("zoxide: unavailable ({error})"), } - let backend = PollingTmuxBackend::new(CommandTmuxClient::new()); - println!("event strategy: {:?}", backend.event_strategy()); + println!( + "event strategy: {}", + match backend_kind { + BackendKind::Tmux => "PollingFallback", + #[cfg(feature = "embers")] + BackendKind::Embers => "SubscriptionStream", + #[cfg(not(feature = "embers"))] + BackendKind::Embers => "Disabled", + BackendKind::Auto => unreachable!("auto backend should resolve before doctor output"), + } + ); + Ok(()) } fn validate_statusline_flags(statusline: &StatuslineGroupCommand) -> Result<(), Box> { @@ -482,7 +715,11 @@ fn validate_statusline_flags(statusline: &StatuslineGroupCommand) -> Result<(), fn run_statusline_group( config: &ResolvedConfig, command: StatuslineGroupCommand, + backend: &RuntimeBackend, ) -> Result<(), Box> { + if !matches!(backend, RuntimeBackend::Tmux) { + return Err(embers_unsupported("wisp statusline")); + } match command.command { StatuslineSubcommand::Install(args) => install_statusline(config, args.line), StatuslineSubcommand::Render(args) => { @@ -503,7 +740,7 @@ fn render_statusline( config: &ResolvedConfig, force: Option, ) -> Result<(), Box> { - let state = load_domain_state()?; + let state = load_domain_state(&RuntimeBackend::Tmux)?; let items = derive_status_items(&state, Some("default")); let tmux = CommandTmuxClient::new(); let capabilities = tmux.capabilities()?; @@ -644,6 +881,26 @@ fn create_session_from_query( Ok(true) } +#[cfg(feature = "embers")] +fn create_session_from_query_embers( + embers: &EmbersClient, + zoxide: &impl ZoxideProvider, + query: &str, + fallback_directory: &Path, +) -> Result> { + let session_name = query.trim(); + if session_name.is_empty() { + return Ok(false); + } + + let directory = zoxide + .query_directory(session_name)? + .map(|entry| entry.path) + .unwrap_or_else(|| fallback_directory.to_path_buf()); + embers.create_or_switch_session(session_name, &directory)?; + Ok(true) +} + fn create_session_with_basename( tmux: &impl TmuxClient, basename: &str, @@ -664,6 +921,27 @@ fn create_session_with_basename( Ok(true) } +#[cfg(feature = "embers")] +fn create_session_with_basename_embers( + embers: &EmbersClient, + basename: &str, + path: &Path, +) -> Result> { + let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let existing_sessions = embers.list_session_names()?; + let session_name = if existing_sessions.iter().any(|name| name == basename) { + format!( + "{}-{:08x}", + sanitize_session_name(&canonical_path), + stable_path_hash(&canonical_path) as u32 + ) + } else { + basename.to_string() + }; + embers.create_or_switch_session(&session_name, &canonical_path)?; + Ok(true) +} + fn session_items_for_picker_mode( state: &DomainState, client_id: Option<&str>, @@ -757,76 +1035,312 @@ fn activate_filter_selection( Ok(false) } -fn open_popup_or_run_inline( - kind: SurfaceKind, - config: &ResolvedConfig, - mode: PickerMode, -) -> Result<(), Box> { - let backend = PollingTmuxBackend::new(CommandTmuxClient::new()); - let command = PopupCommand { - program: env::current_exe()?, - args: vec![ +#[cfg(feature = "embers")] +fn activate_filter_selection_embers( + embers: &EmbersClient, + zoxide: &impl ZoxideProvider, + filtered: &[SessionListItem], + selected: usize, + query: &str, + fallback_directory: &Path, + force_create_from_query: bool, +) -> Result> { + if force_create_from_query || filtered.get(selected).is_none() { + return create_session_from_query_embers(embers, zoxide, query, fallback_directory); + } + + if let Some(item) = filtered.get(selected) { + match item.kind { + wisp_core::SessionListItemKind::Info => return Ok(false), + wisp_core::SessionListItemKind::Worktree => { + if let Some(worktree_path) = &item.worktree_path { + let basename = worktree_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("worktree"); + return create_session_with_basename_embers(embers, basename, worktree_path); + } + return Ok(false); + } + wisp_core::SessionListItemKind::Zoxide => { + if let Some(path) = &item.worktree_path { + let folder_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("zoxide"); + if !folder_name.is_empty() { + return create_session_with_basename_embers(embers, folder_name, path); + } + } + return Ok(false); + } + _ => {} + } + + embers.switch_session(&item.session_id)?; + return Ok(true); + } + + Ok(false) +} + +#[cfg(feature = "embers")] +fn surface_command_args(kind: SurfaceKind, mode: PickerMode) -> Vec { + match kind { + SurfaceKind::Picker => vec![ "ui".to_string(), match mode { PickerMode::AllSessions => "picker".to_string(), PickerMode::Worktree => "picker-worktree".to_string(), }, ], - }; - match backend.open_popup(&PopupSpec { - command, - options: PopupOptions::default(), - }) { - Ok(()) => Ok(()), - Err(TmuxError::PopupUnavailable { .. }) | Err(TmuxError::CommandFailed { .. }) => { - run_surface(kind, config, mode) + SurfaceKind::SidebarCompact => vec!["ui".to_string(), "sidebar-compact".to_string()], + SurfaceKind::SidebarExpanded => vec!["ui".to_string(), "sidebar-expanded".to_string()], + } +} + +#[cfg(feature = "embers")] +fn surface_title(kind: SurfaceKind) -> &'static str { + match kind { + SurfaceKind::Picker => "Wisp Picker", + SurfaceKind::SidebarCompact | SurfaceKind::SidebarExpanded => SIDEBAR_PANE_TITLE, + } +} + +#[cfg(feature = "embers")] +fn embers_sidebar_env(persistent: bool) -> BTreeMap { + let mut env = BTreeMap::new(); + if persistent { + env.insert( + EMBERS_SURFACE_ENV.to_string(), + EMBERS_SIDEBAR_PANE_SURFACE.to_string(), + ); + } + env +} + +#[cfg(feature = "embers")] +fn resolve_dimension_to_cells(dimension: &Dimension, max: u16) -> u16 { + match dimension { + Dimension::Percent(percent) => { + ((u32::from(max) * u32::from(*percent)) / 100).clamp(1, u32::from(max.max(1))) as u16 } - Err(error) => Err(Box::new(error)), + Dimension::Cells(cells) => (*cells).clamp(1, max.max(1)), } } -fn open_sidebar_popup_or_run_inline(config: &ResolvedConfig) -> Result<(), Box> { - let backend = PollingTmuxBackend::new(CommandTmuxClient::new()); - let command = PopupCommand { - program: env::current_exe()?, - args: vec!["ui".to_string(), "sidebar-compact".to_string()], - }; - match backend.open_popup(&PopupSpec { - command, - options: PopupOptions { - width: wisp_tmux::PopupDimension::Percent(35), - height: wisp_tmux::PopupDimension::Percent(85), - title: Some("Wisp Sidebar".to_string()), - }, - }) { +#[cfg(feature = "embers")] +fn create_embers_floating_for_buffer( + client: &EmbersClient, + buffer_id: &str, + title: Option<&str>, + width: u16, + height: u16, + focus: bool, + close_on_empty: bool, +) -> Result<(), Box> { + match client.create_floating_for_buffer_in_current_session( + buffer_id, + title, + width, + height, + focus, + close_on_empty, + ) { Ok(()) => Ok(()), - Err(TmuxError::PopupUnavailable { .. }) | Err(TmuxError::CommandFailed { .. }) => { - run_surface(SurfaceKind::SidebarCompact, config, PickerMode::AllSessions) + Err(error) => { + let _ = client.kill_buffer(buffer_id); + Err(Box::new(error)) } - Err(error) => Err(Box::new(error)), } } -fn open_sidebar_pane() -> Result<(), Box> { - let tmux = CommandTmuxClient::new(); - reconcile_sidebar_for_current_context( - &tmux, - &sidebar_surface_command(env::current_exe()?), - None, - )?; - Ok(()) +#[cfg(feature = "embers")] +fn join_embers_buffer_to_current_session_root( + client: &EmbersClient, + buffer_id: &str, + placement: EmbersJoinPlacement, + leading_size: Option, + focus: bool, +) -> Result> { + match client.join_buffer_to_current_session_root(buffer_id, placement, leading_size, focus) { + Ok(session_name) => Ok(session_name), + Err(error) => { + let _ = client.kill_buffer(buffer_id); + Err(Box::new(error)) + } + } +} + +fn open_popup_or_run_inline( + kind: SurfaceKind, + config: &ResolvedConfig, + mode: PickerMode, + backend: &RuntimeBackend, +) -> Result<(), Box> { + match backend { + RuntimeBackend::Tmux => { + let tmux_backend = PollingTmuxBackend::new(CommandTmuxClient::new()); + let command = PopupCommand { + program: env::current_exe()?, + args: vec![ + "ui".to_string(), + match mode { + PickerMode::AllSessions => "picker".to_string(), + PickerMode::Worktree => "picker-worktree".to_string(), + }, + ], + }; + match tmux_backend.open_popup(&PopupSpec { + command, + options: PopupOptions::default(), + }) { + Ok(()) => Ok(()), + Err(TmuxError::PopupUnavailable { .. }) | Err(TmuxError::CommandFailed { .. }) => { + run_surface(kind, config, mode, backend) + } + Err(error) => Err(Box::new(error)), + } + } + #[cfg(feature = "embers")] + RuntimeBackend::Embers(client) => { + let (viewport_cols, viewport_rows) = client + .current_session_viewport_size()? + .ok_or_else(|| "embers popup requires an active embers session".to_string())?; + let command = surface_command_args(kind, mode); + let cwd = env::current_dir()?; + let width = resolve_dimension_to_cells(&config.tmux.popup_width, viewport_cols); + let height = resolve_dimension_to_cells(&config.tmux.popup_height, viewport_rows); + let buffer_id = client.create_buffer( + &command, + surface_title(kind), + Some(&cwd), + &BTreeMap::new(), + )?; + create_embers_floating_for_buffer( + client, + &buffer_id, + Some(surface_title(kind)), + width, + height, + true, + true, + )?; + Ok(()) + } + } +} + +fn open_sidebar_popup_or_run_inline( + config: &ResolvedConfig, + backend: &RuntimeBackend, +) -> Result<(), Box> { + match backend { + RuntimeBackend::Tmux => { + let tmux_backend = PollingTmuxBackend::new(CommandTmuxClient::new()); + let command = PopupCommand { + program: env::current_exe()?, + args: vec!["ui".to_string(), "sidebar-compact".to_string()], + }; + match tmux_backend.open_popup(&PopupSpec { + command, + options: PopupOptions { + width: wisp_tmux::PopupDimension::Percent(35), + height: wisp_tmux::PopupDimension::Percent(85), + title: Some("Wisp Sidebar".to_string()), + }, + }) { + Ok(()) => Ok(()), + Err(TmuxError::PopupUnavailable { .. }) | Err(TmuxError::CommandFailed { .. }) => { + run_surface( + SurfaceKind::SidebarCompact, + config, + PickerMode::AllSessions, + backend, + ) + } + Err(error) => Err(Box::new(error)), + } + } + #[cfg(feature = "embers")] + RuntimeBackend::Embers(client) => { + let (viewport_cols, viewport_rows) = + client.current_session_viewport_size()?.ok_or_else(|| { + "embers sidebar-popup requires an active embers session".to_string() + })?; + let command = + surface_command_args(SurfaceKind::SidebarCompact, PickerMode::AllSessions); + let cwd = env::current_dir()?; + let buffer_id = client.create_buffer( + &command, + surface_title(SurfaceKind::SidebarCompact), + Some(&cwd), + &BTreeMap::new(), + )?; + create_embers_floating_for_buffer( + client, + &buffer_id, + Some(surface_title(SurfaceKind::SidebarCompact)), + resolve_dimension_to_cells(&Dimension::Percent(35), viewport_cols), + resolve_dimension_to_cells(&Dimension::Percent(85), viewport_rows), + true, + true, + )?; + Ok(()) + } + } +} + +fn open_sidebar_pane(backend: &RuntimeBackend) -> Result<(), Box> { + match backend { + RuntimeBackend::Tmux => { + let tmux = CommandTmuxClient::new(); + reconcile_sidebar_for_current_context( + &tmux, + &sidebar_surface_command(env::current_exe()?), + None, + )?; + Ok(()) + } + #[cfg(feature = "embers")] + RuntimeBackend::Embers(client) => { + let command = + surface_command_args(SurfaceKind::SidebarCompact, PickerMode::AllSessions); + let cwd = env::current_dir()?; + let buffer_id = client.create_buffer( + &command, + SIDEBAR_PANE_TITLE, + Some(&cwd), + &embers_sidebar_env(true), + )?; + join_embers_buffer_to_current_session_root( + client, + &buffer_id, + EmbersJoinPlacement::Left, + Some(SIDEBAR_PANE_WIDTH), + true, + )?; + Ok(()) + } + } } fn run_surface( kind: SurfaceKind, config: &ResolvedConfig, mode: PickerMode, + backend: &RuntimeBackend, ) -> Result<(), Box> { - let state = load_domain_state()?; + let state = load_domain_state(backend)?; let mut session_sort = config.ui.session_sort; let (mut session_items, mut pending_branch_names, mut branch_status_updates) = rebuild_session_items_for_picker_mode(&state, Some(DEFAULT_CLIENT_ID), mode, session_sort); let mut pane_preview_provider = ActivePanePreviewProvider::new(CommandTmuxClient::new()); + #[cfg(feature = "embers")] + let mut embers_preview_provider = match backend { + RuntimeBackend::Tmux => None, + RuntimeBackend::Embers(client) => Some(EmbersPanePreviewProvider::new(client.clone())), + }; let mut details_preview_provider = SessionDetailsPreviewProvider { state: state.clone(), }; @@ -835,9 +1349,13 @@ fn run_surface( let zoxide = CommandZoxideProvider::new(); let current_directory = env::current_dir()?; let sidebar_command = sidebar_surface_command(env::current_exe()?); - let mut sidebar_runtime = sidebar_runtime(&tmux, kind)?; + let mut sidebar_runtime = match backend { + RuntimeBackend::Tmux => tmux_sidebar_runtime(&tmux, kind)?, + #[cfg(feature = "embers")] + RuntimeBackend::Embers(client) => embers_sidebar_runtime(client, kind)?, + }; let saved_sidebar_state = match &sidebar_runtime { - Some(runtime) => load_sidebar_ui_state(&runtime.session_name)?, + Some(runtime) => load_sidebar_ui_state(runtime.session_name())?, None => None, }; if let Some(saved_state) = &saved_sidebar_state @@ -885,28 +1403,87 @@ fn run_surface( enable_raw_mode()?; execute!(stdout(), EnterAlternateScreen)?; + let mut teardown = TerminalTeardown::active(); let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; let result = loop { pane_preview_provider.max_lines = preview_line_budget(&terminal, show_help)?; + #[cfg(feature = "embers")] + if let Some(provider) = embers_preview_provider.as_mut() { + provider.max_lines = preview_line_budget(&terminal, show_help)?; + } - if let Some(runtime) = &sidebar_runtime - && sidebar_requires_handoff(&tmux, runtime)? - { - persist_sidebar_ui_state( - runtime, - &session_items, - &query, - surface_kind, - session_sort, - selected, - )?; - reconcile_sidebar_for_current_context( + if let Some(runtime) = &mut sidebar_runtime { + #[cfg(feature = "embers")] + let requires_handoff = sidebar_requires_handoff( &tmux, - &sidebar_command, - runtime.pane_id.as_deref(), + match backend { + RuntimeBackend::Tmux => None, + RuntimeBackend::Embers(client) => Some(client.as_ref()), + }, + runtime, )?; - break Ok(()); + #[cfg(not(feature = "embers"))] + let requires_handoff = sidebar_requires_handoff(&tmux, runtime)?; + + if !requires_handoff { + // Stay on the current surface. + } else { + persist_sidebar_ui_state( + runtime, + &session_items, + &query, + surface_kind, + session_sort, + selected, + )?; + match (&runtime.target, backend) { + (SidebarTarget::Tmux { pane_id, .. }, RuntimeBackend::Tmux) => { + reconcile_sidebar_for_current_context( + &tmux, + &sidebar_command, + pane_id.as_deref(), + )?; + break Ok(()); + } + #[cfg(feature = "embers")] + (SidebarTarget::Embers { buffer_id }, RuntimeBackend::Embers(client)) => { + let new_session_name = client.join_buffer_to_current_session_root( + buffer_id, + EmbersJoinPlacement::Left, + Some(SIDEBAR_PANE_WIDTH), + true, + )?; + runtime.rename_session(new_session_name); + let reloaded = reload_sidebar_runtime_state(SidebarReloadContext { + config, + backend, + picker_mode, + default_kind: kind, + runtime_session_name: runtime.session_name(), + session_sort: &mut session_sort, + query: &mut query, + selected: &mut selected, + surface_kind: &mut surface_kind, + })?; + ( + details_preview_provider.state, + session_items, + pending_branch_names, + branch_status_updates, + ) = reloaded; + deferred_branch_status.clear(); + preview_session_id = None; + preview_refreshed_at = None; + if preview_enabled { + preview = Some(Vec::new()); + } + continue; + } + #[cfg(feature = "embers")] + _ => {} + } + } } let mut filtered = match input_mode { @@ -984,8 +1561,19 @@ fn run_surface( preview_session_id = None; preview_refreshed_at = None; } else if should_refresh_preview && let Some(item) = selected_item { + let session_preview_provider: &dyn PreviewProvider = match preview_mode { + #[cfg(feature = "embers")] + PreviewMode::Pane => match (&embers_preview_provider, backend) { + (_, RuntimeBackend::Tmux) => &pane_preview_provider, + (Some(provider), RuntimeBackend::Embers(_)) => provider, + (None, RuntimeBackend::Embers(_)) => &details_preview_provider, + }, + #[cfg(not(feature = "embers"))] + PreviewMode::Pane => &pane_preview_provider, + PreviewMode::Details => &details_preview_provider, + }; let combined_provider = CombinedPreviewProvider { - pane: &pane_preview_provider, + session: session_preview_provider, filesystem: &filesystem_preview_provider, }; preview = Some(generate_preview( @@ -1058,6 +1646,37 @@ fn run_surface( continue; } + #[cfg(feature = "embers")] + if matches!(backend, RuntimeBackend::Embers(_)) && backend.poll_updates()? { + let selected_session_id = filtered.get(selected).map(|item| item.session_id.clone()); + let reloaded_state = load_domain_state(backend)?; + (session_items, pending_branch_names, branch_status_updates) = + rebuild_session_items_for_picker_mode( + &reloaded_state, + Some(DEFAULT_CLIENT_ID), + picker_mode, + session_sort, + ); + details_preview_provider.state = reloaded_state.clone(); + deferred_branch_status.clear(); + selected = + selected_index_for_session(&session_items, &query, selected_session_id.as_deref()) + .or_else(|| { + selected_index_for_session( + &session_items, + &query, + current_session_id(&session_items), + ) + }) + .unwrap_or(0); + preview_session_id = None; + preview_refreshed_at = None; + if preview_enabled { + preview = Some(Vec::new()); + } + continue; + } + if event::poll(Duration::from_millis(250))? && let Event::Key(key) = event::read()? && let Some(intent) = translate_key(key, &bindings) @@ -1151,24 +1770,81 @@ fn run_surface( selected, )?; } - let activated = activate_filter_selection( - &tmux, - &zoxide, - &filtered, - selected, - &query, - ¤t_directory, - matches!(activate_intent, UiIntent::CreateSessionFromQuery), - )?; + let activated = match backend { + RuntimeBackend::Tmux => activate_filter_selection( + &tmux, + &zoxide, + &filtered, + selected, + &query, + ¤t_directory, + matches!(activate_intent, UiIntent::CreateSessionFromQuery), + )?, + #[cfg(feature = "embers")] + RuntimeBackend::Embers(client) => activate_filter_selection_embers( + client, + &zoxide, + &filtered, + selected, + &query, + ¤t_directory, + matches!(activate_intent, UiIntent::CreateSessionFromQuery), + )?, + }; if activated { - if let Some(runtime) = &sidebar_runtime { - reconcile_sidebar_for_current_context( - &tmux, - &sidebar_command, - runtime.pane_id.as_deref(), - )?; + match (sidebar_runtime.as_mut(), backend) { + (Some(runtime), RuntimeBackend::Tmux) => { + reconcile_sidebar_for_current_context( + &tmux, + &sidebar_command, + runtime.tmux_pane_id(), + )?; + break Ok(()); + } + #[cfg(feature = "embers")] + (Some(runtime), RuntimeBackend::Embers(client)) + if runtime.embers_buffer_id().is_some() => + { + let buffer_id = runtime + .embers_buffer_id() + .expect("checked embers sidebar buffer exists") + .to_string(); + let new_session_name = client + .join_buffer_to_current_session_root( + &buffer_id, + EmbersJoinPlacement::Left, + Some(SIDEBAR_PANE_WIDTH), + true, + )?; + runtime.rename_session(new_session_name); + let reloaded = + reload_sidebar_runtime_state(SidebarReloadContext { + config, + backend, + picker_mode, + default_kind: kind, + runtime_session_name: runtime.session_name(), + session_sort: &mut session_sort, + query: &mut query, + selected: &mut selected, + surface_kind: &mut surface_kind, + })?; + ( + details_preview_provider.state, + session_items, + pending_branch_names, + branch_status_updates, + ) = reloaded; + deferred_branch_status.clear(); + preview_session_id = None; + preview_refreshed_at = None; + if preview_enabled { + preview = Some(Vec::new()); + } + continue; + } + _ => break Ok(()), } - break Ok(()); } } InputMode::Rename { @@ -1186,8 +1862,14 @@ fn run_surface( continue; } - tmux.rename_session(&session_id, &new_name)?; - let reloaded_state = load_domain_state()?; + match backend { + RuntimeBackend::Tmux => tmux.rename_session(&session_id, &new_name)?, + #[cfg(feature = "embers")] + RuntimeBackend::Embers(client) => { + client.rename_session(&session_id, &new_name)? + } + } + let reloaded_state = load_domain_state(backend)?; (session_items, pending_branch_names, branch_status_updates) = rebuild_session_items_for_picker_mode( &reloaded_state, @@ -1208,10 +1890,10 @@ fn run_surface( preview = Some(Vec::new()); } if let Some(runtime) = sidebar_runtime.as_mut() - && runtime.session_name == session_id + && runtime.session_name() == session_id { - clear_sidebar_ui_state(&runtime.session_name)?; - runtime.session_name = new_name; + clear_sidebar_ui_state(runtime.session_name())?; + runtime.rename_session(new_name); } } }, @@ -1243,8 +1925,12 @@ fn run_surface( ) { let session_id = item.session_id.clone(); - tmux.kill_session(&session_id)?; - let reloaded_state = load_domain_state()?; + match backend { + RuntimeBackend::Tmux => tmux.kill_session(&session_id)?, + #[cfg(feature = "embers")] + RuntimeBackend::Embers(client) => client.kill_session(&session_id)?, + } + let reloaded_state = load_domain_state(backend)?; (session_items, pending_branch_names, branch_status_updates) = rebuild_session_items_for_picker_mode( &reloaded_state, @@ -1264,12 +1950,25 @@ fn run_surface( UiIntent::Close => match &input_mode { InputMode::Filter => { if let Some(runtime) = &sidebar_runtime { - disable_sidebar_for_session( - &tmux, - &runtime.session_name, - runtime.pane_id.as_deref(), - )?; - clear_sidebar_ui_state(&runtime.session_name)?; + match (&runtime.target, backend) { + (SidebarTarget::Tmux { pane_id, .. }, RuntimeBackend::Tmux) => { + disable_sidebar_for_session( + &tmux, + runtime.session_name(), + pane_id.as_deref(), + )?; + } + #[cfg(feature = "embers")] + ( + SidebarTarget::Embers { buffer_id }, + RuntimeBackend::Embers(client), + ) => { + client.detach_buffer(buffer_id)?; + } + #[cfg(feature = "embers")] + _ => {} + } + clear_sidebar_ui_state(runtime.session_name())?; } break Ok(()); } @@ -1287,7 +1986,7 @@ fn run_surface( PickerMode::Worktree => PickerMode::AllSessions, }; - let reloaded_state = load_domain_state()?; + let reloaded_state = load_domain_state(backend)?; (session_items, pending_branch_names, branch_status_updates) = rebuild_session_items_for_picker_mode( &reloaded_state, @@ -1320,9 +2019,7 @@ fn run_surface( show_help = matches!(surface_kind, SurfaceKind::Picker); }; - disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; + teardown.restore(&mut terminal)?; result } @@ -1446,7 +2143,7 @@ fn sidebar_surface_command(program: PathBuf) -> PopupCommand { } } -fn sidebar_runtime( +fn tmux_sidebar_runtime( tmux: &impl TmuxClient, kind: SurfaceKind, ) -> Result, Box> { @@ -1467,20 +2164,63 @@ fn sidebar_runtime( Ok(Some(SidebarRuntime { session_name, - home_window_index, - pane_id: env::var("TMUX_PANE").ok(), + target: SidebarTarget::Tmux { + home_window_index, + pane_id: env::var("TMUX_PANE").ok(), + }, + })) +} + +#[cfg(feature = "embers")] +fn embers_sidebar_runtime( + embers: &EmbersClient, + kind: SurfaceKind, +) -> Result, Box> { + if !matches!( + kind, + SurfaceKind::SidebarCompact | SurfaceKind::SidebarExpanded + ) || env::var(EMBERS_SURFACE_ENV).ok().as_deref() != Some(EMBERS_SIDEBAR_PANE_SURFACE) + { + return Ok(None); + } + + let session_name = embers + .current_session_name()? + .ok_or_else(|| "embers sidebar requires an active session".to_string())?; + let buffer_id = embers + .focused_buffer_id()? + .ok_or_else(|| "embers sidebar could not resolve its focused buffer".to_string())?; + Ok(Some(SidebarRuntime { + session_name, + target: SidebarTarget::Embers { buffer_id }, })) } fn sidebar_requires_handoff( tmux: &impl TmuxClient, + #[cfg(feature = "embers")] embers: Option<&EmbersClient>, runtime: &SidebarRuntime, -) -> Result { - let context = tmux.current_context()?; - Ok( - context.session_name.as_deref() != Some(runtime.session_name.as_str()) - || context.window_index != Some(runtime.home_window_index), - ) +) -> Result> { + match &runtime.target { + SidebarTarget::Tmux { + home_window_index, .. + } => { + let context = tmux.current_context()?; + Ok( + context.session_name.as_deref() != Some(runtime.session_name()) + || context.window_index != Some(*home_window_index), + ) + } + #[cfg(feature = "embers")] + SidebarTarget::Embers { .. } => { + let Some(embers) = embers else { + return Ok(false); + }; + Ok(embers + .current_session_name()? + .is_some_and(|session_name| session_name != runtime.session_name())) + } + } } fn reconcile_sidebar_for_current_context( @@ -1653,7 +2393,7 @@ fn persist_sidebar_ui_state( }, sort_mode: Some(sort_mode), }; - let path = sidebar_state_path(&runtime.session_name); + let path = sidebar_state_path(runtime.session_name()); let directory = path .parent() .ok_or_else(|| format!("missing parent directory for `{}`", path.display()))?; @@ -1687,13 +2427,99 @@ fn clear_sidebar_ui_state(session_name: &str) -> Result<(), Box> { } } -fn load_domain_state() -> Result> { - let backend = PollingTmuxBackend::new(CommandTmuxClient::new()); - let tmux = backend.snapshot()?; +fn load_domain_state(backend: &RuntimeBackend) -> Result> { + let snapshot = match backend { + RuntimeBackend::Tmux => { + let backend = PollingTmuxBackend::new(CommandTmuxClient::new()); + BackendSnapshot::Tmux(backend.snapshot()?) + } + #[cfg(feature = "embers")] + RuntimeBackend::Embers(client) => BackendSnapshot::Embers(client.snapshot()?), + }; let zoxide = CommandZoxideProvider::new() .load_entries(500) .unwrap_or_default(); - Ok(build_domain_state(&CandidateSources { tmux, zoxide })) + Ok(build_domain_state(&CandidateSources { + backend: snapshot, + zoxide, + })) +} + +#[cfg(feature = "embers")] +type SidebarReload = ( + DomainState, + Vec, + VecDeque, + mpsc::Receiver, +); + +#[cfg(feature = "embers")] +struct SidebarReloadContext<'a> { + config: &'a ResolvedConfig, + backend: &'a RuntimeBackend, + picker_mode: PickerMode, + default_kind: SurfaceKind, + runtime_session_name: &'a str, + session_sort: &'a mut SessionSortMode, + query: &'a mut String, + selected: &'a mut usize, + surface_kind: &'a mut SurfaceKind, +} + +#[cfg(feature = "embers")] +fn reload_sidebar_runtime_state( + context: SidebarReloadContext<'_>, +) -> Result> { + let SidebarReloadContext { + config, + backend, + picker_mode, + default_kind, + runtime_session_name, + session_sort, + query, + selected, + surface_kind, + } = context; + + let reloaded_state = load_domain_state(backend)?; + *session_sort = config.ui.session_sort; + let saved_sidebar_state = load_sidebar_ui_state(runtime_session_name)?; + if let Some(saved_state) = &saved_sidebar_state + && let Some(saved_sort_mode) = saved_state.sort_mode + { + *session_sort = saved_sort_mode; + } + let (session_items, pending_branch_names, branch_status_updates) = + rebuild_session_items_for_picker_mode( + &reloaded_state, + Some(DEFAULT_CLIENT_ID), + picker_mode, + *session_sort, + ); + *query = saved_sidebar_state + .as_ref() + .map(|state| state.query.clone()) + .unwrap_or_default(); + *surface_kind = saved_sidebar_state + .as_ref() + .map(|state| state.kind) + .unwrap_or(default_kind); + *selected = saved_sidebar_state + .as_ref() + .and_then(|state| { + selected_index_for_session(&session_items, query, state.selected_session_id.as_deref()) + }) + .or_else(|| { + selected_index_for_session(&session_items, query, current_session_id(&session_items)) + }) + .unwrap_or(0); + Ok(( + reloaded_state, + session_items, + pending_branch_names, + branch_status_updates, + )) } fn git_work_items(state: &DomainState) -> VecDeque { @@ -1762,13 +2588,25 @@ fn spawn_git_status_workers(work_items: Vec) -> mpsc::Receiver queue.pop_front(), + Err(poison) => { + if !reported_poison { + eprintln!( + "wisp: git status worker {worker_index} recovered from a poisoned work queue; continuing" + ); + reported_poison = true; + } + poison.into_inner().pop_front() + } + }; + let Some(work_item) = work_item else { break; }; @@ -1884,9 +2722,9 @@ mod tests { use crate::{ SIDEBAR_PANE_TITLE, SIDEBAR_PANE_WIDTH, STATUSLINE_REFRESH_COMMAND, - STATUSLINE_REFRESH_HOOKS, SidebarRuntime, StatuslineGroupCommand, StatuslineRenderCommand, - StatuslineSubcommand, SurfaceKind, activate_filter_selection, apply_session_sort, - clear_sidebar_ui_state, create_session_from_query, current_session_id, + STATUSLINE_REFRESH_HOOKS, SidebarRuntime, SidebarTarget, StatuslineGroupCommand, + StatuslineRenderCommand, StatuslineSubcommand, SurfaceKind, activate_filter_selection, + apply_session_sort, clear_sidebar_ui_state, create_session_from_query, current_session_id, disable_sidebar_for_session, filter_items, install_statusline_refresh_hooks, load_sidebar_ui_state, persist_sidebar_ui_state, picker_bindings, reconcile_sidebar_for_current_context, selected_index_for_session, @@ -2249,8 +3087,10 @@ mod tests { let runtime = SidebarRuntime { session_name: session_name.clone(), - home_window_index: 1, - pane_id: Some("%1".to_string()), + target: SidebarTarget::Tmux { + home_window_index: 1, + pane_id: Some("%1".to_string()), + }, }; let items = vec![session_item("alpha"), session_item("beta")]; persist_sidebar_ui_state( @@ -2313,22 +3153,38 @@ mod tests { }); let runtime = SidebarRuntime { session_name: "alpha".to_string(), - home_window_index: 1, - pane_id: Some("%1".to_string()), + target: SidebarTarget::Tmux { + home_window_index: 1, + pane_id: Some("%1".to_string()), + }, }; - assert!(sidebar_requires_handoff(&tmux, &runtime).expect("handoff should evaluate")); - assert!( - !sidebar_requires_handoff( - &StubTmuxClient::default().with_context(TmuxContext { - session_name: Some("alpha".to_string()), - window_index: Some(1), - ..TmuxContext::default() - }), - &runtime, - ) - .expect("handoff should evaluate") + #[cfg(feature = "embers")] + let handoff = sidebar_requires_handoff(&tmux, None, &runtime); + #[cfg(not(feature = "embers"))] + let handoff = sidebar_requires_handoff(&tmux, &runtime); + assert!(handoff.expect("handoff should evaluate")); + + #[cfg(feature = "embers")] + let stable_handoff = sidebar_requires_handoff( + &StubTmuxClient::default().with_context(TmuxContext { + session_name: Some("alpha".to_string()), + window_index: Some(1), + ..TmuxContext::default() + }), + None, + &runtime, + ); + #[cfg(not(feature = "embers"))] + let stable_handoff = sidebar_requires_handoff( + &StubTmuxClient::default().with_context(TmuxContext { + session_name: Some("alpha".to_string()), + window_index: Some(1), + ..TmuxContext::default() + }), + &runtime, ); + assert!(!stable_handoff.expect("handoff should evaluate")); } #[test] diff --git a/crates/wisp-bin/tests/smoke.rs b/crates/wisp-bin/tests/smoke.rs index 882df09..e691fe3 100644 --- a/crates/wisp-bin/tests/smoke.rs +++ b/crates/wisp-bin/tests/smoke.rs @@ -100,6 +100,31 @@ fn doctor_command_reports_runtime_environment() { assert!(stdout.contains("event strategy")); } +#[test] +fn doctor_reports_embers_when_selected_without_a_socket() { + let output = Command::new(bin()) + .arg("doctor") + .env("WISP_BACKEND", "embers") + .env_remove("WISP_EMBERS_SOCKET") + .env_remove("EMBERS_SOCKET") + .output() + .expect("run doctor"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("backend: embers")); + #[cfg(feature = "embers")] + { + assert!(stdout.contains("socket not configured")); + assert!(stdout.contains("event strategy: SubscriptionStream")); + } + #[cfg(not(feature = "embers"))] + { + assert!(stdout.contains("support not compiled in")); + assert!(stdout.contains("event strategy: Disabled")); + } +} + #[test] fn statusline_render_command_prints_status_output() { let output = Command::new(bin()) @@ -116,3 +141,19 @@ fn statusline_render_command_prints_status_output() { let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("ó°–”")); } + +#[test] +fn embers_statusline_reports_not_supported_before_connecting() { + let output = Command::new(bin()) + .args(["statusline", "render"]) + .env("WISP_BACKEND", "embers") + .env_remove("WISP_EMBERS_SOCKET") + .env_remove("EMBERS_SOCKET") + .output() + .expect("run statusline render"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("not supported on the embers backend yet")); + assert!(!stderr.contains("socket path")); +} diff --git a/crates/wisp-config/src/lib.rs b/crates/wisp-config/src/lib.rs index 3d7dcc7..2151eb3 100644 --- a/crates/wisp-config/src/lib.rs +++ b/crates/wisp-config/src/lib.rs @@ -10,8 +10,10 @@ use thiserror::Error; #[derive(Debug, Clone, PartialEq)] pub struct ResolvedConfig { + pub backend: BackendConfig, pub ui: UiConfig, pub fuzzy: FuzzyConfig, + pub embers: EmbersConfig, pub tmux: TmuxConfig, pub status: StatusConfig, pub zoxide: ZoxideConfig, @@ -23,6 +25,9 @@ pub struct ResolvedConfig { impl Default for ResolvedConfig { fn default() -> Self { Self { + backend: BackendConfig { + kind: BackendKind::Auto, + }, ui: UiConfig { mode: UiMode::Auto, show_help: true, @@ -35,6 +40,7 @@ impl Default for ResolvedConfig { engine: FuzzyEngine::Nucleo, case_mode: CaseMode::Smart, }, + embers: EmbersConfig { socket_path: None }, tmux: TmuxConfig { query_windows: false, prefer_popup: true, @@ -89,6 +95,11 @@ impl Default for ResolvedConfig { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackendConfig { + pub kind: BackendKind, +} + #[derive(Debug, Clone, PartialEq)] pub struct UiConfig { pub mode: UiMode, @@ -105,6 +116,11 @@ pub struct FuzzyConfig { pub case_mode: CaseMode, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmbersConfig { + pub socket_path: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct TmuxConfig { pub query_windows: bool, @@ -207,6 +223,15 @@ pub enum UiMode { Auto, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum BackendKind { + Tmux, + Embers, + #[default] + Auto, +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum PreviewPosition { @@ -346,6 +371,11 @@ impl_from_str_for_enum!(UiMode { "fullscreen" => UiMode::Fullscreen, "auto" => UiMode::Auto, }); +impl_from_str_for_enum!(BackendKind { + "tmux" => BackendKind::Tmux, + "embers" => BackendKind::Embers, + "auto" => BackendKind::Auto, +}); impl_from_str_for_enum!(FuzzyEngine { "nucleo" => FuzzyEngine::Nucleo, "skim" => FuzzyEngine::Skim, @@ -519,8 +549,10 @@ fn parse_partial_config(input: &str, strict: bool) -> Result) -> Result { let mut config = Self::default(); + if let Some(value) = env_overrides.get("WISP_BACKEND") { + config.backend.kind = + Some( + value + .parse() + .map_err(|message| ConfigError::InvalidEnvironment { + key: "WISP_BACKEND".to_string(), + message, + })?, + ); + } + if let Some(value) = env_overrides .get("WISP_MODE") .or_else(|| env_overrides.get("WISP_UI_MODE")) @@ -586,6 +632,10 @@ impl PartialConfig { ); } + if let Some(value) = env_overrides.get("WISP_EMBERS_SOCKET") { + config.embers.socket_path = Some(PathBuf::from(value)); + } + if let Some(value) = env_overrides.get("WISP_PREVIEW_ENABLED") { config.preview.enabled = Some(parse_bool("WISP_PREVIEW_ENABLED", value)?); } @@ -616,6 +666,10 @@ impl PartialConfig { let mut config = ResolvedConfig::default(); let mut errors = Vec::new(); + if let Some(kind) = self.backend.kind { + config.backend.kind = kind; + } + if let Some(mode) = self.ui.mode { config.ui.mode = mode; } @@ -642,6 +696,8 @@ impl PartialConfig { config.fuzzy.case_mode = case_mode; } + config.embers.socket_path = self.embers.socket_path; + if let Some(query_windows) = self.tmux.query_windows { config.tmux.query_windows = query_windows; } @@ -776,6 +832,18 @@ impl PartialConfig { } } +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +struct PartialBackendConfig { + kind: Option, +} + +impl PartialBackendConfig { + fn merge(&mut self, other: Self) { + merge_option(&mut self.kind, other.kind); + } +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] struct PartialUiConfig { @@ -812,6 +880,18 @@ impl PartialFuzzyConfig { } } +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +struct PartialEmbersConfig { + socket_path: Option, +} + +impl PartialEmbersConfig { + fn merge(&mut self, other: Self) { + merge_option(&mut self.socket_path, other.socket_path); + } +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] struct PartialTmuxConfig { @@ -1026,11 +1106,11 @@ fn validate_config(config: &ResolvedConfig, errors: &mut Vec) { #[cfg(test)] mod tests { - use std::collections::BTreeMap; + use std::{collections::BTreeMap, path::PathBuf}; use super::{ - CliOverrides, ConfigError, FuzzyEngine, KeyAction, LogLevel, SessionSortMode, UiMode, - resolve_config, + BackendKind, CliOverrides, ConfigError, FuzzyEngine, KeyAction, LogLevel, SessionSortMode, + UiMode, resolve_config, }; #[test] @@ -1038,6 +1118,7 @@ mod tests { let config = resolve_config(None, &BTreeMap::new(), &CliOverrides::default(), false) .expect("default config should resolve"); + assert_eq!(config.backend.kind, BackendKind::Auto); assert_eq!(config.ui.mode, UiMode::Auto); assert_eq!(config.fuzzy.engine, FuzzyEngine::Nucleo); assert!(config.zoxide.enabled); @@ -1063,6 +1144,9 @@ mod tests { #[test] fn parses_toml_config_values() { let input = r#" + [backend] + kind = "embers" + [ui] mode = "popup" preview_width = 0.6 @@ -1071,6 +1155,9 @@ mod tests { [fuzzy] engine = "skim" + [embers] + socket_path = "/tmp/embers.sock" + [tmux] popup_width = "90%" popup_height = "40" @@ -1102,10 +1189,15 @@ mod tests { ) .expect("toml config should resolve"); + assert_eq!(config.backend.kind, BackendKind::Embers); assert_eq!(config.ui.mode, UiMode::Popup); assert_eq!(config.ui.preview_width, 0.6); assert_eq!(config.ui.session_sort, SessionSortMode::Alphabetical); assert_eq!(config.fuzzy.engine, FuzzyEngine::Skim); + assert_eq!( + config.embers.socket_path, + Some(PathBuf::from("/tmp/embers.sock")) + ); assert_eq!(config.status.line, 3); assert_eq!(config.status.icon, "Wisp"); assert_eq!(config.status.max_sessions, Some(5)); @@ -1138,8 +1230,13 @@ mod tests { level = "info" "#; let env = BTreeMap::from([ + ("WISP_BACKEND".to_string(), "embers".to_string()), ("WISP_MODE".to_string(), "popup".to_string()), ("WISP_ENGINE".to_string(), "nucleo".to_string()), + ( + "WISP_EMBERS_SOCKET".to_string(), + "/tmp/env-embers.sock".to_string(), + ), ("WISP_LOG_LEVEL".to_string(), "debug".to_string()), ]); let cli = CliOverrides { @@ -1153,8 +1250,13 @@ mod tests { let config = resolve_config(Some(input), &env, &cli, false).expect("merged config should resolve"); + assert_eq!(config.backend.kind, BackendKind::Embers); assert_eq!(config.ui.mode, UiMode::Auto); assert_eq!(config.fuzzy.engine, FuzzyEngine::Skim); + assert_eq!( + config.embers.socket_path, + Some(PathBuf::from("/tmp/env-embers.sock")) + ); assert_eq!(config.logging.level, LogLevel::Trace); assert!(!config.zoxide.enabled); } diff --git a/crates/wisp-core/benches/projections.rs b/crates/wisp-core/benches/projections.rs index e36e75c..43c2d62 100644 --- a/crates/wisp-core/benches/projections.rs +++ b/crates/wisp-core/benches/projections.rs @@ -14,7 +14,7 @@ fn seeded_state() -> DomainState { name.clone(), SessionRecord { id: name.clone(), - tmux_id: None, + native_id: None, name, attached: index % 3 == 0, windows: BTreeMap::from([( diff --git a/crates/wisp-core/src/domain.rs b/crates/wisp-core/src/domain.rs index d847189..0186abd 100644 --- a/crates/wisp-core/src/domain.rs +++ b/crates/wisp-core/src/domain.rs @@ -127,7 +127,7 @@ pub struct DomainSnapshot { #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionRecord { pub id: SessionId, - pub tmux_id: Option, + pub native_id: Option, pub name: String, pub attached: bool, pub windows: BTreeMap, @@ -331,7 +331,7 @@ mod tests { "alpha".to_string(), SessionRecord { id: "alpha".to_string(), - tmux_id: None, + native_id: None, name: "alpha".to_string(), attached: true, windows: BTreeMap::from([( diff --git a/crates/wisp-core/src/reduce.rs b/crates/wisp-core/src/reduce.rs index 9d50f45..5be4845 100644 --- a/crates/wisp-core/src/reduce.rs +++ b/crates/wisp-core/src/reduce.rs @@ -112,7 +112,7 @@ mod tests { "alpha".to_string(), SessionRecord { id: "alpha".to_string(), - tmux_id: None, + native_id: None, name: "alpha".to_string(), attached: true, windows: BTreeMap::from([( @@ -171,7 +171,7 @@ mod tests { "beta".to_string(), SessionRecord { id: "beta".to_string(), - tmux_id: None, + native_id: None, name: "beta".to_string(), attached: false, windows: BTreeMap::from([( @@ -219,7 +219,7 @@ mod tests { "beta".to_string(), SessionRecord { id: "beta".to_string(), - tmux_id: None, + native_id: None, name: "beta".to_string(), attached: false, windows: BTreeMap::from([( @@ -273,7 +273,7 @@ mod tests { "beta".to_string(), SessionRecord { id: "beta".to_string(), - tmux_id: None, + native_id: None, name: "beta".to_string(), attached: false, windows: BTreeMap::from([( diff --git a/crates/wisp-core/src/view.rs b/crates/wisp-core/src/view.rs index 7611a73..ab984ab 100644 --- a/crates/wisp-core/src/view.rs +++ b/crates/wisp-core/src/view.rs @@ -256,8 +256,8 @@ pub fn derive_session_list_with_worktrees( // Add worktrees that don't have matching sessions let session_paths: BTreeSet<&Path> = state .sessions - .iter() - .filter_map(|(_, session)| { + .values() + .filter_map(|session| { session .windows .values() @@ -318,7 +318,7 @@ pub fn derive_status_items(state: &DomainState, client_id: Option<&str>) -> Vec< .iter() .map(|(session_id, session)| StatusSessionItem { session_id: session - .tmux_id + .native_id .clone() .unwrap_or_else(|| session_id.clone()), session_name: session.name.clone(), @@ -378,7 +378,7 @@ mod tests { "alpha".to_string(), SessionRecord { id: "alpha".to_string(), - tmux_id: Some("$1".to_string()), + native_id: Some("$1".to_string()), name: "alpha".to_string(), attached: true, windows: BTreeMap::from([( @@ -472,7 +472,7 @@ mod tests { "beta".to_string(), SessionRecord { id: "beta".to_string(), - tmux_id: Some("$2".to_string()), + native_id: Some("$2".to_string()), name: "beta".to_string(), attached: false, windows: BTreeMap::new(), @@ -485,7 +485,7 @@ mod tests { "aardvark".to_string(), SessionRecord { id: "aardvark".to_string(), - tmux_id: Some("$3".to_string()), + native_id: Some("$3".to_string()), name: "aardvark".to_string(), attached: false, windows: BTreeMap::new(), diff --git a/crates/wisp-embers/Cargo.toml b/crates/wisp-embers/Cargo.toml new file mode 100644 index 0000000..3eec9cf --- /dev/null +++ b/crates/wisp-embers/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "wisp-embers" +version = "0.1.1" +edition = "2024" +rust-version = "1.95.0" +license = "MIT" +repository = "https://github.com/Pajn/wisp" +description = "Embers backend adapter for Wisp" + +[dependencies] +embers-client = "0.1.0" +embers-core = "0.1.0" +embers-protocol = "0.1.0" +thiserror = "2" +tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] } + +[dev-dependencies] +embers-test-support = "0.1.0" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } + +[lints.rust] +unsafe_code = "forbid" +unused_lifetimes = "warn" +unused_macro_rules = "warn" +unused_qualifications = "warn" + +[lints.clippy] +dbg_macro = "deny" +todo = "deny" +unwrap_used = "deny" +unnecessary_wraps = "warn" diff --git a/crates/wisp-embers/src/lib.rs b/crates/wisp-embers/src/lib.rs new file mode 100644 index 0000000..a6de184 --- /dev/null +++ b/crates/wisp-embers/src/lib.rs @@ -0,0 +1,1326 @@ +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + sync::Mutex, + time::Duration, +}; + +use embers_client::{ClientState, MuxClient, SocketTransport}; +use embers_core::{ActivityState, BufferId, FloatGeometry, MuxError, NodeId, PtySize, SessionId}; +use embers_protocol::{ + BufferRequest, ClientMessage, ClientRecord, ClientRequest, FloatingRequest, NodeJoinPlacement, + NodeRecord, NodeRecordKind, NodeRequest, ServerEvent, ServerResponse, SessionRecord, + SessionRequest, +}; +use thiserror::Error; + +pub use embers_core::ActivityState as EmbersActivityState; +pub use embers_protocol::NodeJoinPlacement as EmbersJoinPlacement; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmbersSnapshot { + pub context: EmbersContext, + pub sessions: Vec, + pub windows: Vec, + pub panes: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EmbersContext { + pub client_id: Option, + pub current_session_name: Option, + pub current_window_index: Option, + pub pane_id: Option, + pub previous_session_name: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmbersSession { + pub native_id: String, + pub name: String, + pub attached: bool, + pub last_activity: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmbersWindow { + pub session_name: String, + pub index: u32, + pub name: String, + pub active: bool, + pub activity: bool, + pub bell: bool, + pub silence: bool, + pub current_path: Option, + pub current_command: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmbersPane { + pub session_name: String, + pub window_index: u32, + pub pane_id: String, + pub title: String, + pub active: bool, + pub current_path: Option, + pub current_command: Option, + pub activity: ActivityState, +} + +#[derive(Debug, Error)] +pub enum EmbersError { + #[error("failed to build tokio runtime: {0}")] + Runtime(#[source] std::io::Error), + #[error("failed to connect to embers at {socket_path}: {source}")] + Connect { + socket_path: PathBuf, + #[source] + source: MuxError, + }, + #[error("embers state lock was poisoned")] + Poisoned, + #[error("{0}")] + Mux(#[from] MuxError), + #[error("session `{0}` was not found")] + MissingSession(String), + #[error("session `{0}` has no previewable buffers")] + MissingPreview(String), + #[error("embers client has no current session")] + MissingCurrentSession, + #[error("embers protocol returned an unexpected response: {0}")] + UnexpectedResponse(&'static str), + #[error("invalid embers state: {0}")] + InvalidState(String), + #[error("invalid embers identifier `{value}` for {kind}")] + InvalidIdentifier { kind: &'static str, value: String }, +} + +#[derive(Debug)] +pub struct EmbersClient { + socket_path: PathBuf, + runtime: tokio::runtime::Runtime, + state: Mutex, +} + +#[derive(Debug)] +struct EmbersClientState { + client: MuxClient, + client_id: u64, + previous_session_id: Option, +} + +#[derive(Debug)] +struct WindowProjection { + window: EmbersWindow, + panes: Vec, +} + +impl EmbersClient { + pub fn connect(socket_path: impl Into) -> Result { + let socket_path = socket_path.into(); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .worker_threads(1) + .build() + .map_err(EmbersError::Runtime)?; + let mut client = runtime + .block_on(MuxClient::connect(&socket_path)) + .map_err(|source| EmbersError::Connect { + socket_path: socket_path.clone(), + source, + })?; + let current_client = runtime.block_on(client.current_client())?; + runtime.block_on(client.subscribe(None))?; + runtime.block_on(client.resync_all_sessions())?; + + Ok(Self { + socket_path, + runtime, + state: Mutex::new(EmbersClientState { + client, + client_id: current_client.id, + previous_session_id: None, + }), + }) + } + + #[must_use] + pub fn socket_path(&self) -> &Path { + &self.socket_path + } + + pub fn snapshot(&self) -> Result { + self.with_state(|runtime, state| { + runtime.block_on(state.client.resync_all_sessions())?; + let current_client = current_client_record(runtime, state)?; + let clients = list_clients(runtime, state)?; + build_snapshot(state, &clients, ¤t_client) + }) + } + + pub fn list_session_names(&self) -> Result, EmbersError> { + Ok(self + .snapshot()? + .sessions + .into_iter() + .map(|session| session.name) + .collect()) + } + + pub fn poll_updates(&self) -> Result { + self.with_state(|runtime, state| { + let mut saw_event = false; + while let Some(event) = + runtime.block_on(state.client.process_next_event_timeout(Duration::ZERO))? + { + saw_event = true; + if let ServerEvent::ClientChanged(event) = event + && event.client.id == state.client_id + { + state.previous_session_id = event.previous_session_id; + } + } + Ok(saw_event) + }) + } + + pub fn switch_session(&self, session_name: &str) -> Result<(), EmbersError> { + self.with_state(|runtime, state| { + runtime.block_on(state.client.resync_all_sessions())?; + let current_client = current_client_record(runtime, state)?; + let session = resolve_session_by_name(state.client.state(), session_name)?; + state.previous_session_id = current_client.current_session_id; + runtime.block_on(state.client.switch_current_session(session.id))?; + runtime.block_on(state.client.resync_all_sessions())?; + Ok(()) + }) + } + + pub fn create_or_switch_session( + &self, + session_name: &str, + directory: &Path, + ) -> Result<(), EmbersError> { + self.with_state(|runtime, state| { + runtime.block_on(state.client.resync_all_sessions())?; + let current_client = current_client_record(runtime, state)?; + if let Ok(existing_id) = resolve_session_by_name(state.client.state(), session_name) + .map(|session| session.id) + { + state.previous_session_id = current_client.current_session_id; + ensure_root_window(runtime, state, existing_id, directory)?; + runtime.block_on(state.client.switch_current_session(existing_id))?; + runtime.block_on(state.client.resync_all_sessions())?; + return Ok(()); + } + + let session_id = create_session(runtime, state, session_name)?; + ensure_root_window(runtime, state, session_id, directory)?; + state.previous_session_id = current_client.current_session_id; + runtime.block_on(state.client.switch_current_session(session_id))?; + runtime.block_on(state.client.resync_all_sessions())?; + Ok(()) + }) + } + + pub fn rename_session(&self, session_name: &str, new_name: &str) -> Result<(), EmbersError> { + self.with_state(|runtime, state| { + runtime.block_on(state.client.resync_all_sessions())?; + let session = resolve_session_by_name(state.client.state(), session_name)?; + let response = runtime.block_on(state.client.request_message( + ClientMessage::Session(SessionRequest::Rename { + request_id: state.client.next_request_id(), + session_id: session.id, + name: new_name.to_string(), + }), + ))?; + match response { + ServerResponse::Ok(_) => { + runtime.block_on(state.client.resync_all_sessions())?; + Ok(()) + } + _ => Err(EmbersError::UnexpectedResponse("session rename")), + } + }) + } + + pub fn kill_session(&self, session_name: &str) -> Result<(), EmbersError> { + self.with_state(|runtime, state| { + runtime.block_on(state.client.resync_all_sessions())?; + let session = resolve_session_by_name(state.client.state(), session_name)?; + let response = runtime.block_on(state.client.request_message( + ClientMessage::Session(SessionRequest::Close { + request_id: state.client.next_request_id(), + session_id: session.id, + force: false, + }), + ))?; + match response { + ServerResponse::Ok(_) => { + runtime.block_on(state.client.resync_all_sessions())?; + Ok(()) + } + _ => Err(EmbersError::UnexpectedResponse("session close")), + } + }) + } + + pub fn capture_session_preview( + &self, + session_name: &str, + max_lines: usize, + ) -> Result { + self.with_state(|runtime, state| { + runtime.block_on(state.client.resync_all_sessions())?; + let session_id = resolve_session_by_name(state.client.state(), session_name) + .map(|session| session.id)?; + runtime.block_on(state.client.resync_session(session_id))?; + let buffer_id = preview_buffer_id(state.client.state(), session_id) + .ok_or_else(|| EmbersError::MissingPreview(session_name.to_string()))?; + let snapshot = runtime.block_on(state.client.capture_buffer(buffer_id))?; + let line_count = snapshot.lines.len(); + let start = line_count.saturating_sub(max_lines); + Ok(snapshot.lines[start..].join("\n")) + }) + } + + pub fn current_session_name(&self) -> Result, EmbersError> { + self.with_state(|runtime, state| { + runtime.block_on(state.client.resync_all_sessions())?; + let current_client = current_client_record(runtime, state)?; + Ok(current_client.current_session_id.and_then(|session_id| { + state + .client + .state() + .sessions + .get(&session_id) + .map(|session| session.name.clone()) + })) + }) + } + + pub fn focused_buffer_id(&self) -> Result, EmbersError> { + self.with_state(|runtime, state| { + runtime.block_on(state.client.resync_all_sessions())?; + let current_client = current_client_record(runtime, state)?; + let Some(session_id) = current_client.current_session_id else { + return Ok(None); + }; + runtime.block_on(state.client.resync_session(session_id))?; + Ok(current_focused_buffer_id(state.client.state(), session_id) + .map(|buffer_id| buffer_id.0.to_string())) + }) + } + + pub fn current_session_viewport_size(&self) -> Result, EmbersError> { + self.with_state(|runtime, state| { + runtime.block_on(state.client.resync_all_sessions())?; + let current_client = current_client_record(runtime, state)?; + let Some(session_id) = current_client.current_session_id else { + return Ok(None); + }; + runtime.block_on(state.client.resync_session(session_id))?; + Ok( + visible_size_for_session_root(state.client.state(), session_id) + .map(|size| (size.cols, size.rows)), + ) + }) + } + + pub fn create_buffer( + &self, + command: &[String], + title: &str, + cwd: Option<&Path>, + env: &BTreeMap, + ) -> Result { + self.with_state(|runtime, state| { + let buffer_id = create_buffer_record(runtime, state, command, title, cwd, env)?; + Ok(buffer_id.0.to_string()) + }) + } + + pub fn create_floating_for_buffer_in_current_session( + &self, + buffer_id: &str, + title: Option<&str>, + width: u16, + height: u16, + focus: bool, + close_on_empty: bool, + ) -> Result<(), EmbersError> { + self.with_state(|runtime, state| { + let (session_id, _) = current_session_info(runtime, state)?; + runtime.block_on(state.client.resync_session(session_id))?; + let viewport = visible_size_for_session_root(state.client.state(), session_id) + .unwrap_or_else(|| PtySize::new(80, 24)); + let geometry = centered_geometry(viewport, width, height); + let buffer_id = parse_buffer_id(buffer_id)?; + let response = runtime.block_on(state.client.request_message( + ClientMessage::Floating(FloatingRequest::Create { + request_id: state.client.next_request_id(), + session_id, + root_node_id: None, + buffer_id: Some(buffer_id), + geometry, + title: title.map(ToString::to_string), + focus, + close_on_empty, + }), + ))?; + match response { + ServerResponse::Floating(_) => { + runtime.block_on(state.client.resync_session(session_id))?; + Ok(()) + } + _ => Err(EmbersError::UnexpectedResponse("floating create")), + } + }) + } + + pub fn join_buffer_to_current_session_root( + &self, + buffer_id: &str, + placement: NodeJoinPlacement, + leading_size: Option, + focus: bool, + ) -> Result { + self.with_state(|runtime, state| { + let (session_id, session_name) = current_session_info(runtime, state)?; + runtime.block_on(state.client.resync_session(session_id))?; + let root_node_id = state + .client + .state() + .sessions + .get(&session_id) + .map(|session| session.root_node_id) + .ok_or_else(|| { + EmbersError::InvalidState(format!( + "session {session_id} is not cached for root join" + )) + })?; + let viewport = visible_size_for_session_root(state.client.state(), session_id) + .unwrap_or_else(|| PtySize::new(80, 24)); + let buffer_id = parse_buffer_id(buffer_id)?; + let location = buffer_location(runtime, state, buffer_id)?; + if location + .session_id() + .is_some_and(|attached_session_id| attached_session_id != session_id) + { + detach_buffer_record(runtime, state, buffer_id)?; + } + let response = runtime.block_on(state.client.request_message(ClientMessage::Node( + NodeRequest::JoinBufferAtNode { + request_id: state.client.next_request_id(), + node_id: root_node_id, + buffer_id, + placement, + }, + )))?; + apply_session_layout_response( + runtime, + &mut state.client, + response, + session_id, + "join buffer at node", + )?; + + if let Some(leading_size) = leading_size + && let Some(sizes) = split_sizes_for_join(viewport, leading_size, placement) + { + let location = buffer_location(runtime, state, buffer_id)?; + let leaf_node_id = location.node_id().ok_or_else(|| { + EmbersError::InvalidState(format!( + "buffer {buffer_id} did not attach to a node" + )) + })?; + let split_id = state + .client + .state() + .nodes + .get(&leaf_node_id) + .and_then(|node| node.parent_id) + .ok_or_else(|| { + EmbersError::InvalidState(format!( + "buffer {buffer_id} has no split parent after root join" + )) + })?; + let response = runtime.block_on(state.client.request_message( + ClientMessage::Node(NodeRequest::Resize { + request_id: state.client.next_request_id(), + node_id: split_id, + sizes, + }), + ))?; + apply_session_layout_response( + runtime, + &mut state.client, + response, + session_id, + "resize split", + )?; + } + + if focus { + let location = buffer_location(runtime, state, buffer_id)?; + let leaf_node_id = location.node_id().ok_or_else(|| { + EmbersError::InvalidState(format!( + "buffer {buffer_id} did not resolve to a leaf" + )) + })?; + let response = runtime.block_on(state.client.request_message( + ClientMessage::Node(NodeRequest::Focus { + request_id: state.client.next_request_id(), + session_id, + node_id: leaf_node_id, + }), + ))?; + apply_session_layout_response( + runtime, + &mut state.client, + response, + session_id, + "focus buffer", + )?; + } + + Ok(session_name) + }) + } + + pub fn detach_buffer(&self, buffer_id: &str) -> Result<(), EmbersError> { + self.with_state(|runtime, state| { + let buffer_id = parse_buffer_id(buffer_id)?; + let response = runtime.block_on(state.client.request_message( + ClientMessage::Buffer(BufferRequest::Detach { + request_id: state.client.next_request_id(), + buffer_id, + }), + ))?; + match response { + ServerResponse::Ok(_) => Ok(()), + _ => Err(EmbersError::UnexpectedResponse("buffer detach")), + } + }) + } + + pub fn kill_buffer(&self, buffer_id: &str) -> Result<(), EmbersError> { + self.with_state(|runtime, state| { + let buffer_id = parse_buffer_id(buffer_id)?; + let response = runtime.block_on(state.client.request_message( + ClientMessage::Buffer(BufferRequest::Kill { + request_id: state.client.next_request_id(), + buffer_id, + force: true, + }), + ))?; + match response { + ServerResponse::Ok(_) => Ok(()), + _ => Err(EmbersError::UnexpectedResponse("buffer kill")), + } + }) + } + + fn with_state( + &self, + action: impl FnOnce(&tokio::runtime::Runtime, &mut EmbersClientState) -> Result, + ) -> Result { + let mut state = self.state.lock().map_err(|_| EmbersError::Poisoned)?; + action(&self.runtime, &mut state) + } +} + +fn current_client_record( + runtime: &tokio::runtime::Runtime, + state: &mut EmbersClientState, +) -> Result { + let record = runtime.block_on(state.client.current_client())?; + state.client_id = record.id; + Ok(record) +} + +fn list_clients( + runtime: &tokio::runtime::Runtime, + state: &EmbersClientState, +) -> Result, EmbersError> { + let response = runtime.block_on(state.client.request_message(ClientMessage::Client( + ClientRequest::List { + request_id: state.client.next_request_id(), + }, + )))?; + match response { + ServerResponse::Clients(response) => Ok(response.clients), + _ => Err(EmbersError::UnexpectedResponse("client list")), + } +} + +fn current_session_info( + runtime: &tokio::runtime::Runtime, + state: &mut EmbersClientState, +) -> Result<(SessionId, String), EmbersError> { + runtime.block_on(state.client.resync_all_sessions())?; + let current_client = current_client_record(runtime, state)?; + let session_id = current_client + .current_session_id + .ok_or(EmbersError::MissingCurrentSession)?; + let session_name = state + .client + .state() + .sessions + .get(&session_id) + .map(|session| session.name.clone()) + .ok_or_else(|| { + EmbersError::InvalidState(format!("current session {session_id} is not cached")) + })?; + Ok((session_id, session_name)) +} + +fn detach_buffer_record( + runtime: &tokio::runtime::Runtime, + state: &mut EmbersClientState, + buffer_id: BufferId, +) -> Result<(), EmbersError> { + let response = runtime.block_on(state.client.request_message(ClientMessage::Buffer( + BufferRequest::Detach { + request_id: state.client.next_request_id(), + buffer_id, + }, + )))?; + match response { + ServerResponse::Ok(_) => Ok(()), + _ => Err(EmbersError::UnexpectedResponse("buffer detach")), + } +} + +fn apply_session_layout_response( + runtime: &tokio::runtime::Runtime, + client: &mut MuxClient, + response: ServerResponse, + session_id: SessionId, + operation: &'static str, +) -> Result<(), EmbersError> { + match response { + ServerResponse::SessionSnapshot(response) => { + client.state_mut().apply_session_snapshot(response.snapshot); + Ok(()) + } + ServerResponse::Ok(_) => { + runtime.block_on(client.resync_session(session_id))?; + Ok(()) + } + _ => Err(EmbersError::UnexpectedResponse(operation)), + } +} + +fn buffer_location( + runtime: &tokio::runtime::Runtime, + state: &mut EmbersClientState, + buffer_id: BufferId, +) -> Result { + let response = runtime.block_on(state.client.request_message(ClientMessage::Buffer( + BufferRequest::GetLocation { + request_id: state.client.next_request_id(), + buffer_id, + }, + )))?; + match response { + ServerResponse::BufferLocation(response) => Ok(response.location), + _ => Err(EmbersError::UnexpectedResponse("buffer location")), + } +} + +fn create_session( + runtime: &tokio::runtime::Runtime, + state: &mut EmbersClientState, + name: &str, +) -> Result { + let response = runtime.block_on(state.client.request_message(ClientMessage::Session( + SessionRequest::Create { + request_id: state.client.next_request_id(), + name: name.to_string(), + }, + )))?; + match response { + ServerResponse::SessionSnapshot(response) => { + let session_id = response.snapshot.session.id; + state + .client + .state_mut() + .apply_session_snapshot(response.snapshot); + Ok(session_id) + } + _ => Err(EmbersError::UnexpectedResponse("session create")), + } +} + +fn ensure_root_window( + runtime: &tokio::runtime::Runtime, + state: &mut EmbersClientState, + session_id: SessionId, + directory: &Path, +) -> Result<(), EmbersError> { + runtime.block_on(state.client.resync_session(session_id))?; + if session_has_root_window(state.client.state(), session_id)? { + return Ok(()); + } + + let command = default_shell_command(); + let title = default_title(&command, "shell"); + let buffer_id = create_buffer_record( + runtime, + state, + &command, + &title, + Some(directory), + &BTreeMap::new(), + )?; + let response = runtime.block_on(state.client.request_message(ClientMessage::Session( + SessionRequest::AddRootTab { + request_id: state.client.next_request_id(), + session_id, + title, + buffer_id: Some(buffer_id), + child_node_id: None, + }, + )))?; + match response { + ServerResponse::SessionSnapshot(response) => { + state + .client + .state_mut() + .apply_session_snapshot(response.snapshot); + Ok(()) + } + _ => Err(EmbersError::UnexpectedResponse("add root tab")), + } +} + +fn create_buffer_record( + runtime: &tokio::runtime::Runtime, + state: &mut EmbersClientState, + command: &[String], + title: &str, + cwd: Option<&Path>, + env: &BTreeMap, +) -> Result { + let response = runtime.block_on(state.client.request_message(ClientMessage::Buffer( + BufferRequest::Create { + request_id: state.client.next_request_id(), + title: Some(title.to_string()), + command: command.to_vec(), + cwd: cwd.map(|path| path.to_string_lossy().into_owned()), + env: env.clone(), + }, + )))?; + match response { + ServerResponse::Buffer(response) => { + let buffer_id = response.buffer.id; + state + .client + .state_mut() + .apply_buffer_record(response.buffer); + Ok(buffer_id) + } + _ => Err(EmbersError::UnexpectedResponse("buffer create")), + } +} + +fn session_has_root_window( + state: &ClientState, + session_id: SessionId, +) -> Result { + let session = state + .sessions + .get(&session_id) + .ok_or_else(|| EmbersError::InvalidState(format!("session {session_id} is not cached")))?; + let root = state.nodes.get(&session.root_node_id).ok_or_else(|| { + EmbersError::InvalidState(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())) +} + +fn resolve_session_by_name<'a>( + state: &'a ClientState, + session_name: &str, +) -> Result<&'a SessionRecord, EmbersError> { + state + .sessions + .values() + .find(|session| session.name == session_name) + .ok_or_else(|| EmbersError::MissingSession(session_name.to_string())) +} + +fn preview_buffer_id(state: &ClientState, session_id: SessionId) -> Option { + let session = state.sessions.get(&session_id)?; + let focus = session + .focused_leaf_id + .filter(|leaf_id| node_belongs_to_subtree(state, session.root_node_id, *leaf_id)) + .and_then(|leaf_id| buffer_id_for_buffer_view(state, leaf_id)); + focus.or_else(|| first_buffer_id_in_subtree(state, session.root_node_id)) +} + +fn current_focused_buffer_id(state: &ClientState, session_id: SessionId) -> Option { + let session = state.sessions.get(&session_id)?; + session + .focused_leaf_id + .and_then(|leaf_id| buffer_id_for_buffer_view(state, leaf_id)) + .or_else(|| preview_buffer_id(state, session_id)) +} + +fn visible_size_for_session_root(state: &ClientState, session_id: SessionId) -> Option { + let session = state.sessions.get(&session_id)?; + visible_size_for_node(state, session.root_node_id) +} + +fn visible_size_for_node(state: &ClientState, node_id: NodeId) -> Option { + let node = state.nodes.get(&node_id)?; + match node.kind { + NodeRecordKind::BufferView => { + let view = node.buffer_view.as_ref()?; + non_zero_size(view.last_render_size).or_else(|| { + buffer_for_node(state, node_id) + .ok() + .and_then(|buffer| non_zero_size(buffer.pty_size)) + }) + } + NodeRecordKind::Split => { + let split = node.split.as_ref()?; + let child_sizes = split + .child_ids + .iter() + .filter_map(|child_id| visible_size_for_node(state, *child_id)) + .collect::>(); + if child_sizes.is_empty() { + return None; + } + let aggregate = match split.direction { + embers_core::SplitDirection::Vertical => PtySize::new( + saturating_u16_sum(child_sizes.iter().map(|size| size.cols)), + child_sizes.iter().map(|size| size.rows).max().unwrap_or(0), + ), + embers_core::SplitDirection::Horizontal => PtySize::new( + child_sizes.iter().map(|size| size.cols).max().unwrap_or(0), + saturating_u16_sum(child_sizes.iter().map(|size| size.rows)), + ), + }; + non_zero_size(aggregate) + } + NodeRecordKind::Tabs => { + let tabs = node.tabs.as_ref()?; + let active = usize::try_from(tabs.active).ok()?; + tabs.tabs + .get(active) + .or_else(|| tabs.tabs.first()) + .and_then(|tab| visible_size_for_node(state, tab.child_id)) + } + } +} + +fn build_snapshot( + state: &EmbersClientState, + clients: &[ClientRecord], + current_client: &ClientRecord, +) -> Result { + let client_state = state.client.state(); + let mut session_records = client_state.sessions.values().cloned().collect::>(); + session_records.sort_by(|left, right| left.name.cmp(&right.name)); + + let mut sessions = Vec::with_capacity(session_records.len()); + let mut windows = Vec::new(); + let mut panes = Vec::new(); + + for session in session_records { + let attached = clients + .iter() + .any(|client| client.current_session_id == Some(session.id)); + let session_windows = project_session_windows(client_state, &session)?; + for projection in &session_windows { + windows.push(projection.window.clone()); + panes.extend(projection.panes.iter().cloned()); + } + sessions.push(EmbersSession { + native_id: session.id.to_string(), + name: session.name, + attached, + last_activity: None, + }); + } + + let current_session_name = current_client.current_session_id.and_then(|session_id| { + client_state + .sessions + .get(&session_id) + .map(|session| session.name.clone()) + }); + let current_window_index = current_client + .current_session_id + .and_then(|session_id| current_window_index(client_state, session_id)); + let pane_id = current_client + .current_session_id + .and_then(|session_id| client_state.sessions.get(&session_id)) + .and_then(|session| session.focused_leaf_id) + .map(|pane_id| pane_id.to_string()); + let previous_session_name = state.previous_session_id.and_then(|session_id| { + client_state + .sessions + .get(&session_id) + .map(|session| session.name.clone()) + }); + + Ok(EmbersSnapshot { + context: EmbersContext { + client_id: Some(current_client.id.to_string()), + current_session_name, + current_window_index, + pane_id, + previous_session_name, + }, + sessions, + windows, + panes, + }) +} + +fn current_window_index(state: &ClientState, session_id: SessionId) -> Option { + let session = state.sessions.get(&session_id)?; + let root = state.nodes.get(&session.root_node_id)?; + if root.kind != NodeRecordKind::Tabs { + return Some(1); + } + let tabs = root.tabs.as_ref()?; + if let Some(focused_leaf_id) = session.focused_leaf_id { + for (index, tab) in tabs.tabs.iter().enumerate() { + if node_belongs_to_subtree(state, tab.child_id, focused_leaf_id) { + return u32::try_from(index + 1).ok(); + } + } + } + u32::try_from(usize::try_from(tabs.active).ok()?.saturating_add(1)).ok() +} + +fn project_session_windows( + state: &ClientState, + session: &SessionRecord, +) -> Result, EmbersError> { + let root = state.nodes.get(&session.root_node_id).ok_or_else(|| { + EmbersError::InvalidState(format!("node {} is not cached", session.root_node_id)) + })?; + if root.kind == NodeRecordKind::Tabs { + let tabs = root.tabs.as_ref().ok_or_else(|| { + EmbersError::InvalidState(format!( + "tabs node {} is missing tabs payload", + session.root_node_id + )) + })?; + return tabs + .tabs + .iter() + .enumerate() + .map(|(index, tab)| { + let index = u32::try_from(index + 1).map_err(|_| { + EmbersError::InvalidState("tab index exceeded u32 range".to_string()) + })?; + build_window_projection( + state, + session, + index, + tab.title.clone(), + index == tabs.active.saturating_add(1), + tab.child_id, + ) + }) + .collect(); + } + + vec![build_window_projection( + state, + session, + 1, + default_window_name(state, session.root_node_id, &session.name), + true, + session.root_node_id, + )] + .into_iter() + .collect() +} + +fn build_window_projection( + state: &ClientState, + session: &SessionRecord, + index: u32, + name: String, + active: bool, + root_node_id: NodeId, +) -> Result { + let mut pane_nodes = Vec::new(); + collect_buffer_view_nodes(state, root_node_id, &mut pane_nodes)?; + let panes = pane_nodes + .into_iter() + .map(|pane_node_id| { + let buffer = buffer_for_node(state, pane_node_id)?; + Ok(EmbersPane { + session_name: session.name.clone(), + window_index: index, + pane_id: pane_node_id.to_string(), + title: buffer.title.clone(), + active: session.focused_leaf_id == Some(pane_node_id), + current_path: buffer.cwd.as_ref().map(PathBuf::from), + current_command: display_command(&buffer.command), + activity: buffer.activity, + }) + }) + .collect::, EmbersError>>()?; + + let active_pane = panes + .iter() + .find(|pane| pane.active) + .or_else(|| panes.first()); + let activity = panes + .iter() + .any(|pane| matches!(pane.activity, ActivityState::Activity)); + let bell = panes + .iter() + .any(|pane| matches!(pane.activity, ActivityState::Bell)); + + Ok(WindowProjection { + window: EmbersWindow { + session_name: session.name.clone(), + index, + name, + active, + activity, + bell, + silence: false, + current_path: active_pane.and_then(|pane| pane.current_path.clone()), + current_command: active_pane.and_then(|pane| pane.current_command.clone()), + }, + panes, + }) +} + +fn buffer_for_node( + state: &ClientState, + node_id: NodeId, +) -> Result<&embers_protocol::BufferRecord, EmbersError> { + let node = state + .nodes + .get(&node_id) + .ok_or_else(|| EmbersError::InvalidState(format!("node {node_id} is not cached")))?; + let buffer_view = node + .buffer_view + .as_ref() + .ok_or_else(|| EmbersError::InvalidState(format!("node {node_id} is not a buffer view")))?; + state.buffers.get(&buffer_view.buffer_id).ok_or_else(|| { + EmbersError::InvalidState(format!("buffer {} is not cached", buffer_view.buffer_id)) + }) +} + +fn collect_buffer_view_nodes( + state: &ClientState, + node_id: NodeId, + buffer_views: &mut Vec, +) -> Result<(), EmbersError> { + let node = state + .nodes + .get(&node_id) + .ok_or_else(|| EmbersError::InvalidState(format!("node {node_id} is not cached")))?; + match node.kind { + NodeRecordKind::BufferView => buffer_views.push(node_id), + NodeRecordKind::Split => { + let split = node.split.as_ref().ok_or_else(|| { + EmbersError::InvalidState(format!("split node {node_id} is missing split payload")) + })?; + for child_id in &split.child_ids { + collect_buffer_view_nodes(state, *child_id, buffer_views)?; + } + } + NodeRecordKind::Tabs => { + let tabs = node.tabs.as_ref().ok_or_else(|| { + EmbersError::InvalidState(format!("tabs node {node_id} is missing tabs payload")) + })?; + for tab in &tabs.tabs { + collect_buffer_view_nodes(state, tab.child_id, buffer_views)?; + } + } + } + Ok(()) +} + +fn first_buffer_id_in_subtree(state: &ClientState, node_id: NodeId) -> Option { + let mut buffer_views = Vec::new(); + collect_buffer_view_nodes(state, node_id, &mut buffer_views).ok()?; + buffer_views + .into_iter() + .find_map(|buffer_node_id| buffer_id_for_buffer_view(state, buffer_node_id)) +} + +fn buffer_id_for_buffer_view(state: &ClientState, node_id: NodeId) -> Option { + let node = state.nodes.get(&node_id)?; + let buffer_view = node.buffer_view.as_ref()?; + Some(buffer_view.buffer_id) +} + +fn centered_geometry(viewport: PtySize, width: u16, height: u16) -> FloatGeometry { + let max_width = viewport.cols.max(1); + let max_height = viewport.rows.max(1); + let width = width.clamp(1, max_width); + let height = height.clamp(1, max_height); + FloatGeometry::new( + max_width.saturating_sub(width) / 2, + max_height.saturating_sub(height) / 2, + width, + height, + ) +} + +fn split_sizes_for_join( + viewport: PtySize, + leading_size: u16, + placement: NodeJoinPlacement, +) -> Option> { + let total = match placement { + NodeJoinPlacement::Left | NodeJoinPlacement::Right => viewport.cols, + NodeJoinPlacement::Up | NodeJoinPlacement::Down => viewport.rows, + NodeJoinPlacement::TabBefore | NodeJoinPlacement::TabAfter => return None, + }; + if total <= 1 { + return None; + } + let leading = leading_size.clamp(1, total.saturating_sub(1)); + let trailing = total.saturating_sub(leading).max(1); + Some(match placement { + NodeJoinPlacement::Left | NodeJoinPlacement::Up => vec![leading, trailing], + NodeJoinPlacement::Right | NodeJoinPlacement::Down => vec![trailing, leading], + NodeJoinPlacement::TabBefore | NodeJoinPlacement::TabAfter => unreachable!(), + }) +} + +fn non_zero_size(size: PtySize) -> Option { + (size.cols > 0 && size.rows > 0).then_some(size) +} + +fn saturating_u16_sum(values: impl Iterator) -> u16 { + values + .fold(0_u32, |total, value| total.saturating_add(u32::from(value))) + .min(u32::from(u16::MAX)) as u16 +} + +fn node_belongs_to_subtree( + state: &ClientState, + root_node_id: NodeId, + target_node_id: NodeId, +) -> bool { + if root_node_id == target_node_id { + return true; + } + let Some(node) = state.nodes.get(&root_node_id) else { + return false; + }; + child_ids(node) + .into_iter() + .any(|child_id| node_belongs_to_subtree(state, child_id, target_node_id)) +} + +fn child_ids(node: &NodeRecord) -> Vec { + match node.kind { + NodeRecordKind::BufferView => Vec::new(), + NodeRecordKind::Split => node + .split + .as_ref() + .map(|split| split.child_ids.clone()) + .unwrap_or_default(), + NodeRecordKind::Tabs => node + .tabs + .as_ref() + .map(|tabs| tabs.tabs.iter().map(|tab| tab.child_id).collect()) + .unwrap_or_default(), + } +} + +fn default_window_name(state: &ClientState, root_node_id: NodeId, fallback: &str) -> String { + first_buffer_id_in_subtree(state, root_node_id) + .and_then(|buffer_id| state.buffers.get(&buffer_id)) + .map(|buffer| buffer.title.clone()) + .filter(|title| !title.is_empty()) + .unwrap_or_else(|| fallback.to_string()) +} + +fn display_command(command: &[String]) -> Option { + let first = command.first()?; + Path::new(first) + .file_name() + .and_then(|name| name.to_str()) + .map(ToString::to_string) + .or_else(|| Some(first.clone())) +} + +fn default_shell_command() -> Vec { + vec![std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())] +} + +fn parse_buffer_id(value: &str) -> Result { + value + .parse::() + .map(BufferId) + .map_err(|_| EmbersError::InvalidIdentifier { + kind: "buffer", + value: value.to_string(), + }) +} + +fn default_title(command: &[String], fallback: &str) -> String { + command + .first() + .and_then(|value| { + Path::new(value) + .file_name() + .and_then(|file_name| file_name.to_str()) + }) + .filter(|value| !value.is_empty()) + .unwrap_or(fallback) + .to_string() +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::{ + EmbersJoinPlacement, centered_geometry, split_sizes_for_join, visible_size_for_node, + }; + use embers_client::ClientState; + use embers_core::{ActivityState, BufferId, FloatGeometry, NodeId, PtySize, SessionId}; + use embers_protocol::{ + BufferRecord, BufferRecordKind, BufferRecordState, BufferViewRecord, NodeRecord, + NodeRecordKind, SessionRecord, SplitRecord, + }; + + #[test] + fn visible_size_for_split_sums_vertical_children() { + let mut state = ClientState::default(); + state.sessions.insert( + SessionId(1), + SessionRecord { + id: SessionId(1), + name: "alpha".to_string(), + root_node_id: NodeId(1), + floating_ids: Vec::new(), + focused_leaf_id: Some(NodeId(2)), + focused_floating_id: None, + zoomed_node_id: None, + }, + ); + state.nodes.insert( + NodeId(1), + NodeRecord { + id: NodeId(1), + session_id: SessionId(1), + parent_id: None, + kind: NodeRecordKind::Split, + buffer_view: None, + split: Some(SplitRecord { + direction: embers_core::SplitDirection::Vertical, + child_ids: vec![NodeId(2), NodeId(3)], + sizes: vec![36, 84], + }), + tabs: None, + }, + ); + state.nodes.insert( + NodeId(2), + buffer_view_node( + NodeId(2), + Some(NodeId(1)), + BufferId(10), + PtySize::new(36, 20), + ), + ); + state.nodes.insert( + NodeId(3), + buffer_view_node( + NodeId(3), + Some(NodeId(1)), + BufferId(11), + PtySize::new(84, 20), + ), + ); + state + .buffers + .insert(BufferId(10), buffer_record(BufferId(10))); + state + .buffers + .insert(BufferId(11), buffer_record(BufferId(11))); + + assert_eq!( + visible_size_for_node(&state, NodeId(1)), + Some(PtySize::new(120, 20)) + ); + } + + #[test] + fn split_sizes_for_join_respects_left_and_right_ordering() { + let viewport = PtySize::new(120, 40); + assert_eq!( + split_sizes_for_join(viewport, 36, EmbersJoinPlacement::Left), + Some(vec![36, 84]) + ); + assert_eq!( + split_sizes_for_join(viewport, 36, EmbersJoinPlacement::Right), + Some(vec![84, 36]) + ); + } + + #[test] + fn centered_geometry_clamps_to_viewport() { + assert_eq!( + centered_geometry(PtySize::new(100, 30), 120, 40), + FloatGeometry::new(0, 0, 100, 30) + ); + } + + fn buffer_view_node( + id: NodeId, + parent_id: Option, + buffer_id: BufferId, + size: PtySize, + ) -> NodeRecord { + NodeRecord { + id, + session_id: SessionId(1), + parent_id, + kind: NodeRecordKind::BufferView, + buffer_view: Some(BufferViewRecord { + buffer_id, + focused: false, + zoomed: false, + follow_output: true, + last_render_size: size, + }), + split: None, + tabs: None, + } + } + + fn buffer_record(id: BufferId) -> BufferRecord { + BufferRecord { + id, + title: "buffer".to_string(), + command: vec!["/bin/sh".to_string()], + cwd: None, + pipe: None, + kind: BufferRecordKind::Pty, + state: BufferRecordState::Running, + pid: Some(1), + attachment_node_id: None, + read_only: false, + helper_source_buffer_id: None, + helper_scope: None, + pty_size: PtySize::new(80, 24), + activity: ActivityState::Idle, + last_snapshot_seq: 0, + exit_code: None, + env: BTreeMap::new(), + } + } +} diff --git a/crates/wisp-embers/tests/integration.rs b/crates/wisp-embers/tests/integration.rs new file mode 100644 index 0000000..e726e3a --- /dev/null +++ b/crates/wisp-embers/tests/integration.rs @@ -0,0 +1,277 @@ +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use embers_core::{BufferId, SessionId, new_request_id}; +use embers_protocol::{ + BufferLocation, BufferLocationResponse, BufferRequest, ClientMessage, ServerResponse, + SessionRequest, +}; +use embers_test_support::TestServer; +use wisp_embers::{EmbersClient, EmbersJoinPlacement}; + +fn unique_test_dir(name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + std::env::temp_dir().join(format!("wisp-embers-{name}-{nonce}")) +} + +fn shell_sleep_command(seconds: u64) -> Vec { + vec![ + "/bin/sh".to_string(), + "-lc".to_string(), + format!("sleep {seconds}"), + ] +} + +async fn session_id_by_name( + connection: &mut embers_test_support::TestConnection, + name: &str, +) -> SessionId { + match connection + .request(&ClientMessage::Session(SessionRequest::List { + request_id: new_request_id(), + })) + .await + .expect("list sessions succeeds") + { + ServerResponse::Sessions(response) => { + response + .sessions + .into_iter() + .find(|session| session.name == name) + .expect("session exists") + .id + } + other => panic!("expected sessions response, got {other:?}"), + } +} + +async fn buffer_location( + connection: &mut embers_test_support::TestConnection, + buffer_id: BufferId, +) -> BufferLocation { + match connection + .request(&ClientMessage::Buffer(BufferRequest::GetLocation { + request_id: new_request_id(), + buffer_id, + })) + .await + .expect("buffer location succeeds") + { + ServerResponse::BufferLocation(BufferLocationResponse { location, .. }) => location, + other => panic!("expected buffer location response, got {other:?}"), + } +} + +fn parse_buffer_id(buffer_id: &str) -> BufferId { + BufferId(buffer_id.parse().expect("buffer id should parse")) +} + +fn make_session_dir(path: &Path) { + fs::create_dir_all(path).expect("session directory"); +} + +#[test] +fn creates_floating_window_for_current_session_buffer() { + let runtime = tokio::runtime::Runtime::new().expect("test runtime"); + let server = runtime + .block_on(TestServer::start()) + .expect("start embers server"); + let workspace = unique_test_dir("floating"); + make_session_dir(&workspace); + let client = EmbersClient::connect(server.socket_path()).expect("create embers client"); + client + .create_or_switch_session("alpha", &workspace) + .expect("create current session"); + assert_eq!( + client.current_session_name().expect("read current session"), + Some("alpha".to_string()) + ); + + let viewport = client + .current_session_viewport_size() + .expect("read viewport") + .expect("viewport should exist"); + assert!(viewport.0 >= 40); + assert!(viewport.1 >= 12); + + let buffer_id = client + .create_buffer( + &shell_sleep_command(30), + "wisp float test", + Some(&workspace), + &BTreeMap::new(), + ) + .expect("create floating buffer"); + client + .create_floating_for_buffer_in_current_session( + &buffer_id, + Some("Wisp Float"), + 40, + 12, + true, + true, + ) + .expect("create floating window"); + assert_eq!( + client.focused_buffer_id().expect("read focused buffer"), + Some(buffer_id.clone()) + ); + + let mut connection = runtime + .block_on(embers_test_support::TestConnection::connect( + server.socket_path(), + )) + .expect("connect protocol client"); + let alpha_id = runtime.block_on(session_id_by_name(&mut connection, "alpha")); + let snapshot = runtime + .block_on(connection.session_snapshot(alpha_id)) + .expect("read alpha snapshot"); + assert_eq!(snapshot.floating.len(), 1); + let floating = &snapshot.floating[0]; + assert_eq!(floating.title.as_deref(), Some("Wisp Float")); + assert_eq!(floating.geometry.width, 40); + assert_eq!(floating.geometry.height, 12); + assert!(floating.focused); + assert!(floating.close_on_empty); + + let location = runtime.block_on(buffer_location( + &mut connection, + parse_buffer_id(&buffer_id), + )); + assert_eq!(location.session_id(), Some(alpha_id)); + assert_eq!(location.floating_id(), Some(floating.id)); + assert!(location.node_id().is_some()); + + let _ = fs::remove_dir_all(&workspace); + runtime + .block_on(server.shutdown()) + .expect("shutdown embers server"); +} + +#[test] +fn joins_moves_and_detaches_sidebar_buffer_at_session_root() { + let runtime = tokio::runtime::Runtime::new().expect("test runtime"); + let server = runtime + .block_on(TestServer::start()) + .expect("start embers server"); + let alpha_dir = unique_test_dir("alpha"); + let beta_dir = unique_test_dir("beta"); + make_session_dir(&alpha_dir); + make_session_dir(&beta_dir); + let client = EmbersClient::connect(server.socket_path()).expect("create embers client"); + client + .create_or_switch_session("alpha", &alpha_dir) + .expect("create alpha session"); + let buffer_id = client + .create_buffer( + &shell_sleep_command(30), + "wisp sidebar test", + Some(&alpha_dir), + &BTreeMap::new(), + ) + .expect("create sidebar buffer"); + + let joined_session = client + .join_buffer_to_current_session_root(&buffer_id, EmbersJoinPlacement::Left, Some(36), true) + .expect("join buffer to alpha root"); + assert_eq!(joined_session, "alpha"); + assert_eq!( + client + .focused_buffer_id() + .expect("read focused alpha buffer"), + Some(buffer_id.clone()) + ); + + let mut connection = runtime + .block_on(embers_test_support::TestConnection::connect( + server.socket_path(), + )) + .expect("connect protocol client"); + let alpha_id = runtime.block_on(session_id_by_name(&mut connection, "alpha")); + let alpha_snapshot = runtime + .block_on(connection.session_snapshot(alpha_id)) + .expect("alpha snapshot"); + let alpha_root = alpha_snapshot + .nodes + .iter() + .find(|node| node.id == alpha_snapshot.session.root_node_id) + .expect("alpha root node"); + let alpha_split = alpha_root.split.as_ref().expect("root should become split"); + assert_eq!(alpha_split.sizes.first().copied(), Some(36)); + assert_eq!(alpha_split.child_ids.len(), 2); + + let alpha_location = runtime.block_on(buffer_location( + &mut connection, + parse_buffer_id(&buffer_id), + )); + assert_eq!(alpha_location.session_id(), Some(alpha_id)); + assert!(alpha_location.node_id().is_some()); + assert!(alpha_location.floating_id().is_none()); + + client + .create_or_switch_session("beta", &beta_dir) + .expect("create beta session"); + assert_eq!( + client.current_session_name().expect("read beta session"), + Some("beta".to_string()) + ); + + let moved_session = client + .join_buffer_to_current_session_root(&buffer_id, EmbersJoinPlacement::Left, Some(36), true) + .expect("move buffer to beta root"); + assert_eq!(moved_session, "beta"); + assert_eq!( + client + .focused_buffer_id() + .expect("read focused beta buffer"), + Some(buffer_id.clone()) + ); + + let beta_id = runtime.block_on(session_id_by_name(&mut connection, "beta")); + let moved_location = runtime.block_on(buffer_location( + &mut connection, + parse_buffer_id(&buffer_id), + )); + assert_eq!(moved_location.session_id(), Some(beta_id)); + assert!(moved_location.node_id().is_some()); + assert!(moved_location.floating_id().is_none()); + + let beta_snapshot = runtime + .block_on(connection.session_snapshot(beta_id)) + .expect("beta snapshot"); + let beta_root = beta_snapshot + .nodes + .iter() + .find(|node| node.id == beta_snapshot.session.root_node_id) + .expect("beta root node"); + let beta_split = beta_root + .split + .as_ref() + .expect("beta root should become split"); + assert_eq!(beta_split.sizes.first().copied(), Some(36)); + assert_eq!(beta_split.child_ids.len(), 2); + + client + .detach_buffer(&buffer_id) + .expect("detach sidebar buffer"); + let detached_location = runtime.block_on(buffer_location( + &mut connection, + parse_buffer_id(&buffer_id), + )); + assert_eq!(detached_location.session_id(), None); + assert_eq!(detached_location.node_id(), None); + assert_eq!(detached_location.floating_id(), None); + + let _ = fs::remove_dir_all(&alpha_dir); + let _ = fs::remove_dir_all(&beta_dir); + runtime + .block_on(server.shutdown()) + .expect("shutdown embers server"); +} diff --git a/crates/wisp-preview/src/lib.rs b/crates/wisp-preview/src/lib.rs index 2dca8fb..d9d2edf 100644 --- a/crates/wisp-preview/src/lib.rs +++ b/crates/wisp-preview/src/lib.rs @@ -209,13 +209,12 @@ where return Err(PreviewError::Unsupported); }; - let captured = - self.tmux - .capture_pane(session_name) - .map_err(|source| PreviewError::Tmux { - session_name: session_name.clone(), - message: source.to_string(), - })?; + let captured = self.tmux.capture_pane(session_name).map_err(|source| { + PreviewError::SessionCapture { + session_name: session_name.clone(), + message: source.to_string(), + } + })?; Ok(PreviewContent::from_text_tail( format!("Pane {session_name}"), @@ -237,8 +236,8 @@ pub enum PreviewError { }, #[error("session `{0}` was not found")] MissingSession(String), - #[error("failed to capture active pane for session `{session_name}`: {message}")] - Tmux { + #[error("failed to capture session preview for `{session_name}`: {message}")] + SessionCapture { session_name: String, message: String, }, @@ -311,7 +310,7 @@ mod tests { "alpha".to_string(), SessionRecord { id: "alpha".to_string(), - tmux_id: None, + native_id: None, name: "alpha".to_string(), attached: true, windows: BTreeMap::from([( diff --git a/docs/config.schema.toml b/docs/config.schema.toml index e7dc89f..6ea59b0 100644 --- a/docs/config.schema.toml +++ b/docs/config.schema.toml @@ -6,6 +6,14 @@ # Wisp merges configuration as: # defaults < config file < environment overrides < CLI overrides +[backend] +# Runtime backend selection: +# - "auto" prefers Embers when an Embers socket is configured or exported +# through EMBERS_SOCKET, otherwise Wisp falls back to tmux. +# - "tmux" forces tmux. +# - "embers" forces Embers. +kind = "auto" + [ui] # Preferred UI mode: "auto", "popup", or "fullscreen". mode = "auto" @@ -33,6 +41,10 @@ engine = "nucleo" # Case mode: "ignore", "respect", or "smart". case_mode = "smart" +[embers] +# Optional Embers socket path. If unset, Wisp still auto-detects EMBERS_SOCKET. +# socket_path = "/tmp/embers.sock" + [tmux] # Query extra window information from tmux when needed. query_windows = false @@ -42,6 +54,7 @@ prefer_popup = true # Popup dimensions accept either percentages like "80%" # or absolute cell counts like "120". +# Wisp also reuses these dimensions for Embers floating popup surfaces. popup_width = "80%" popup_height = "85%" diff --git a/docs/configuration.md b/docs/configuration.md index 1a10bfc..d39f41b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -25,12 +25,20 @@ The config library supports a strict mode that rejects unknown TOML keys using ` ## Value formats +- `backend.kind` accepts `auto`, `tmux`, or `embers`. +- `embers.socket_path` accepts an absolute socket path. - `tmux.popup_width` and `tmux.popup_height` accept either percentages like `"80%"` or cell counts like `"40"`. - `ui.preview_width` is a float from `0.2` to `0.8`. - Boolean environment overrides accept `1`, `true`, `yes`, `on`, `0`, `false`, `no`, or `off`. ## Sections and keys +### `[backend]` + +| Key | Type | Default | Valid values | Notes | +| --- | --- | --- | --- | --- | +| `kind` | string | `"auto"` | `auto`, `tmux`, `embers` | With the `embers` Cargo feature enabled, `auto` prefers Embers when an Embers socket is configured or exposed via `EMBERS_SOCKET`; otherwise Wisp falls back to tmux. Without that feature, `auto` always uses tmux and explicit `embers` reports that support was not compiled in. | + ### `[ui]` | Key | Type | Default | Valid values | Notes | @@ -49,6 +57,12 @@ The config library supports a strict mode that rejects unknown TOML keys using ` | `engine` | string | `"nucleo"` | `nucleo`, `skim` | Matcher backend selection. | | `case_mode` | string | `"smart"` | `ignore`, `respect`, `smart` | Case-sensitivity strategy for fuzzy matching. | +### `[embers]` + +| Key | Type | Default | Valid values | Notes | +| --- | --- | --- | --- | --- | +| `socket_path` | string | unset | absolute path | Socket path used when the Embers backend is selected. If omitted, Wisp still auto-detects `EMBERS_SOCKET` at runtime. | + ### `[tmux]` | Key | Type | Default | Valid values | Notes | @@ -58,6 +72,8 @@ The config library supports a strict mode that rejects unknown TOML keys using ` | `popup_width` | string | `"80%"` | percent or cells | Popup width, for example `"80%"` or `"120"`. | | `popup_height` | string | `"85%"` | percent or cells | Popup height, for example `"85%"` or `"40"`. | +Embers runtime support is behind the `embers` Cargo feature and expects the local Embers checkout used by the adapter crate. When Wisp is built with that feature and `backend.kind = "embers"`, `wisp fullscreen`, `wisp popup`, `wisp sidebar-popup`, `wisp sidebar-pane`, and the internal `wisp ui ...` surfaces work against Embers. `wisp popup` and `wisp sidebar-popup` open native Embers floating windows, and `wisp sidebar-pane` opens a root-level split sidebar in the current session. Popup sizing still reuses `tmux.popup_width` and `tmux.popup_height`. `wisp statusline ...` remains tmux-only for now. + ### `[status]` | Key | Type | Default | Valid values | Notes | @@ -130,22 +146,31 @@ These environment variables are recognized today: | Variable | Effect | | --- | --- | | `WISP_CONFIG` | Overrides the config file path. | +| `WISP_BACKEND` | Overrides `backend.kind`. | +| `WISP_EMBERS_SOCKET` | Overrides `embers.socket_path`. | | `WISP_MODE` or `WISP_UI_MODE` | Overrides `ui.mode`. | | `WISP_ENGINE` or `WISP_FUZZY_ENGINE` | Overrides `fuzzy.engine`. | | `WISP_LOG_LEVEL` | Overrides `logging.level`. | | `WISP_PREVIEW_ENABLED` | Overrides `preview.enabled`. | | `WISP_TMUX_PREFER_POPUP` | Overrides `tmux.prefer_popup`. | | `WISP_NO_ZOXIDE` | Disables zoxide by forcing `zoxide.enabled = false`. | +| `EMBERS_SOCKET` | Lets `backend.kind = "auto"` discover Embers automatically when no Wisp-specific socket override is set. | ## Example ```toml +[backend] +kind = "auto" + [ui] mode = "popup" preview_position = "right" preview_width = 0.6 session_sort = "recent" +[embers] +# socket_path = "/tmp/embers.sock" + [tmux] prefer_popup = true popup_width = "80%" diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 292fe49..38ab2c6 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,3 @@ [toolchain] -channel = "stable" +channel = "1.95.0" +components = ["clippy", "rustfmt"]