From 6541751aab697f4594793c318f72e08795071ffe Mon Sep 17 00:00:00 2001 From: James Henry Date: Wed, 9 Apr 2025 17:56:55 +0100 Subject: [PATCH] feat(core): add the experimental Terminal UI for tasks (#30565) --- Cargo.lock | 803 +++++- docs/generated/cli/affected.md | 1 + docs/generated/cli/run-many.md | 1 + docs/generated/cli/run.md | 1 + docs/generated/devkit/NxJsonConfiguration.md | 16 + docs/generated/devkit/Workspace.md | 20 + .../packages/nx/documents/affected.md | 1 + .../packages/nx/documents/run-many.md | 1 + docs/generated/packages/nx/documents/run.md | 1 + packages/nx/Cargo.toml | 38 +- packages/nx/schemas/nx-schema.json | 17 + packages/nx/src/adapter/compat.ts | 1 + .../command-line/affected/command-object.ts | 27 +- .../src/command-line/exec/command-object.ts | 3 +- .../nx/src/command-line/release/publish.ts | 2 + .../command-line/run-many/command-object.ts | 19 +- .../nx/src/command-line/run/command-object.ts | 5 +- packages/nx/src/command-line/run/run.ts | 4 +- .../yargs-utils/shared-options.ts | 26 +- packages/nx/src/config/nx-json.ts | 18 + .../run-commands/run-commands.impl.ts | 84 +- .../executors/run-commands/running-tasks.ts | 133 +- .../executors/run-script/run-script.impl.ts | 8 +- packages/nx/src/native/index.d.ts | 35 + packages/nx/src/native/logger/mod.rs | 29 +- packages/nx/src/native/mod.rs | 2 + packages/nx/src/native/native-bindings.js | 2 + .../native/pseudo_terminal/child_process.rs | 24 +- .../native/pseudo_terminal/command/unix.rs | 9 +- .../native/pseudo_terminal/command/windows.rs | 8 +- packages/nx/src/native/pseudo_terminal/mac.rs | 21 +- packages/nx/src/native/pseudo_terminal/mod.rs | 2 +- .../nx/src/native/pseudo_terminal/non_mac.rs | 20 +- .../native/pseudo_terminal/pseudo_terminal.rs | 428 ++-- packages/nx/src/native/tasks/types.rs | 12 + packages/nx/src/native/tui/action.rs | 25 + packages/nx/src/native/tui/app.rs | 687 +++++ packages/nx/src/native/tui/components.rs | 52 + .../native/tui/components/countdown_popup.rs | 299 +++ .../src/native/tui/components/help_popup.rs | 347 +++ .../nx/src/native/tui/components/help_text.rs | 83 + .../src/native/tui/components/pagination.rs | 64 + .../tui/components/task_selection_manager.rs | 464 ++++ .../src/native/tui/components/tasks_list.rs | 2262 +++++++++++++++++ .../native/tui/components/terminal_pane.rs | 493 ++++ packages/nx/src/native/tui/config.rs | 62 + packages/nx/src/native/tui/lifecycle.rs | 262 ++ packages/nx/src/native/tui/mod.rs | 8 + packages/nx/src/native/tui/pty.rs | 120 + packages/nx/src/native/tui/tui.rs | 212 ++ packages/nx/src/native/tui/utils.rs | 392 +++ .../nx-cloud/nx-cloud-tasks-runner-shell.ts | 16 +- .../nx/src/tasks-runner/create-task-graph.ts | 32 +- .../src/tasks-runner/default-tasks-runner.ts | 16 +- .../forked-process-task-runner.ts | 79 +- .../nx/src/tasks-runner/is-tui-enabled.ts | 66 + packages/nx/src/tasks-runner/life-cycle.ts | 34 +- .../task-history-life-cycle-old.ts | 11 +- .../life-cycles/task-history-life-cycle.ts | 11 +- .../life-cycles/tui-summary-life-cycle.ts | 394 +++ .../src/tasks-runner/pseudo-terminal.spec.ts | 32 +- .../nx/src/tasks-runner/pseudo-terminal.ts | 76 +- packages/nx/src/tasks-runner/run-command.ts | 233 +- packages/nx/src/tasks-runner/task-env.ts | 6 +- .../nx/src/tasks-runner/task-orchestrator.ts | 157 +- packages/nx/src/tasks-runner/utils.ts | 32 +- 66 files changed, 8216 insertions(+), 633 deletions(-) create mode 100644 packages/nx/src/native/tui/action.rs create mode 100644 packages/nx/src/native/tui/app.rs create mode 100644 packages/nx/src/native/tui/components.rs create mode 100644 packages/nx/src/native/tui/components/countdown_popup.rs create mode 100644 packages/nx/src/native/tui/components/help_popup.rs create mode 100644 packages/nx/src/native/tui/components/help_text.rs create mode 100644 packages/nx/src/native/tui/components/pagination.rs create mode 100644 packages/nx/src/native/tui/components/task_selection_manager.rs create mode 100644 packages/nx/src/native/tui/components/tasks_list.rs create mode 100644 packages/nx/src/native/tui/components/terminal_pane.rs create mode 100644 packages/nx/src/native/tui/config.rs create mode 100644 packages/nx/src/native/tui/lifecycle.rs create mode 100644 packages/nx/src/native/tui/mod.rs create mode 100644 packages/nx/src/native/tui/pty.rs create mode 100644 packages/nx/src/native/tui/tui.rs create mode 100644 packages/nx/src/native/tui/utils.rs create mode 100644 packages/nx/src/tasks-runner/is-tui-enabled.ts create mode 100644 packages/nx/src/tasks-runner/life-cycles/tui-summary-life-cycle.ts diff --git a/Cargo.lock b/Cargo.lock index 0d46fd5b80..c2d5df58ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,6 +71,21 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstyle" version = "1.0.6" @@ -83,6 +98,32 @@ version = "1.0.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247" +[[package]] +name = "arboard" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1df21f715862ede32a0c525ce2ca4d52626bb0007f8c18b87a384503ac33e70" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "x11rb", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "assert_fs" version = "1.1.1" @@ -107,7 +148,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] @@ -127,7 +168,7 @@ checksum = "30c5ef0ede93efbf733c1a727f3b6b5a1060bbedd5600183e66f6e4be4af0ec5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] @@ -138,7 +179,7 @@ checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" dependencies = [ "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] @@ -174,6 +215,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "better-panic" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fa9e1d11a268684cbd90ed36370d7577afb6c62d912ddff5c15fc34343e5036" +dependencies = [ + "backtrace", + "console", +] + [[package]] name = "better_scoped_tls" version = "0.1.1" @@ -192,7 +243,7 @@ dependencies = [ "bitflags 2.6.0", "cexpr", "clang-sys", - "itertools", + "itertools 0.10.5", "lazy_static", "lazycell", "log", @@ -202,7 +253,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.53", + "syn 2.0.100", "which", ] @@ -278,12 +329,39 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bytemuck" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.17" @@ -314,6 +392,20 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -325,6 +417,42 @@ dependencies = [ "libloading", ] +[[package]] +name = "clipboard-win" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" +dependencies = [ + "error-code", +] + +[[package]] +name = "color-eyre" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55146f5e46f237f7423d74111267d4597b59b0dad0ffaf7303bce9945d843ad5" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + [[package]] name = "colored" version = "2.1.0" @@ -347,6 +475,32 @@ dependencies = [ "winapi", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + [[package]] name = "convert_case" version = "0.6.0" @@ -413,14 +567,31 @@ checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ "bitflags 2.6.0", "crossterm_winapi", + "futures-core", "libc", - "mio", + "mio 0.8.11", "parking_lot", "signal-hook", "signal-hook-mio", "winapi", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.6.0", + "crossterm_winapi", + "mio 1.0.3", + "parking_lot", + "rustix 0.38.38", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -437,7 +608,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.53", + "syn 2.0.100", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.100", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.100", ] [[package]] @@ -493,12 +699,24 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "endian-type" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.10" @@ -509,12 +727,28 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "error-code" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d9305ccc6942a704f4335694ecd3de2ea531b114ac2d51f5f843750787a92f" + [[package]] name = "event-listener" version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -542,6 +776,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -580,6 +823,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -597,7 +846,7 @@ checksum = "3a0b11eeb173ce52f84ebd943d42e58813a2ebb78a6a3ff0a243b71c5199cd7b" dependencies = [ "proc-macro2", "swc_macros_common", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] @@ -687,7 +936,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] @@ -720,6 +969,16 @@ dependencies = [ "slab", ] +[[package]] +name = "gethostname" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +dependencies = [ + "libc", + "windows-targets 0.48.5", +] + [[package]] name = "getrandom" version = "0.2.12" @@ -1020,6 +1279,17 @@ dependencies = [ "rkyv", ] +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashlink" version = "0.9.1" @@ -1029,6 +1299,12 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.3.9" @@ -1141,6 +1417,36 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.5.0" @@ -1204,6 +1510,31 @@ dependencies = [ "tracing", ] +[[package]] +name = "image" +version = "0.25.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "num-traits", + "png", + "tiff", +] + +[[package]] +name = "indenter" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" + +[[package]] +name = "indoc" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" + [[package]] name = "inotify" version = "0.9.6" @@ -1224,6 +1555,19 @@ dependencies = [ "libc", ] +[[package]] +name = "instability" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "ioctl-rs" version = "0.1.6" @@ -1248,7 +1592,7 @@ dependencies = [ "Inflector", "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] @@ -1261,10 +1605,25 @@ dependencies = [ ] [[package]] -name = "itoa" -version = "1.0.10" +name = "itertools" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jpeg-decoder" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" [[package]] name = "js-sys" @@ -1359,9 +1718,18 @@ dependencies = [ [[package]] name = "log" -version = "0.4.21" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.2", +] [[package]] name = "machine-uid" @@ -1417,7 +1785,7 @@ dependencies = [ "miette-derive", "once_cell", "thiserror", - "unicode-width", + "unicode-width 0.1.11", ] [[package]] @@ -1428,7 +1796,7 @@ checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] @@ -1459,6 +1827,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -1473,6 +1842,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + [[package]] name = "napi" version = "2.16.17" @@ -1481,6 +1862,7 @@ checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" dependencies = [ "anyhow", "bitflags 2.6.0", + "chrono", "ctor", "napi-derive", "napi-sys", @@ -1505,7 +1887,7 @@ dependencies = [ "napi-derive-backend", "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] @@ -1520,7 +1902,7 @@ dependencies = [ "quote", "regex", "semver", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] @@ -1602,7 +1984,7 @@ dependencies = [ "kqueue", "libc", "log", - "mio", + "mio 0.8.11", "walkdir", "windows-sys 0.48.0", ] @@ -1686,22 +2068,26 @@ name = "nx" version = "0.1.0" dependencies = [ "anyhow", + "arboard", "assert_fs", + "better-panic", + "color-eyre", "colored", "crossbeam-channel", - "crossterm", + "crossterm 0.27.0", "dashmap", "dunce", "flate2", "fs4", "fs_extra", + "futures", "globset", "hashbrown 0.14.5", "ignore", "ignore-files 2.1.0", - "itertools", + "itertools 0.10.5", "machine-uid", - "mio", + "mio 0.8.11", "napi", "napi-build", "napi-derive", @@ -1710,6 +2096,7 @@ dependencies = [ "parking_lot", "portable-pty", "rand 0.9.0", + "ratatui", "rayon", "regex", "reqwest", @@ -1725,8 +2112,12 @@ dependencies = [ "tempfile", "thiserror", "tokio", + "tokio-util", "tracing", + "tracing-appender", "tracing-subscriber", + "tui-term", + "vt100-ctt", "walkdir", "watchexec", "watchexec-events", @@ -1736,6 +2127,77 @@ dependencies = [ "xxhash-rust", ] +[[package]] +name = "objc2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3531f65190d9cff863b77a99857e74c314dd16bf56c538c4b57c7cbc3f3a6e59" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5906f93257178e2f7ae069efb89fbd6ee94f0592740b5f8a1512ca498814d0fb" +dependencies = [ + "bitflags 2.6.0", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daeaf60f25471d26948a1c2f840e3f7d86f4109e3af4e8e4b5cd70c39690d925" +dependencies = [ + "bitflags 2.6.0", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dca602628b65356b6513290a21a6405b4d4027b8b250f0b98dddbb28b7de02" +dependencies = [ + "bitflags 2.6.0", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a21c6c9014b82c39515db5b396f91645182611c97d24637cf56ac01e5f8d998" +dependencies = [ + "bitflags 2.6.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "161a8b87e32610086e1a7a9e9ec39f84459db7b3a0881c1f16ca5a2605581c19" +dependencies = [ + "bitflags 2.6.0", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.32.2" @@ -1757,6 +2219,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + [[package]] name = "parking_lot" version = "0.12.1" @@ -1780,6 +2248,12 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.1" @@ -1823,6 +2297,19 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.5", +] + [[package]] name = "portable-pty" version = "0.8.1" @@ -1895,14 +2382,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d3928fb5db768cb86f891ff014f0144589297e3c6a1aba6ed7cecfdace270c7" dependencies = [ "proc-macro2", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] name = "proc-macro2" -version = "1.0.79" +version = "1.0.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" dependencies = [ "unicode-ident", ] @@ -1999,14 +2486,14 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "quote" -version = "1.0.35" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] @@ -2087,6 +2574,27 @@ dependencies = [ "getrandom 0.3.1", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.6.0", + "cassowary", + "compact_str", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "rayon" version = "1.9.0" @@ -2415,7 +2923,7 @@ checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] @@ -2533,12 +3041,13 @@ dependencies = [ [[package]] name = "signal-hook-mio" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio", + "mio 0.8.11", + "mio 1.0.3", "signal-hook", ] @@ -2551,6 +3060,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "simdutf8" version = "0.1.4" @@ -2659,7 +3174,35 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.53", + "syn 2.0.100", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.100", ] [[package]] @@ -2704,7 +3247,7 @@ dependencies = [ "swc_eq_ignore_macros", "swc_visit", "tracing", - "unicode-width", + "unicode-width 0.1.11", "url", ] @@ -2778,7 +3321,7 @@ checksum = "695a1d8b461033d32429b5befbf0ad4d7a2c4d6ba9cd5ba4e0645c615839e8e4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] @@ -2789,7 +3332,7 @@ checksum = "50176cfc1cbc8bb22f41c6fe9d1ec53fbe057001219b5954961b8ad0f336fce9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] @@ -2812,7 +3355,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] @@ -2828,9 +3371,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.53" +version = "2.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7383cd0e49fff4b6b90ca5670bfd3e9d6a733b3f90c686605aa7eec8c4996032" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" dependencies = [ "proc-macro2", "quote", @@ -2922,7 +3465,7 @@ checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] @@ -2935,6 +3478,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + [[package]] name = "time" version = "0.3.36" @@ -2992,8 +3546,9 @@ dependencies = [ "backtrace", "bytes", "libc", - "mio", + "mio 0.8.11", "num_cpus", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -3009,7 +3564,7 @@ checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] @@ -3033,6 +3588,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.2" @@ -3072,6 +3640,18 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +dependencies = [ + "crossbeam-channel", + "thiserror", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.27" @@ -3080,7 +3660,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] @@ -3093,6 +3673,16 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + [[package]] name = "tracing-log" version = "0.2.0" @@ -3138,6 +3728,16 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-term" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72af159125ce32b02ceaced6cffae6394b0e6b6dfd4dc164a6c59a2db9b3c0b0" +dependencies = [ + "ratatui", + "vt100", +] + [[package]] name = "typed-arena" version = "2.0.2" @@ -3183,12 +3783,29 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.11", +] + [[package]] name = "unicode-width" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "untrusted" version = "0.9.0" @@ -3206,6 +3823,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.8.0" @@ -3230,6 +3853,63 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "vt100" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +dependencies = [ + "itoa", + "log", + "unicode-width 0.1.11", + "vte 0.11.1", +] + +[[package]] +name = "vt100-ctt" +version = "0.16.0" +source = "git+https://github.com/JamesHenry/vt100-rust?rev=1de895505fe9f697aadac585e4075b8fb45c880d#1de895505fe9f697aadac585e4075b8fb45c880d" +dependencies = [ + "itoa", + "log", + "ratatui", + "tui-term", + "unicode-width 0.2.0", + "vte 0.13.1", +] + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a0b683b20ef64071ff03745b14391751f6beab06a54347885459b77a3f2caa5" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -3286,7 +3966,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.100", "wasm-bindgen-shared", ] @@ -3321,7 +4001,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.100", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3433,6 +4113,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" + [[package]] name = "which" version = "4.4.2" @@ -3506,7 +4192,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] @@ -3517,7 +4203,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] @@ -3822,6 +4508,23 @@ dependencies = [ "tap", ] +[[package]] +name = "x11rb" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" +dependencies = [ + "gethostname", + "rustix 0.38.38", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" + [[package]] name = "xattr" version = "1.5.0" @@ -3864,7 +4567,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] @@ -3875,7 +4578,7 @@ checksum = "6352c01d0edd5db859a63e2605f4ea3183ddbd15e2c4a9e7d32184df75e4f154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.100", ] [[package]] diff --git a/docs/generated/cli/affected.md b/docs/generated/cli/affected.md index be8eca8f5f..ab55298a89 100644 --- a/docs/generated/cli/affected.md +++ b/docs/generated/cli/affected.md @@ -106,6 +106,7 @@ Print the task graph to the console: | `--skipRemoteCache`, `--disableRemoteCache` | boolean | Disables the remote cache. (Default: `false`) | | `--skipSync` | boolean | Skips running the sync generators associated with the tasks. (Default: `false`) | | `--targets`, `--target`, `--t` | string | Tasks to run for affected projects. | +| `--tuiAutoExit` | string | Whether or not to exit the TUI automatically after all tasks finish, and after how long. If set to `true`, the TUI will exit immediately. If set to `false` the TUI will not automatically exit. If set to a number, an interruptible countdown popup will be shown for that many seconds before the TUI exits. | | `--uncommitted` | boolean | Uncommitted changes. | | `--untracked` | boolean | Untracked changes. | | `--verbose` | boolean | Prints additional information about the commands (e.g., stack traces). | diff --git a/docs/generated/cli/run-many.md b/docs/generated/cli/run-many.md index 2419807344..824005811d 100644 --- a/docs/generated/cli/run-many.md +++ b/docs/generated/cli/run-many.md @@ -110,5 +110,6 @@ Print the task graph to the console: | `--skipRemoteCache`, `--disableRemoteCache` | boolean | Disables the remote cache. (Default: `false`) | | `--skipSync` | boolean | Skips running the sync generators associated with the tasks. (Default: `false`) | | `--targets`, `--target`, `--t` | string | Tasks to run for affected projects. | +| `--tuiAutoExit` | string | Whether or not to exit the TUI automatically after all tasks finish, and after how long. If set to `true`, the TUI will exit immediately. If set to `false` the TUI will not automatically exit. If set to a number, an interruptible countdown popup will be shown for that many seconds before the TUI exits. | | `--verbose` | boolean | Prints additional information about the commands (e.g., stack traces). | | `--version` | boolean | Show version number. | diff --git a/docs/generated/cli/run.md b/docs/generated/cli/run.md index a1fbf9dde9..d58b87bb84 100644 --- a/docs/generated/cli/run.md +++ b/docs/generated/cli/run.md @@ -83,5 +83,6 @@ Run's a target named build:test for the myapp project. Note the quotes around th | `--skipNxCache`, `--disableNxCache` | boolean | Rerun the tasks even when the results are available in the cache. (Default: `false`) | | `--skipRemoteCache`, `--disableRemoteCache` | boolean | Disables the remote cache. (Default: `false`) | | `--skipSync` | boolean | Skips running the sync generators associated with the tasks. (Default: `false`) | +| `--tuiAutoExit` | string | Whether or not to exit the TUI automatically after all tasks finish, and after how long. If set to `true`, the TUI will exit immediately. If set to `false` the TUI will not automatically exit. If set to a number, an interruptible countdown popup will be shown for that many seconds before the TUI exits. | | `--verbose` | boolean | Prints additional information about the commands (e.g., stack traces). | | `--version` | boolean | Show version number. | diff --git a/docs/generated/devkit/NxJsonConfiguration.md b/docs/generated/devkit/NxJsonConfiguration.md index 163283ca90..6d9d9e38fd 100644 --- a/docs/generated/devkit/NxJsonConfiguration.md +++ b/docs/generated/devkit/NxJsonConfiguration.md @@ -42,6 +42,7 @@ Nx.json configuration - [sync](../../devkit/documents/NxJsonConfiguration#sync): NxSyncConfiguration - [targetDefaults](../../devkit/documents/NxJsonConfiguration#targetdefaults): TargetDefaults - [tasksRunnerOptions](../../devkit/documents/NxJsonConfiguration#tasksrunneroptions): Object +- [tui](../../devkit/documents/NxJsonConfiguration#tui): Object - [useDaemonProcess](../../devkit/documents/NxJsonConfiguration#usedaemonprocess): boolean - [useInferencePlugins](../../devkit/documents/NxJsonConfiguration#useinferenceplugins): boolean - [useLegacyCache](../../devkit/documents/NxJsonConfiguration#uselegacycache): boolean @@ -289,6 +290,21 @@ Available Task Runners for Nx to use --- +### tui + +• `Optional` **tui**: `Object` + +Settings for the Nx Terminal User Interface (TUI) + +#### Type declaration + +| Name | Type | Description | +| :---------- | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `autoExit?` | `number` \| `boolean` | Whether to exit the TUI automatically after all tasks finish. - If set to `true`, the TUI will exit immediately. - If set to `false` the TUI will not automatically exit. - If set to a number, an interruptible countdown popup will be shown for that many seconds before the TUI exits. | +| `enabled?` | `boolean` | Whether to enable the TUI whenever possible (based on the current environment and terminal). | + +--- + ### useDaemonProcess • `Optional` **useDaemonProcess**: `boolean` diff --git a/docs/generated/devkit/Workspace.md b/docs/generated/devkit/Workspace.md index 9dd08e1fc9..f747213d62 100644 --- a/docs/generated/devkit/Workspace.md +++ b/docs/generated/devkit/Workspace.md @@ -41,6 +41,7 @@ use ProjectsConfigurations or NxJsonConfiguration - [sync](../../devkit/documents/Workspace#sync): NxSyncConfiguration - [targetDefaults](../../devkit/documents/Workspace#targetdefaults): TargetDefaults - [tasksRunnerOptions](../../devkit/documents/Workspace#tasksrunneroptions): Object +- [tui](../../devkit/documents/Workspace#tui): Object - [useDaemonProcess](../../devkit/documents/Workspace#usedaemonprocess): boolean - [useInferencePlugins](../../devkit/documents/Workspace#useinferenceplugins): boolean - [useLegacyCache](../../devkit/documents/Workspace#uselegacycache): boolean @@ -397,6 +398,25 @@ Available Task Runners for Nx to use --- +### tui + +• `Optional` **tui**: `Object` + +Settings for the Nx Terminal User Interface (TUI) + +#### Type declaration + +| Name | Type | Description | +| :---------- | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `autoExit?` | `number` \| `boolean` | Whether to exit the TUI automatically after all tasks finish. - If set to `true`, the TUI will exit immediately. - If set to `false` the TUI will not automatically exit. - If set to a number, an interruptible countdown popup will be shown for that many seconds before the TUI exits. | +| `enabled?` | `boolean` | Whether to enable the TUI whenever possible (based on the current environment and terminal). | + +#### Inherited from + +[NxJsonConfiguration](../../devkit/documents/NxJsonConfiguration).[tui](../../devkit/documents/NxJsonConfiguration#tui) + +--- + ### useDaemonProcess • `Optional` **useDaemonProcess**: `boolean` diff --git a/docs/generated/packages/nx/documents/affected.md b/docs/generated/packages/nx/documents/affected.md index be8eca8f5f..ab55298a89 100644 --- a/docs/generated/packages/nx/documents/affected.md +++ b/docs/generated/packages/nx/documents/affected.md @@ -106,6 +106,7 @@ Print the task graph to the console: | `--skipRemoteCache`, `--disableRemoteCache` | boolean | Disables the remote cache. (Default: `false`) | | `--skipSync` | boolean | Skips running the sync generators associated with the tasks. (Default: `false`) | | `--targets`, `--target`, `--t` | string | Tasks to run for affected projects. | +| `--tuiAutoExit` | string | Whether or not to exit the TUI automatically after all tasks finish, and after how long. If set to `true`, the TUI will exit immediately. If set to `false` the TUI will not automatically exit. If set to a number, an interruptible countdown popup will be shown for that many seconds before the TUI exits. | | `--uncommitted` | boolean | Uncommitted changes. | | `--untracked` | boolean | Untracked changes. | | `--verbose` | boolean | Prints additional information about the commands (e.g., stack traces). | diff --git a/docs/generated/packages/nx/documents/run-many.md b/docs/generated/packages/nx/documents/run-many.md index 2419807344..824005811d 100644 --- a/docs/generated/packages/nx/documents/run-many.md +++ b/docs/generated/packages/nx/documents/run-many.md @@ -110,5 +110,6 @@ Print the task graph to the console: | `--skipRemoteCache`, `--disableRemoteCache` | boolean | Disables the remote cache. (Default: `false`) | | `--skipSync` | boolean | Skips running the sync generators associated with the tasks. (Default: `false`) | | `--targets`, `--target`, `--t` | string | Tasks to run for affected projects. | +| `--tuiAutoExit` | string | Whether or not to exit the TUI automatically after all tasks finish, and after how long. If set to `true`, the TUI will exit immediately. If set to `false` the TUI will not automatically exit. If set to a number, an interruptible countdown popup will be shown for that many seconds before the TUI exits. | | `--verbose` | boolean | Prints additional information about the commands (e.g., stack traces). | | `--version` | boolean | Show version number. | diff --git a/docs/generated/packages/nx/documents/run.md b/docs/generated/packages/nx/documents/run.md index a1fbf9dde9..d58b87bb84 100644 --- a/docs/generated/packages/nx/documents/run.md +++ b/docs/generated/packages/nx/documents/run.md @@ -83,5 +83,6 @@ Run's a target named build:test for the myapp project. Note the quotes around th | `--skipNxCache`, `--disableNxCache` | boolean | Rerun the tasks even when the results are available in the cache. (Default: `false`) | | `--skipRemoteCache`, `--disableRemoteCache` | boolean | Disables the remote cache. (Default: `false`) | | `--skipSync` | boolean | Skips running the sync generators associated with the tasks. (Default: `false`) | +| `--tuiAutoExit` | string | Whether or not to exit the TUI automatically after all tasks finish, and after how long. If set to `true`, the TUI will exit immediately. If set to `false` the TUI will not automatically exit. If set to a number, an interruptible countdown popup will be shown for that many seconds before the TUI exits. | | `--verbose` | boolean | Prints additional information about the commands (e.g., stack traces). | | `--version` | boolean | Show version number. | diff --git a/packages/nx/Cargo.toml b/packages/nx/Cargo.toml index 30737c4ab6..7d305a616c 100644 --- a/packages/nx/Cargo.toml +++ b/packages/nx/Cargo.toml @@ -13,50 +13,62 @@ strip = "none" [dependencies] anyhow = "1.0.71" +arboard = "3.4.1" +better-panic = "0.3.0" colored = "2" +color-eyre = "0.6.3" crossbeam-channel = '0.5' dashmap = { version = "5.5.3", features = ["rayon"] } dunce = "1" flate2 = "1.1.1" fs_extra = "1.3.0" +futures = "0.3.28" globset = "0.4.10" hashbrown = { version = "0.14.5", features = ["rayon", "rkyv"] } ignore = '0.4' itertools = "0.10.5" once_cell = "1.18.0" parking_lot = { version = "0.12.1", features = ["send_guard"] } -napi = { version = '2.16.0', default-features = false, features = [ - 'anyhow', - 'napi4', - 'tokio_rt', +napi = { version = "2.16.0", default-features = false, features = [ + "anyhow", + "napi4", + "tokio_rt", + "async", + "chrono_date", ] } napi-derive = '2.16.0' nom = '7.1.3' regex = "1.9.1" +ratatui = { version = "0.29", features = ["scrolling-regions"] } rayon = "1.7.0" rkyv = { version = "0.7", features = ["validation"] } -tar = "0.4.44" -thiserror = "1.0.40" -tracing = "0.1.37" -tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } -walkdir = '2.3.3' -xxhash-rust = { version = '0.8.5', features = ['xxh3', 'xxh64'] } swc_common = "0.31.16" swc_ecma_parser = { version = "0.137.1", features = ["typescript"] } swc_ecma_visit = "0.93.0" swc_ecma_ast = "0.107.0" sysinfo = "0.33.1" rand = "0.9.0" +tar = "0.4.44" +thiserror = "1.0.40" +tracing = "0.1.37" +tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } +tokio = { version = "1.32.0", features = ["full"] } +tokio-util = "0.7.9" +tracing-appender = "0.2" +tui-term = "0.2.0" +walkdir = '2.3.3' +xxhash-rust = { version = '0.8.5', features = ['xxh3', 'xxh64'] } +vt100-ctt = { git = "https://github.com/JamesHenry/vt100-rust", rev = "1de895505fe9f697aadac585e4075b8fb45c880d" } + [target.'cfg(windows)'.dependencies] winapi = { version = "0.3", features = ["fileapi"] } [target.'cfg(all(not(windows), not(target_family = "wasm")))'.dependencies] mio = "0.8" - [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +crossterm = { version = "0.27.0", features = ["event-stream"] } portable-pty = { git = "https://github.com/cammisuli/wezterm", rev = "b538ee29e1e89eeb4832fb35ae095564dce34c29" } -crossterm = "0.27.0" ignore-files = "2.1.0" fs4 = "0.12.0" reqwest = { version = "0.12.15", default-features = false, features = ["rustls-tls"] } @@ -78,5 +90,3 @@ assert_fs = "1.0.10" # This is only used for unit tests swc_ecma_dep_graph = "0.109.1" tempfile = "3.13.0" -# We only explicitly use tokio for async tests -tokio = "1.38.0" diff --git a/packages/nx/schemas/nx-schema.json b/packages/nx/schemas/nx-schema.json index be8ce4ab7a..5d2a1ad918 100644 --- a/packages/nx/schemas/nx-schema.json +++ b/packages/nx/schemas/nx-schema.json @@ -77,6 +77,23 @@ "$ref": "#/definitions/plugins" } }, + "tui": { + "type": "object", + "description": "Settings for the Nx Terminal User Interface (TUI)", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether to enable the Terminal UI whenever possible (based on the current environment and terminal).", + "default": true + }, + "autoExit": { + "oneOf": [{ "type": "boolean" }, { "type": "number" }], + "description": "Whether to exit the TUI automatically after all tasks finish. If set to `true`, the TUI will exit immediately. If set to `false` the TUI will not automatically exit. If set to a number, an interruptible countdown popup will be shown for that many seconds before the TUI exits.", + "default": 3 + } + }, + "additionalProperties": false + }, "defaultProject": { "type": "string", "description": "Default project. When project isn't provided, the default project will be used." diff --git a/packages/nx/src/adapter/compat.ts b/packages/nx/src/adapter/compat.ts index 99613a77cf..b6e16e46b7 100644 --- a/packages/nx/src/adapter/compat.ts +++ b/packages/nx/src/adapter/compat.ts @@ -83,6 +83,7 @@ export const allowedWorkspaceExtensions = [ 'sync', 'useLegacyCache', 'maxCacheSize', + 'tui', ] as const; if (!patched) { diff --git a/packages/nx/src/command-line/affected/command-object.ts b/packages/nx/src/command-line/affected/command-object.ts index 389186cab7..afbfe01fe5 100644 --- a/packages/nx/src/command-line/affected/command-object.ts +++ b/packages/nx/src/command-line/affected/command-object.ts @@ -1,4 +1,5 @@ import { CommandModule } from 'yargs'; +import { handleErrors } from '../../utils/handle-errors'; import { linkToNxDevAndExamples } from '../yargs-utils/documentation'; import { withAffectedOptions, @@ -8,8 +9,8 @@ import { withOverrides, withRunOptions, withTargetAndConfigurationOption, + withTuiOptions, } from '../yargs-utils/shared-options'; -import { handleErrors } from '../../utils/handle-errors'; export const yargsAffectedCommand: CommandModule = { command: 'affected', @@ -17,9 +18,11 @@ export const yargsAffectedCommand: CommandModule = { builder: (yargs) => linkToNxDevAndExamples( withAffectedOptions( - withRunOptions( - withOutputStyleOption( - withTargetAndConfigurationOption(withBatch(yargs)) + withTuiOptions( + withRunOptions( + withOutputStyleOption( + withTargetAndConfigurationOption(withBatch(yargs)) + ) ) ) ) @@ -56,7 +59,9 @@ export const yargsAffectedTestCommand: CommandModule = { builder: (yargs) => linkToNxDevAndExamples( withAffectedOptions( - withRunOptions(withOutputStyleOption(withConfiguration(yargs))) + withTuiOptions( + withRunOptions(withOutputStyleOption(withConfiguration(yargs))) + ) ), 'affected' ), @@ -80,7 +85,9 @@ export const yargsAffectedBuildCommand: CommandModule = { builder: (yargs) => linkToNxDevAndExamples( withAffectedOptions( - withRunOptions(withOutputStyleOption(withConfiguration(yargs))) + withTuiOptions( + withRunOptions(withOutputStyleOption(withConfiguration(yargs))) + ) ), 'affected' ), @@ -104,7 +111,9 @@ export const yargsAffectedLintCommand: CommandModule = { builder: (yargs) => linkToNxDevAndExamples( withAffectedOptions( - withRunOptions(withOutputStyleOption(withConfiguration(yargs))) + withTuiOptions( + withRunOptions(withOutputStyleOption(withConfiguration(yargs))) + ) ), 'affected' ), @@ -128,7 +137,9 @@ export const yargsAffectedE2ECommand: CommandModule = { builder: (yargs) => linkToNxDevAndExamples( withAffectedOptions( - withRunOptions(withOutputStyleOption(withConfiguration(yargs))) + withTuiOptions( + withRunOptions(withOutputStyleOption(withConfiguration(yargs))) + ) ), 'affected' ), diff --git a/packages/nx/src/command-line/exec/command-object.ts b/packages/nx/src/command-line/exec/command-object.ts index 99ab72e7d5..4e7652af8d 100644 --- a/packages/nx/src/command-line/exec/command-object.ts +++ b/packages/nx/src/command-line/exec/command-object.ts @@ -2,12 +2,13 @@ import { CommandModule } from 'yargs'; import { withOverrides, withRunManyOptions, + withTuiOptions, } from '../yargs-utils/shared-options'; export const yargsExecCommand: CommandModule = { command: 'exec', describe: 'Executes any command as if it was a target on the project.', - builder: (yargs) => withRunManyOptions(yargs), + builder: (yargs) => withTuiOptions(withRunManyOptions(yargs)), handler: async (args) => { try { await (await import('./exec')).nxExecCommand(withOverrides(args) as any); diff --git a/packages/nx/src/command-line/release/publish.ts b/packages/nx/src/command-line/release/publish.ts index 1a27e4f7a7..032db49c9c 100644 --- a/packages/nx/src/command-line/release/publish.ts +++ b/packages/nx/src/command-line/release/publish.ts @@ -268,7 +268,9 @@ async function runPublishOnProjects( /** * Run the relevant nx-release-publish executor on each of the selected projects. + * NOTE: Force TUI to be disabled for now. */ + process.env.NX_TUI = 'false'; const commandResults = await runCommandForTasks( projectsWithTarget, projectGraph, diff --git a/packages/nx/src/command-line/run-many/command-object.ts b/packages/nx/src/command-line/run-many/command-object.ts index 3916349673..e8d85efcf6 100644 --- a/packages/nx/src/command-line/run-many/command-object.ts +++ b/packages/nx/src/command-line/run-many/command-object.ts @@ -1,22 +1,25 @@ import { CommandModule } from 'yargs'; +import { handleErrors } from '../../utils/handle-errors'; import { linkToNxDevAndExamples } from '../yargs-utils/documentation'; import { - withRunManyOptions, - withOutputStyleOption, - withTargetAndConfigurationOption, - withOverrides, withBatch, + withOutputStyleOption, + withOverrides, + withRunManyOptions, + withTargetAndConfigurationOption, + withTuiOptions, } from '../yargs-utils/shared-options'; -import { handleErrors } from '../../utils/handle-errors'; export const yargsRunManyCommand: CommandModule = { command: 'run-many', describe: 'Run target for multiple listed projects.', builder: (yargs) => linkToNxDevAndExamples( - withRunManyOptions( - withOutputStyleOption( - withTargetAndConfigurationOption(withBatch(yargs)) + withTuiOptions( + withRunManyOptions( + withOutputStyleOption( + withTargetAndConfigurationOption(withBatch(yargs)) + ) ) ), 'run-many' diff --git a/packages/nx/src/command-line/run/command-object.ts b/packages/nx/src/command-line/run/command-object.ts index 1dd1e70748..7d37103930 100644 --- a/packages/nx/src/command-line/run/command-object.ts +++ b/packages/nx/src/command-line/run/command-object.ts @@ -1,10 +1,11 @@ import { CommandModule, showHelp } from 'yargs'; +import { handleErrors } from '../../utils/handle-errors'; import { withBatch, withOverrides, withRunOneOptions, + withTuiOptions, } from '../yargs-utils/shared-options'; -import { handleErrors } from '../../utils/handle-errors'; export const yargsRunCommand: CommandModule = { command: 'run [project][:target][:configuration] [_..]', @@ -15,7 +16,7 @@ export const yargsRunCommand: CommandModule = { (e.g., nx serve myapp --configuration=production) You can skip the use of Nx cache by using the --skip-nx-cache option.`, - builder: (yargs) => withRunOneOptions(withBatch(yargs)), + builder: (yargs) => withTuiOptions(withRunOneOptions(withBatch(yargs))), handler: async (args) => { const exitCode = await handleErrors( (args.verbose as boolean) ?? process.env.NX_VERBOSE_LOGGING === 'true', diff --git a/packages/nx/src/command-line/run/run.ts b/packages/nx/src/command-line/run/run.ts index 9419159cff..b0800c2981 100644 --- a/packages/nx/src/command-line/run/run.ts +++ b/packages/nx/src/command-line/run/run.ts @@ -20,7 +20,7 @@ import { } from '../../utils/async-iterator'; import { getExecutorInformation } from './executor-utils'; import { - getPseudoTerminal, + createPseudoTerminal, PseudoTerminal, } from '../../tasks-runner/pseudo-terminal'; import { exec } from 'child_process'; @@ -124,7 +124,7 @@ async function printTargetRunHelpInternal( ...localEnv, }; if (PseudoTerminal.isSupported()) { - const terminal = getPseudoTerminal(); + const terminal = createPseudoTerminal(); await new Promise(() => { const cp = terminal.runCommand(helpCommand, { jsEnv: env }); cp.onExit((code) => { diff --git a/packages/nx/src/command-line/yargs-utils/shared-options.ts b/packages/nx/src/command-line/yargs-utils/shared-options.ts index ded2c742e4..7df6e474a9 100644 --- a/packages/nx/src/command-line/yargs-utils/shared-options.ts +++ b/packages/nx/src/command-line/yargs-utils/shared-options.ts @@ -41,6 +41,31 @@ export interface RunOptions { skipSync: boolean; } +export interface TuiOptions { + tuiAutoExit: boolean | number; +} + +export function withTuiOptions(yargs: Argv): Argv { + return yargs.options('tuiAutoExit', { + describe: + 'Whether or not to exit the TUI automatically after all tasks finish, and after how long. If set to `true`, the TUI will exit immediately. If set to `false` the TUI will not automatically exit. If set to a number, an interruptible countdown popup will be shown for that many seconds before the TUI exits.', + type: 'string', + coerce: (value) => { + if (value === 'true') { + return true; + } + if (value === 'false') { + return false; + } + const num = Number(value); + if (!Number.isNaN(num)) { + return num; + } + throw new Error(`Invalid value for --tui-auto-exit: ${value}`); + }, + }) as Argv; +} + export function withRunOptions(yargs: Argv): Argv { return withVerbose(withExcludeOption(yargs)) .option('parallel', { @@ -112,7 +137,6 @@ export function withRunOptions(yargs: Argv): Argv { type: 'boolean', hidden: true, }) - .options('dte', { type: 'boolean', hidden: true, diff --git a/packages/nx/src/config/nx-json.ts b/packages/nx/src/config/nx-json.ts index adad6c2e35..3c3c687d65 100644 --- a/packages/nx/src/config/nx-json.ts +++ b/packages/nx/src/config/nx-json.ts @@ -654,6 +654,24 @@ export interface NxJsonConfiguration { * Sets the maximum size of the local cache. Accepts a number followed by a unit (e.g. 100MB). Accepted units are B, KB, MB, and GB. */ maxCacheSize?: string; + + /** + * Settings for the Nx Terminal User Interface (TUI) + */ + tui?: { + /** + * Whether to enable the TUI whenever possible (based on the current environment and terminal). + */ + enabled?: boolean; + /** + * Whether to exit the TUI automatically after all tasks finish. + * + * - If set to `true`, the TUI will exit immediately. + * - If set to `false` the TUI will not automatically exit. + * - If set to a number, an interruptible countdown popup will be shown for that many seconds before the TUI exits. + */ + autoExit?: boolean | number; + }; } export type PluginConfiguration = string | ExpandedPluginConfiguration; diff --git a/packages/nx/src/executors/run-commands/run-commands.impl.ts b/packages/nx/src/executors/run-commands/run-commands.impl.ts index 98b22fa040..f5ed9b87d3 100644 --- a/packages/nx/src/executors/run-commands/run-commands.impl.ts +++ b/packages/nx/src/executors/run-commands/run-commands.impl.ts @@ -1,12 +1,18 @@ import { Serializable } from 'child_process'; import * as yargsParser from 'yargs-parser'; import { ExecutorContext } from '../../config/misc-interfaces'; +import { isTuiEnabled } from '../../tasks-runner/is-tui-enabled'; import { - getPseudoTerminal, + createPseudoTerminal, PseudoTerminal, + PseudoTtyProcess, } from '../../tasks-runner/pseudo-terminal'; import { signalToCode } from '../../utils/exit-codes'; -import { ParallelRunningTasks, SeriallyRunningTasks } from './running-tasks'; +import { + ParallelRunningTasks, + runSingleCommandWithPseudoTerminal, + SeriallyRunningTasks, +} from './running-tasks'; export const LARGE_BUFFER = 1024 * 1000000; export type Json = { @@ -65,10 +71,7 @@ const propKeys = [ ]; export interface NormalizedRunCommandsOptions extends RunCommandsOptions { - commands: { - command: string; - forwardAllArgs?: boolean; - }[]; + commands: Array; unknownOptions?: { [k: string]: any; }; @@ -120,17 +123,26 @@ export async function runCommands( ); } - const pseudoTerminal = - !options.parallel && PseudoTerminal.isSupported() - ? getPseudoTerminal() - : null; + const isSingleCommand = normalized.commands.length === 1; + + const usePseudoTerminal = + (isSingleCommand || !options.parallel) && PseudoTerminal.isSupported(); + + const isSingleCommandAndCanUsePseudoTerminal = + isSingleCommand && + usePseudoTerminal && + process.env.NX_NATIVE_COMMAND_RUNNER !== 'false' && + !normalized.commands[0].prefix && + normalized.usePty; + + const tuiEnabled = isTuiEnabled(); try { - const runningTask = options.parallel - ? new ParallelRunningTasks(normalized, context) - : new SeriallyRunningTasks(normalized, context, pseudoTerminal); - - registerProcessListener(runningTask, pseudoTerminal); + const runningTask = isSingleCommandAndCanUsePseudoTerminal + ? await runSingleCommandWithPseudoTerminal(normalized, context) + : options.parallel + ? new ParallelRunningTasks(normalized, context, tuiEnabled) + : new SeriallyRunningTasks(normalized, context, tuiEnabled); return runningTask; } catch (e) { if (process.env.NX_VERBOSE_LOGGING === 'true') { @@ -322,48 +334,6 @@ function filterPropKeysFromUnParsedOptions( return parsedOptions; } -let registered = false; - -function registerProcessListener( - runningTask: ParallelRunningTasks | SeriallyRunningTasks, - pseudoTerminal?: PseudoTerminal -) { - if (registered) { - return; - } - - registered = true; - // When the nx process gets a message, it will be sent into the task's process - process.on('message', (message: Serializable) => { - // this.publisher.publish(message.toString()); - if (pseudoTerminal) { - pseudoTerminal.sendMessageToChildren(message); - } - - runningTask.send(message); - }); - - // Terminate any task processes on exit - process.on('exit', () => { - runningTask.kill(); - }); - process.on('SIGINT', () => { - runningTask.kill('SIGTERM'); - // we exit here because we don't need to write anything to cache. - process.exit(signalToCode('SIGINT')); - }); - process.on('SIGTERM', () => { - runningTask.kill('SIGTERM'); - // no exit here because we expect child processes to terminate which - // will store results to the cache and will terminate this process - }); - process.on('SIGHUP', () => { - runningTask.kill('SIGTERM'); - // no exit here because we expect child processes to terminate which - // will store results to the cache and will terminate this process - }); -} - function wrapArgIntoQuotesIfNeeded(arg: string): string { if (arg.includes('=')) { const [key, value] = arg.split('='); diff --git a/packages/nx/src/executors/run-commands/running-tasks.ts b/packages/nx/src/executors/run-commands/running-tasks.ts index 216d943867..303f89b567 100644 --- a/packages/nx/src/executors/run-commands/running-tasks.ts +++ b/packages/nx/src/executors/run-commands/running-tasks.ts @@ -1,24 +1,25 @@ +import * as chalk from 'chalk'; import { ChildProcess, exec, Serializable } from 'child_process'; -import { RunningTask } from '../../tasks-runner/running-tasks/running-task'; +import { env as appendLocalEnv } from 'npm-run-path'; +import { isAbsolute, join } from 'path'; +import * as treeKill from 'tree-kill'; import { ExecutorContext } from '../../config/misc-interfaces'; import { - LARGE_BUFFER, - NormalizedRunCommandsOptions, - RunCommandsCommandOptions, - RunCommandsOptions, -} from './run-commands.impl'; -import { + createPseudoTerminal, PseudoTerminal, PseudoTtyProcess, } from '../../tasks-runner/pseudo-terminal'; -import { isAbsolute, join } from 'path'; -import * as chalk from 'chalk'; -import { env as appendLocalEnv } from 'npm-run-path'; +import { RunningTask } from '../../tasks-runner/running-tasks/running-task'; import { loadAndExpandDotEnvFile, unloadDotEnvFile, } from '../../tasks-runner/task-env'; -import * as treeKill from 'tree-kill'; +import { signalToCode } from '../../utils/exit-codes'; +import { + LARGE_BUFFER, + NormalizedRunCommandsOptions, + RunCommandsCommandOptions, +} from './run-commands.impl'; export class ParallelRunningTasks implements RunningTask { private readonly childProcesses: RunningNodeProcess[]; @@ -28,7 +29,11 @@ export class ParallelRunningTasks implements RunningTask { private exitCallbacks: Array<(code: number, terminalOutput: string) => void> = []; - constructor(options: NormalizedRunCommandsOptions, context: ExecutorContext) { + constructor( + options: NormalizedRunCommandsOptions, + context: ExecutorContext, + private readonly tuiEnabled: boolean + ) { this.childProcesses = options.commands.map( (commandConfig) => new RunningNodeProcess( @@ -160,7 +165,7 @@ export class SeriallyRunningTasks implements RunningTask { constructor( options: NormalizedRunCommandsOptions, context: ExecutorContext, - private pseudoTerminal?: PseudoTerminal + private readonly tuiEnabled: boolean ) { this.run(options, context) .catch((e) => { @@ -204,11 +209,9 @@ export class SeriallyRunningTasks implements RunningTask { for (const c of options.commands) { const childProcess = await this.createProcess( c, - [], options.color, calculateCwd(options.cwd, context), options.processEnv ?? options.env ?? {}, - false, options.usePty, options.streamOutput, options.tty, @@ -235,11 +238,9 @@ export class SeriallyRunningTasks implements RunningTask { private async createProcess( commandConfig: RunCommandsCommandOptions, - readyWhenStatus: { stringToMatch: string; found: boolean }[] = [], color: boolean, cwd: string, env: Record, - isParallel: boolean, usePty: boolean = true, streamOutput: boolean = true, tty: boolean, @@ -248,15 +249,16 @@ export class SeriallyRunningTasks implements RunningTask { // The rust runCommand is always a tty, so it will not look nice in parallel and if we need prefixes // currently does not work properly in windows if ( - this.pseudoTerminal && process.env.NX_NATIVE_COMMAND_RUNNER !== 'false' && !commandConfig.prefix && - readyWhenStatus.length === 0 && - !isParallel && - usePty + usePty && + PseudoTerminal.isSupported() ) { + const pseudoTerminal = createPseudoTerminal(); + registerProcessListener(this, pseudoTerminal); + return createProcessWithPseudoTty( - this.pseudoTerminal, + pseudoTerminal, commandConfig, color, cwd, @@ -272,7 +274,7 @@ export class SeriallyRunningTasks implements RunningTask { color, cwd, env, - readyWhenStatus, + [], streamOutput, envFile ); @@ -393,14 +395,28 @@ class RunningNodeProcess implements RunningTask { } } +export async function runSingleCommandWithPseudoTerminal( + normalized: NormalizedRunCommandsOptions, + context: ExecutorContext +): Promise { + const pseudoTerminal = createPseudoTerminal(); + const pseudoTtyProcess = await createProcessWithPseudoTty( + pseudoTerminal, + normalized.commands[0], + normalized.color, + calculateCwd(normalized.cwd, context), + normalized.env, + normalized.streamOutput, + pseudoTerminal ? normalized.isTTY : false, + normalized.envFile + ); + registerProcessListener(pseudoTtyProcess, pseudoTerminal); + return pseudoTtyProcess; +} + async function createProcessWithPseudoTty( pseudoTerminal: PseudoTerminal, - commandConfig: { - command: string; - color?: string; - bgColor?: string; - prefix?: string; - }, + commandConfig: RunCommandsCommandOptions, color: boolean, cwd: string, env: Record, @@ -408,23 +424,12 @@ async function createProcessWithPseudoTty( tty: boolean, envFile?: string ) { - let terminalOutput = chalk.dim('> ') + commandConfig.command + '\r\n\r\n'; - if (streamOutput) { - process.stdout.write(terminalOutput); - } - env = processEnv(color, cwd, env, envFile); - const childProcess = pseudoTerminal.runCommand(commandConfig.command, { + return pseudoTerminal.runCommand(commandConfig.command, { cwd, - jsEnv: env, + jsEnv: processEnv(color, cwd, env, envFile), quiet: !streamOutput, tty, }); - - childProcess.onOutput((output) => { - terminalOutput += output; - }); - - return childProcess; } function addColorAndPrefix(out: string, config: RunCommandsCommandOptions) { @@ -517,3 +522,47 @@ function loadEnvVarsFile(path: string, env: Record = {}) { throw result.error; } } + +let registered = false; + +function registerProcessListener( + runningTask: PseudoTtyProcess | ParallelRunningTasks | SeriallyRunningTasks, + pseudoTerminal?: PseudoTerminal +) { + if (registered) { + return; + } + + registered = true; + // When the nx process gets a message, it will be sent into the task's process + process.on('message', (message: Serializable) => { + // this.publisher.publish(message.toString()); + if (pseudoTerminal) { + pseudoTerminal.sendMessageToChildren(message); + } + + if ('send' in runningTask) { + runningTask.send(message); + } + }); + + // Terminate any task processes on exit + process.on('exit', () => { + runningTask.kill(); + }); + process.on('SIGINT', () => { + runningTask.kill('SIGTERM'); + // we exit here because we don't need to write anything to cache. + process.exit(signalToCode('SIGINT')); + }); + process.on('SIGTERM', () => { + runningTask.kill('SIGTERM'); + // no exit here because we expect child processes to terminate which + // will store results to the cache and will terminate this process + }); + process.on('SIGHUP', () => { + runningTask.kill('SIGTERM'); + // no exit here because we expect child processes to terminate which + // will store results to the cache and will terminate this process + }); +} diff --git a/packages/nx/src/executors/run-script/run-script.impl.ts b/packages/nx/src/executors/run-script/run-script.impl.ts index 46aa851906..e2bd6f6f30 100644 --- a/packages/nx/src/executors/run-script/run-script.impl.ts +++ b/packages/nx/src/executors/run-script/run-script.impl.ts @@ -1,11 +1,11 @@ +import { execSync } from 'child_process'; import * as path from 'path'; import type { ExecutorContext } from '../../config/misc-interfaces'; -import { getPackageManagerCommand } from '../../utils/package-manager'; -import { execSync } from 'child_process'; import { - getPseudoTerminal, + createPseudoTerminal, PseudoTerminal, } from '../../tasks-runner/pseudo-terminal'; +import { getPackageManagerCommand } from '../../utils/package-manager'; export interface RunScriptOptions { script: string; @@ -63,7 +63,7 @@ async function ptyProcess( cwd: string, env: Record ) { - const terminal = getPseudoTerminal(); + const terminal = createPseudoTerminal(); return new Promise((res, rej) => { const cp = terminal.runCommand(command, { cwd, jsEnv: env }); diff --git a/packages/nx/src/native/index.d.ts b/packages/nx/src/native/index.d.ts index 63a9a9ee8c..69edd46688 100644 --- a/packages/nx/src/native/index.d.ts +++ b/packages/nx/src/native/index.d.ts @@ -7,7 +7,21 @@ export declare class ExternalObject { [K: symbol]: T } } +export declare class AppLifeCycle { + constructor(tasks: Array, pinnedTasks: Array, tuiCliArgs: TuiCliArgs, tuiConfig: TuiConfig, titleText: string) + startCommand(threadCount?: number | undefined | null): void + scheduleTask(task: Task): void + startTasks(tasks: Array, metadata: object): void + printTaskTerminalOutput(task: Task, status: string, output: string): void + endTasks(taskResults: Array, metadata: object): void + endCommand(): void + __init(doneCallback: () => any): void + registerRunningTask(taskId: string, parserAndWriter: ExternalObject<[ParserArc, WriterArc]>): void + __setCloudMessage(message: string): Promise +} + export declare class ChildProcess { + getParserAndWriter(): ExternalObject<[ParserArc, WriterArc]> kill(): void onExit(callback: (message: string) => void): void onOutput(callback: (message: string) => void): void @@ -251,6 +265,8 @@ export interface ProjectGraph { export declare export declare function remove(src: string): void +export declare export declare function restoreTerminal(): void + export interface RuntimeInput { runtime: string } @@ -269,6 +285,9 @@ export interface Task { target: TaskTarget outputs: Array projectRoot?: string + startTime?: number + endTime?: number + continuous?: boolean } export interface TaskGraph { @@ -277,6 +296,13 @@ export interface TaskGraph { dependencies: Record> } +export interface TaskResult { + task: Task + status: string + code: number + terminalOutput?: string +} + export interface TaskRun { hash: string status: string @@ -299,6 +325,15 @@ export declare export declare function testOnlyTransferFileMap(projectFiles: Rec */ export declare export declare function transferProjectGraph(projectGraph: ProjectGraph): ExternalObject +export interface TuiCliArgs { + targets?: string[] | undefined + tuiAutoExit?: boolean | number | undefined +} + +export interface TuiConfig { + autoExit?: boolean | number | undefined +} + export interface UpdatedWorkspaceFiles { fileMap: FileMap externalReferences: NxWorkspaceFilesExternals diff --git a/packages/nx/src/native/logger/mod.rs b/packages/nx/src/native/logger/mod.rs index 6a0a4df4ec..99a46e79f3 100644 --- a/packages/nx/src/native/logger/mod.rs +++ b/packages/nx/src/native/logger/mod.rs @@ -1,9 +1,11 @@ use colored::Colorize; use std::io::IsTerminal; use tracing::{Event, Level, Subscriber}; +use tracing_appender::rolling::{RollingFileAppender, Rotation}; use tracing_subscriber::fmt::{format, FmtContext, FormatEvent, FormatFields, FormattedFields}; +use tracing_subscriber::prelude::*; use tracing_subscriber::registry::LookupSpan; -use tracing_subscriber::EnvFilter; +use tracing_subscriber::{EnvFilter, Layer}; struct NxLogFormatter; impl FormatEvent for NxLogFormatter @@ -88,13 +90,30 @@ where /// - `NX_NATIVE_LOGGING=nx=trace` - enable all logs for the `nx` (this) crate /// - `NX_NATIVE_LOGGING=nx::native::tasks::hashers::hash_project_files=trace` - enable all logs for the `hash_project_files` module /// - `NX_NATIVE_LOGGING=[{project_name=project}]` - enable logs that contain the project in its span +/// NX_NATIVE_FILE_LOGGING acts the same but logs to .nx/workspace-data/nx.log instead of stdout pub(crate) fn enable_logger() { - let env_filter = - EnvFilter::try_from_env("NX_NATIVE_LOGGING").unwrap_or_else(|_| EnvFilter::new("ERROR")); - _ = tracing_subscriber::fmt() - .with_env_filter(env_filter) + let stdout_layer = tracing_subscriber::fmt::layer() .with_ansi(std::io::stdout().is_terminal()) + .with_writer(std::io::stdout) .event_format(NxLogFormatter) + .with_filter( + EnvFilter::try_from_env("NX_NATIVE_LOGGING") + .unwrap_or_else(|_| EnvFilter::new("ERROR")), + ); + + let file_appender: RollingFileAppender = + RollingFileAppender::new(Rotation::NEVER, ".nx/workspace-data", "nx.log"); + let file_layer = tracing_subscriber::fmt::layer() + .with_writer(file_appender) + .event_format(NxLogFormatter) + .with_ansi(false) + .with_filter( + EnvFilter::try_from_env("NX_NATIVE_FILE_LOGGING") + .unwrap_or_else(|_| EnvFilter::new("ERROR")), + ); + tracing_subscriber::registry() + .with(stdout_layer) + .with(file_layer) .try_init() .ok(); } diff --git a/packages/nx/src/native/mod.rs b/packages/nx/src/native/mod.rs index ddcbd9ec78..606060aaf7 100644 --- a/packages/nx/src/native/mod.rs +++ b/packages/nx/src/native/mod.rs @@ -17,4 +17,6 @@ pub mod db; #[cfg(not(target_arch = "wasm32"))] pub mod pseudo_terminal; #[cfg(not(target_arch = "wasm32"))] +pub mod tui; +#[cfg(not(target_arch = "wasm32"))] pub mod watch; diff --git a/packages/nx/src/native/native-bindings.js b/packages/nx/src/native/native-bindings.js index 76a912791a..2d217eb589 100644 --- a/packages/nx/src/native/native-bindings.js +++ b/packages/nx/src/native/native-bindings.js @@ -361,6 +361,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } +module.exports.AppLifeCycle = nativeBinding.AppLifeCycle module.exports.ChildProcess = nativeBinding.ChildProcess module.exports.FileLock = nativeBinding.FileLock module.exports.HashPlanner = nativeBinding.HashPlanner @@ -388,6 +389,7 @@ module.exports.hashArray = nativeBinding.hashArray module.exports.hashFile = nativeBinding.hashFile module.exports.IS_WASM = nativeBinding.IS_WASM module.exports.remove = nativeBinding.remove +module.exports.restoreTerminal = nativeBinding.restoreTerminal module.exports.testOnlyTransferFileMap = nativeBinding.testOnlyTransferFileMap module.exports.transferProjectGraph = nativeBinding.transferProjectGraph module.exports.validateOutputs = nativeBinding.validateOutputs diff --git a/packages/nx/src/native/pseudo_terminal/child_process.rs b/packages/nx/src/native/pseudo_terminal/child_process.rs index 3b37f2c3fa..06a2c186c5 100644 --- a/packages/nx/src/native/pseudo_terminal/child_process.rs +++ b/packages/nx/src/native/pseudo_terminal/child_process.rs @@ -1,13 +1,18 @@ +use crate::native::pseudo_terminal::pseudo_terminal::{ParserArc, WriterArc}; use crossbeam_channel::Sender; use crossbeam_channel::{bounded, Receiver}; +use napi::bindgen_prelude::External; use napi::{ - threadsafe_function::{ - ErrorStrategy::Fatal, ThreadsafeFunction, ThreadsafeFunctionCallMode::NonBlocking, - }, - Env, JsFunction, + threadsafe_function::{ + ErrorStrategy::Fatal, ThreadsafeFunction, ThreadsafeFunctionCallMode::NonBlocking, + }, + Env, JsFunction, }; use portable_pty::ChildKiller; +use std::io::Write; +use std::sync::{Arc, Mutex, RwLock}; use tracing::warn; +use vt100_ctt::Parser; pub enum ChildProcessMessage { Kill, @@ -15,19 +20,25 @@ pub enum ChildProcessMessage { #[napi] pub struct ChildProcess { + parser: Arc>, process_killer: Box, message_receiver: Receiver, pub(crate) wait_receiver: Receiver, thread_handles: Vec>, + writer_arc: Arc>>, } #[napi] impl ChildProcess { pub fn new( + parser: Arc>, + writer_arc: Arc>>, process_killer: Box, message_receiver: Receiver, exit_receiver: Receiver, ) -> Self { Self { + parser, + writer_arc, process_killer, message_receiver, wait_receiver: exit_receiver, @@ -35,6 +46,11 @@ impl ChildProcess { } } + #[napi] + pub fn get_parser_and_writer(&mut self) -> External<(ParserArc, WriterArc)> { + External::new((self.parser.clone(), self.writer_arc.clone())) + } + #[napi] pub fn kill(&mut self) -> anyhow::Result<()> { self.process_killer.kill().map_err(anyhow::Error::from) diff --git a/packages/nx/src/native/pseudo_terminal/command/unix.rs b/packages/nx/src/native/pseudo_terminal/command/unix.rs index 3ee3eee56b..47e99d5292 100644 --- a/packages/nx/src/native/pseudo_terminal/command/unix.rs +++ b/packages/nx/src/native/pseudo_terminal/command/unix.rs @@ -1,11 +1,12 @@ +use mio::{unix::SourceFd, Events}; use std::{ io::{Read, Stdin, Write}, os::fd::AsRawFd, }; - -use mio::{unix::SourceFd, Events}; use tracing::trace; +use super::pseudo_terminal::WriterArc; + pub fn handle_path_space(path: String) -> String { if path.contains(' ') { format!("'{}'", path) @@ -14,7 +15,7 @@ pub fn handle_path_space(path: String) -> String { } } -pub fn write_to_pty(stdin: &mut Stdin, writer: &mut impl Write) -> anyhow::Result<()> { +pub fn write_to_pty(stdin: &mut Stdin, writer: WriterArc) -> anyhow::Result<()> { let mut buffer = [0; 1024]; let mut poll = mio::Poll::new()?; @@ -45,6 +46,8 @@ pub fn write_to_pty(stdin: &mut Stdin, writer: &mut impl Write) -> anyhow::Resul mio::Token(0) => { // Read data from stdin loop { + let mut writer = writer.lock().expect("Failed to lock writer"); + match stdin.read(&mut buffer) { Ok(n) => { writer.write_all(&buffer[..n])?; diff --git a/packages/nx/src/native/pseudo_terminal/command/windows.rs b/packages/nx/src/native/pseudo_terminal/command/windows.rs index ae87f38809..bf49598cc6 100644 --- a/packages/nx/src/native/pseudo_terminal/command/windows.rs +++ b/packages/nx/src/native/pseudo_terminal/command/windows.rs @@ -1,9 +1,10 @@ use std::io::{Stdin, Write}; use std::os::windows::ffi::OsStrExt; use std::{ffi::OsString, os::windows::ffi::OsStringExt}; - use winapi::um::fileapi::GetShortPathNameW; +use super::pseudo_terminal::WriterArc; + pub fn handle_path_space(path: String) -> String { let wide: Vec = std::path::PathBuf::from(&path) .as_os_str() @@ -24,8 +25,9 @@ pub fn handle_path_space(path: String) -> String { } } -pub fn write_to_pty(stdin: &mut Stdin, writer: &mut impl Write) -> anyhow::Result<()> { - std::io::copy(stdin, writer) +pub fn write_to_pty(stdin: &mut Stdin, writer: WriterArc) -> anyhow::Result<()> { + let mut writer = writer.lock().expect("Failed to lock writer"); + std::io::copy(stdin, writer.as_mut()) .map_err(|e| anyhow::anyhow!(e)) .map(|_| ()) } diff --git a/packages/nx/src/native/pseudo_terminal/mac.rs b/packages/nx/src/native/pseudo_terminal/mac.rs index 19468598d5..22f83a233c 100644 --- a/packages/nx/src/native/pseudo_terminal/mac.rs +++ b/packages/nx/src/native/pseudo_terminal/mac.rs @@ -1,26 +1,30 @@ use std::collections::HashMap; - use tracing::trace; use super::child_process::ChildProcess; use super::os; -use super::pseudo_terminal::{create_pseudo_terminal, run_command}; +use super::pseudo_terminal::PseudoTerminal; use crate::native::logger::enable_logger; #[napi] -pub struct RustPseudoTerminal {} +pub struct RustPseudoTerminal { + pseudo_terminal: PseudoTerminal, +} #[napi] impl RustPseudoTerminal { #[napi(constructor)] pub fn new() -> napi::Result { enable_logger(); - Ok(Self {}) + + let pseudo_terminal = PseudoTerminal::default()?; + + Ok(Self { pseudo_terminal }) } #[napi] pub fn run_command( - &self, + &mut self, command: String, command_dir: Option, js_env: Option>, @@ -28,9 +32,7 @@ impl RustPseudoTerminal { quiet: Option, tty: Option, ) -> napi::Result { - let pseudo_terminal = create_pseudo_terminal()?; - run_command( - &pseudo_terminal, + self.pseudo_terminal.run_command( command, command_dir, js_env, @@ -43,9 +45,8 @@ impl RustPseudoTerminal { /// This allows us to run a pseudoterminal with a fake node ipc channel /// this makes it possible to be backwards compatible with the old implementation #[napi] - #[allow(clippy::too_many_arguments)] pub fn fork( - &self, + &mut self, id: String, fork_script: String, pseudo_ipc_path: String, diff --git a/packages/nx/src/native/pseudo_terminal/mod.rs b/packages/nx/src/native/pseudo_terminal/mod.rs index 5f65841199..8c6dbd7f86 100644 --- a/packages/nx/src/native/pseudo_terminal/mod.rs +++ b/packages/nx/src/native/pseudo_terminal/mod.rs @@ -3,7 +3,7 @@ mod os; #[allow(clippy::module_inception)] -mod pseudo_terminal; +pub mod pseudo_terminal; pub mod child_process; diff --git a/packages/nx/src/native/pseudo_terminal/non_mac.rs b/packages/nx/src/native/pseudo_terminal/non_mac.rs index 8e7ff6695a..f90cc04904 100644 --- a/packages/nx/src/native/pseudo_terminal/non_mac.rs +++ b/packages/nx/src/native/pseudo_terminal/non_mac.rs @@ -4,7 +4,7 @@ use tracing::trace; use super::child_process::ChildProcess; use super::os; -use super::pseudo_terminal::{create_pseudo_terminal, run_command, PseudoTerminal}; +use super::pseudo_terminal::PseudoTerminal; use crate::native::logger::enable_logger; #[napi] @@ -18,14 +18,14 @@ impl RustPseudoTerminal { pub fn new() -> napi::Result { enable_logger(); - let pseudo_terminal = create_pseudo_terminal()?; + let pseudo_terminal = PseudoTerminal::default()?; Ok(Self { pseudo_terminal }) } #[napi] pub fn run_command( - &self, + &mut self, command: String, command_dir: Option, js_env: Option>, @@ -33,8 +33,7 @@ impl RustPseudoTerminal { quiet: Option, tty: Option, ) -> napi::Result { - run_command( - &self.pseudo_terminal, + self.pseudo_terminal.run_command( command, command_dir, js_env, @@ -48,7 +47,7 @@ impl RustPseudoTerminal { /// this makes it possible to be backwards compatible with the old implementation #[napi] pub fn fork( - &self, + &mut self, id: String, fork_script: String, pseudo_ipc_path: String, @@ -65,6 +64,13 @@ impl RustPseudoTerminal { ); trace!("nx_fork command: {}", &command); - self.run_command(command, command_dir, js_env, exec_argv, Some(quiet), Some(true)) + self.run_command( + command, + command_dir, + js_env, + exec_argv, + Some(quiet), + Some(true), + ) } } diff --git a/packages/nx/src/native/pseudo_terminal/pseudo_terminal.rs b/packages/nx/src/native/pseudo_terminal/pseudo_terminal.rs index 61cd28143f..88a2f64146 100644 --- a/packages/nx/src/native/pseudo_terminal/pseudo_terminal.rs +++ b/packages/nx/src/native/pseudo_terminal/pseudo_terminal.rs @@ -1,3 +1,14 @@ +use anyhow::anyhow; +use crossbeam_channel::{bounded, unbounded, Receiver}; +use crossterm::{ + terminal, + terminal::{disable_raw_mode, enable_raw_mode}, + tty::IsTty, +}; +use napi::bindgen_prelude::*; +use portable_pty::{CommandBuilder, NativePtySystem, PtyPair, PtySize, PtySystem}; +use std::io::stdout; +use std::sync::{Mutex, RwLock}; use std::{ collections::HashMap, io::{Read, Write}, @@ -7,16 +18,9 @@ use std::{ }, time::Instant, }; - -use anyhow::anyhow; -use crossbeam_channel::{bounded, unbounded, Receiver}; -use crossterm::{ - terminal, - terminal::{disable_raw_mode, enable_raw_mode}, - tty::IsTty, -}; -use portable_pty::{CommandBuilder, NativePtySystem, PtyPair, PtySize, PtySystem}; +use tracing::debug; use tracing::log::trace; +use vt100_ctt::Parser; use super::os; use crate::native::pseudo_terminal::child_process::ChildProcess; @@ -27,192 +31,249 @@ pub struct PseudoTerminal { pub printing_rx: Receiver<()>, pub quiet: Arc, pub running: Arc, + pub writer: WriterArc, + pub parser: ParserArc, + is_within_nx_tui: bool, } -pub fn create_pseudo_terminal() -> napi::Result { - let quiet = Arc::new(AtomicBool::new(true)); - let running = Arc::new(AtomicBool::new(false)); +pub struct PseudoTerminalOptions { + pub size: (u16, u16), +} - let pty_system = NativePtySystem::default(); - - let (w, h) = terminal::size().unwrap_or((80, 24)); - trace!("Opening Pseudo Terminal"); - let pty_pair = pty_system.openpty(PtySize { - rows: h, - cols: w, - pixel_width: 0, - pixel_height: 0, - })?; - - let mut writer = pty_pair.master.take_writer()?; - // Stdin -> pty stdin - if std::io::stdout().is_tty() { - trace!("Passing through stdin"); - std::thread::spawn(move || { - let mut stdin = std::io::stdin(); - if let Err(e) = os::write_to_pty(&mut stdin, &mut writer) { - trace!("Error writing to pty: {:?}", e); - } - }); - } - // Why do we do this here when it's already done when running a command? - if std::io::stdout().is_tty() { - trace!("Enabling raw mode"); - enable_raw_mode().expect("Failed to enter raw terminal mode"); +impl Default for PseudoTerminalOptions { + fn default() -> Self { + let (w, h) = terminal::size().unwrap_or((80, 24)); + Self { size: (w, h) } } +} - let mut reader = pty_pair.master.try_clone_reader()?; - let (message_tx, message_rx) = unbounded(); - let (printing_tx, printing_rx) = unbounded(); - // Output -> stdout handling - let quiet_clone = quiet.clone(); - let running_clone = running.clone(); - std::thread::spawn(move || { - let mut stdout = std::io::stdout(); - let mut buf = [0; 8 * 1024]; +pub type ParserArc = Arc>; +pub type WriterArc = Arc>>; - 'read_loop: loop { - if let Ok(len) = reader.read(&mut buf) { - if len == 0 { - break; +impl PseudoTerminal { + pub fn new(options: PseudoTerminalOptions) -> Result { + let quiet = Arc::new(AtomicBool::new(true)); + let running = Arc::new(AtomicBool::new(false)); + + let pty_system = NativePtySystem::default(); + + trace!("Opening Pseudo Terminal"); + let (w, h) = options.size; + let pty_pair = pty_system.openpty(PtySize { + rows: h, + cols: w, + pixel_width: 0, + pixel_height: 0, + })?; + + let writer = pty_pair.master.take_writer()?; + let writer_arc = Arc::new(Mutex::new(writer)); + let writer_clone = writer_arc.clone(); + + let is_within_nx_tui = + std::env::var("NX_TUI").unwrap_or_else(|_| String::from("false")) == "true"; + if !is_within_nx_tui && stdout().is_tty() { + // Stdin -> pty stdin + trace!("Passing through stdin"); + std::thread::spawn(move || { + let mut stdin = std::io::stdin(); + if let Err(e) = os::write_to_pty(&mut stdin, writer_clone) { + trace!("Error writing to pty: {:?}", e); } - message_tx - .send(String::from_utf8_lossy(&buf[0..len]).to_string()) - .ok(); - let quiet = quiet_clone.load(Ordering::Relaxed); - trace!("Quiet: {}", quiet); - if !quiet { - let mut content = String::from_utf8_lossy(&buf[0..len]).to_string(); - if content.contains("\x1B[6n") { - trace!("Prevented terminal escape sequence ESC[6n from being printed."); - content = content.replace("\x1B[6n", ""); + }); + } + + let mut reader = pty_pair.master.try_clone_reader()?; + let (message_tx, message_rx) = unbounded(); + let (printing_tx, printing_rx) = unbounded(); + // Output -> stdout handling + let quiet_clone = quiet.clone(); + let running_clone = running.clone(); + + let parser = Arc::new(RwLock::new(Parser::new(h, w, 10000))); + let parser_clone = parser.clone(); + std::thread::spawn(move || { + let mut stdout = std::io::stdout(); + let mut buf = [0; 8 * 1024]; + let mut first: bool = true; + + 'read_loop: loop { + if let Ok(len) = reader.read(&mut buf) { + if len == 0 { + break; } - let mut logged_interrupted_error = false; - while let Err(e) = stdout.write_all(content.as_bytes()) { - match e.kind() { - std::io::ErrorKind::Interrupted => { - if !logged_interrupted_error { - trace!("Interrupted error writing to stdout: {:?}", e); - logged_interrupted_error = true; + message_tx + .send(String::from_utf8_lossy(&buf[0..len]).to_string()) + .ok(); + let quiet = quiet_clone.load(Ordering::Relaxed); + trace!("Quiet: {}", quiet); + let contains_clear = buf[..len] + .windows(4) + .any(|window| window == [0x1B, 0x5B, 0x32, 0x4A]); + debug!("Contains clear: {}", contains_clear); + debug!("Read {} bytes", len); + if let Ok(mut parser) = parser_clone.write() { + let prev = parser.screen().clone(); + + parser.process(&buf[..len]); + debug!("{}", parser.get_raw_output().len()); + + let write_buf = if first { + parser.screen().contents_formatted() + } else { + parser.screen().contents_diff(&prev) + }; + first = false; + if !quiet { + let mut logged_interrupted_error = false; + while let Err(e) = stdout.write_all(&write_buf) { + match e.kind() { + std::io::ErrorKind::Interrupted => { + if !logged_interrupted_error { + trace!("Interrupted error writing to stdout: {:?}", e); + logged_interrupted_error = true; + } + continue; + } + _ => { + // We should figure out what to do for more error types as they appear. + trace!("Error writing to stdout: {:?}", e); + trace!("Error kind: {:?}", e.kind()); + break 'read_loop; + } } - continue; - } - _ => { - // We should figure out what to do for more error types as they appear. - trace!("Error writing to stdout: {:?}", e); - trace!("Error kind: {:?}", e.kind()); - break 'read_loop; } + let _ = stdout.flush(); + } + } else { + debug!("Failed to lock parser"); + } + } + if !running_clone.load(Ordering::SeqCst) { + printing_tx.send(()).ok(); + } + } + + printing_tx.send(()).ok(); + }); + Ok(PseudoTerminal { + quiet, + writer: writer_arc, + running, + parser, + pty_pair, + message_rx, + printing_rx, + is_within_nx_tui, + }) + } + + pub fn default() -> Result { + Self::new(PseudoTerminalOptions::default()) + } + + pub fn run_command( + &mut self, + command: String, + command_dir: Option, + js_env: Option>, + exec_argv: Option>, + quiet: Option, + tty: Option, + ) -> napi::Result { + let command_dir = get_directory(command_dir)?; + + let pair = &self.pty_pair; + + let quiet = quiet.unwrap_or(false); + + self.quiet.store(quiet, Ordering::Relaxed); + + let mut cmd = command_builder(); + cmd.arg(command.as_str()); + cmd.cwd(command_dir); + + if let Some(js_env) = js_env { + for (key, value) in js_env { + cmd.env(key, value); + } + } + + if let Some(exec_argv) = exec_argv { + cmd.env("NX_PSEUDO_TERMINAL_EXEC_ARGV", exec_argv.join("|")); + } + + let (exit_to_process_tx, exit_to_process_rx) = bounded(1); + trace!("Running {}", command); + + // TODO(@FrozenPandaz): This access is too naive, we need to handle the writer lock properly (e.g. multiple invocations of run_command sequentially) + // Prepend the command to the output + self.writer + .lock() + .unwrap() + // Sadly ANSI escape codes don't seem to work properly when writing directly to the writer... + .write_all(format!("> {}\n\n", command).as_bytes()) + .unwrap(); + + let mut child = pair.slave.spawn_command(cmd)?; + self.running.store(true, Ordering::SeqCst); + + let is_tty = tty.unwrap_or_else(|| std::io::stdout().is_tty()); + // Do not manipulate raw mode if running within the context of the NX_TUI, it handles it itself + let should_control_raw_mode = is_tty && !self.is_within_nx_tui; + if should_control_raw_mode { + trace!("Enabling raw mode"); + enable_raw_mode().expect("Failed to enter raw terminal mode"); + } + let process_killer = child.clone_killer(); + + trace!("Getting running clone"); + let running_clone = self.running.clone(); + trace!("Getting printing_rx clone"); + let printing_rx = self.printing_rx.clone(); + + trace!("spawning thread to wait for command"); + std::thread::spawn(move || { + trace!("Waiting for {}", command); + + let res = child.wait(); + if let Ok(exit) = res { + trace!("{} Exited", command); + // This mitigates the issues with ConPTY on windows and makes it work. + running_clone.store(false, Ordering::SeqCst); + if cfg!(windows) { + trace!("Waiting for printing to finish"); + let timeout = 500; + let a = Instant::now(); + loop { + if printing_rx.try_recv().is_ok() { + break; + } + if a.elapsed().as_millis() > timeout { + break; } } - let _ = stdout.flush(); + trace!("Printing finished"); } - } - if !running_clone.load(Ordering::SeqCst) { - printing_tx.send(()).ok(); - } - } - - printing_tx.send(()).ok(); - }); - if std::io::stdout().is_tty() { - trace!("Disabling raw mode"); - disable_raw_mode().expect("Failed to exit raw terminal mode"); - } - Ok(PseudoTerminal { - quiet, - running, - pty_pair, - message_rx, - printing_rx, - }) -} -pub fn run_command( - pseudo_terminal: &PseudoTerminal, - command: String, - command_dir: Option, - js_env: Option>, - exec_argv: Option>, - quiet: Option, - tty: Option, -) -> napi::Result { - let command_dir = get_directory(command_dir)?; - - let pair = &pseudo_terminal.pty_pair; - - let quiet = quiet.unwrap_or(false); - - pseudo_terminal.quiet.store(quiet, Ordering::Relaxed); - - let mut cmd = command_builder(); - cmd.arg(command.as_str()); - cmd.cwd(command_dir); - - if let Some(js_env) = js_env { - for (key, value) in js_env { - cmd.env(key, value); - } - } - - if let Some(exec_argv) = exec_argv { - cmd.env("NX_PSEUDO_TERMINAL_EXEC_ARGV", exec_argv.join("|")); - } - - let (exit_to_process_tx, exit_to_process_rx) = bounded(1); - trace!("Running {}", command); - let mut child = pair.slave.spawn_command(cmd)?; - pseudo_terminal.running.store(true, Ordering::SeqCst); - let is_tty = tty.unwrap_or_else(|| std::io::stdout().is_tty()); - if is_tty { - trace!("Enabling raw mode"); - enable_raw_mode().expect("Failed to enter raw terminal mode"); - } - let process_killer = child.clone_killer(); - - trace!("Getting running clone"); - let running_clone = pseudo_terminal.running.clone(); - trace!("Getting printing_rx clone"); - let printing_rx = pseudo_terminal.printing_rx.clone(); - - trace!("spawning thread to wait for command"); - std::thread::spawn(move || { - trace!("Waiting for {}", command); - - let res = child.wait(); - if let Ok(exit) = res { - trace!("{} Exited", command); - // This mitigates the issues with ConPTY on windows and makes it work. - running_clone.store(false, Ordering::SeqCst); - if cfg!(windows) { - trace!("Waiting for printing to finish"); - let timeout = 500; - let a = Instant::now(); - loop { - if printing_rx.try_recv().is_ok() { - break; - } - if a.elapsed().as_millis() > timeout { - break; - } + if should_control_raw_mode { + trace!("Disabling raw mode"); + disable_raw_mode().expect("Failed to restore non-raw terminal"); } - trace!("Printing finished"); - } - if is_tty { - trace!("Disabling raw mode"); - disable_raw_mode().expect("Failed to restore non-raw terminal"); - } - exit_to_process_tx.send(exit.to_string()).ok(); - } else { - trace!("Error waiting for {}", command); - }; - }); + exit_to_process_tx.send(exit.to_string()).ok(); + } else { + trace!("Error waiting for {}", command); + }; + }); - trace!("Returning ChildProcess"); - Ok(ChildProcess::new( - process_killer, - pseudo_terminal.message_rx.clone(), - exit_to_process_rx, - )) + trace!("Returning ChildProcess"); + Ok(ChildProcess::new( + self.parser.clone(), + self.writer.clone(), + process_killer, + self.message_rx.clone(), + exit_to_process_rx, + )) + } } fn get_directory(command_dir: Option) -> anyhow::Result { @@ -252,11 +313,12 @@ mod tests { #[test] fn can_run_commands() { let mut i = 0; - let pseudo_terminal = create_pseudo_terminal().unwrap(); + let mut pseudo_terminal = PseudoTerminal::default().unwrap(); while i < 10 { println!("Running {}", i); - let cp1 = - run_command(&pseudo_terminal, String::from("whoami"), None, None, None).unwrap(); + let cp1 = pseudo_terminal + .run_command(String::from("whoami"), None, None, None) + .unwrap(); cp1.wait_receiver.recv().unwrap(); i += 1; } diff --git a/packages/nx/src/native/tasks/types.rs b/packages/nx/src/native/tasks/types.rs index 9c90c2e1e2..6d2ffbae19 100644 --- a/packages/nx/src/native/tasks/types.rs +++ b/packages/nx/src/native/tasks/types.rs @@ -13,6 +13,9 @@ pub struct Task { pub target: TaskTarget, pub outputs: Vec, pub project_root: Option, + pub start_time: Option, + pub end_time: Option, + pub continuous: Option, } #[napi(object)] @@ -23,6 +26,15 @@ pub struct TaskTarget { pub configuration: Option, } +#[napi(object)] +#[derive(Default, Clone)] +pub struct TaskResult { + pub task: Task, + pub status: String, + pub code: i32, + pub terminal_output: Option, +} + #[napi(object)] pub struct TaskGraph { pub roots: Vec, diff --git a/packages/nx/src/native/tui/action.rs b/packages/nx/src/native/tui/action.rs new file mode 100644 index 0000000000..3598d68cda --- /dev/null +++ b/packages/nx/src/native/tui/action.rs @@ -0,0 +1,25 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + Tick, + Render, + Resize(u16, u16), + Quit, + CancelQuit, + Error(String), + Help, + EnterFilterMode, + ClearFilter, + AddFilterChar(char), + RemoveFilterChar, + ScrollUp, + ScrollDown, + NextTask, + PreviousTask, + NextPage, + PreviousPage, + ToggleOutput, + FocusNext, + FocusPrevious, + ScrollPaneUp(usize), + ScrollPaneDown(usize), +} diff --git a/packages/nx/src/native/tui/app.rs b/packages/nx/src/native/tui/app.rs new file mode 100644 index 0000000000..439693a99d --- /dev/null +++ b/packages/nx/src/native/tui/app.rs @@ -0,0 +1,687 @@ +use color_eyre::eyre::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind}; +use napi::bindgen_prelude::External; +use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction}; +use ratatui::layout::{Alignment, Rect}; +use ratatui::style::Modifier; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use tokio::sync::mpsc; +use tokio::sync::mpsc::UnboundedSender; +use tracing::debug; + +use crate::native::pseudo_terminal::pseudo_terminal::{ParserArc, WriterArc}; +use crate::native::tasks::types::{Task, TaskResult}; +use crate::native::tui::tui::Tui; + +use super::config::TuiConfig; +use super::utils::is_cache_hit; +use super::{ + action::Action, + components::{ + countdown_popup::CountdownPopup, + help_popup::HelpPopup, + tasks_list::{TaskStatus, TasksList}, + Component, + }, + tui, +}; + +pub struct App { + pub components: Vec>, + pub quit_at: Option, + focus: Focus, + previous_focus: Focus, + done_callback: Option>, + tui_config: TuiConfig, + // We track whether the user has interacted with the app to determine if we should show perform any auto-exit at all + user_has_interacted: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Focus { + TaskList, + MultipleOutput(usize), + HelpPopup, + CountdownPopup, +} + +impl App { + pub fn new( + tasks: Vec, + pinned_tasks: Vec, + tui_config: TuiConfig, + title_text: String, + ) -> Result { + let tasks_list = TasksList::new(tasks, pinned_tasks, title_text); + let help_popup = HelpPopup::new(); + let countdown_popup = CountdownPopup::new(); + let focus = tasks_list.get_focus(); + let components: Vec> = vec![ + Box::new(tasks_list), + Box::new(help_popup), + Box::new(countdown_popup), + ]; + + Ok(Self { + components, + quit_at: None, + focus, + previous_focus: Focus::TaskList, + done_callback: None, + tui_config, + user_has_interacted: false, + }) + } + + pub fn start_command(&mut self, thread_count: Option) { + if let Some(tasks_list) = self + .components + .iter_mut() + .find_map(|c| c.as_any_mut().downcast_mut::()) + { + tasks_list.set_max_parallel(thread_count); + } + } + + pub fn start_tasks(&mut self, tasks: Vec) { + if let Some(tasks_list) = self + .components + .iter_mut() + .find_map(|c| c.as_any_mut().downcast_mut::()) + { + tasks_list.start_tasks(tasks); + } + } + + pub fn print_task_terminal_output( + &mut self, + task_id: String, + status: TaskStatus, + output: String, + ) { + // If the status is a cache hit, we need to create a new parser and writer for the task in order to print the output + if is_cache_hit(status) { + let (parser, parser_and_writer) = TasksList::create_empty_parser_and_noop_writer(); + + // Add ANSI escape sequence to hide cursor at the end of output, it would be confusing to have it visible when a task is a cache hit + let output_with_hidden_cursor = format!("{}\x1b[?25l", output); + TasksList::write_output_to_parser(parser, output_with_hidden_cursor); + + if let Some(tasks_list) = self + .components + .iter_mut() + .find_map(|c| c.as_any_mut().downcast_mut::()) + { + tasks_list.create_and_register_pty_instance(&task_id, parser_and_writer); + tasks_list.update_task_status(task_id.clone(), status); + let _ = tasks_list.handle_resize(None); + } + } + } + + pub fn end_tasks(&mut self, task_results: Vec) { + if let Some(tasks_list) = self + .components + .iter_mut() + .find_map(|c| c.as_any_mut().downcast_mut::()) + { + tasks_list.end_tasks(task_results); + } + } + + // Show countdown popup for the configured duration (making sure the help popup is not open first) + pub fn end_command(&mut self) { + // If the user has interacted with the app, or auto-exit is disabled, do nothing + if self.user_has_interacted || !self.tui_config.auto_exit.should_exit_automatically() { + return; + } + + let countdown_duration = self.tui_config.auto_exit.countdown_seconds(); + // If countdown is disabled, exit immediately + if countdown_duration.is_none() { + self.quit_at = Some(std::time::Instant::now()); + return; + } + + // Otherwise, show the countdown popup for the configured duration + let countdown_duration = countdown_duration.unwrap() as u64; + if let Some(countdown_popup) = self + .components + .iter_mut() + .find_map(|c| c.as_any_mut().downcast_mut::()) + { + countdown_popup.start_countdown(countdown_duration); + self.previous_focus = self.focus; + self.focus = Focus::CountdownPopup; + self.quit_at = Some( + std::time::Instant::now() + std::time::Duration::from_secs(countdown_duration), + ); + } + } + + // A pseudo-terminal running task will provide the parser and writer directly + pub fn register_running_task( + &mut self, + task_id: String, + parser_and_writer: External<(ParserArc, WriterArc)>, + task_status: TaskStatus, + ) { + if let Some(tasks_list) = self + .components + .iter_mut() + .find_map(|c| c.as_any_mut().downcast_mut::()) + { + tasks_list.create_and_register_pty_instance(&task_id, parser_and_writer); + tasks_list.update_task_status(task_id.clone(), task_status); + } + } + + pub fn handle_event( + &mut self, + event: tui::Event, + action_tx: &mpsc::UnboundedSender, + ) -> Result { + match event { + tui::Event::Quit => { + action_tx.send(Action::Quit)?; + return Ok(true); + } + tui::Event::Tick => action_tx.send(Action::Tick)?, + tui::Event::Render => action_tx.send(Action::Render)?, + tui::Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?, + tui::Event::Key(key) => { + debug!("Handling Key Event: {:?}", key); + + // Record that the user has interacted with the app + self.user_has_interacted = true; + + // Handle Ctrl+C to quit + if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL { + // Quit immediately + self.quit_at = Some(std::time::Instant::now()); + return Ok(true); + } + + // Get tasks list component to check interactive mode before handling '?' key + if let Some(tasks_list) = self + .components + .iter_mut() + .find_map(|c| c.as_any_mut().downcast_mut::()) + { + // Only handle '?' key if we're not in interactive mode and the countdown popup is not open + if matches!(key.code, KeyCode::Char('?')) + && !tasks_list.is_interactive_mode() + && !matches!(self.focus, Focus::CountdownPopup) + { + let show_help_popup = !matches!(self.focus, Focus::HelpPopup); + if let Some(help_popup) = self + .components + .iter_mut() + .find_map(|c| c.as_any_mut().downcast_mut::()) + { + help_popup.set_visible(show_help_popup); + } + if show_help_popup { + self.previous_focus = self.focus; + self.focus = Focus::HelpPopup; + } else { + self.focus = self.previous_focus; + } + return Ok(false); + } + } + + // If countdown popup is open, handle its keyboard events + if matches!(self.focus, Focus::CountdownPopup) { + // Any key pressed (other than scroll keys if the popup is scrollable) will cancel the countdown + if let Some(countdown_popup) = self + .components + .iter_mut() + .find_map(|c| c.as_any_mut().downcast_mut::()) + { + if !countdown_popup.is_scrollable() { + countdown_popup.cancel_countdown(); + self.quit_at = None; + self.focus = self.previous_focus; + return Ok(false); + } + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + countdown_popup.scroll_up(); + return Ok(false); + } + KeyCode::Down | KeyCode::Char('j') => { + countdown_popup.scroll_down(); + return Ok(false); + } + _ => { + countdown_popup.cancel_countdown(); + self.quit_at = None; + self.focus = self.previous_focus; + } + } + } + + return Ok(false); + } + + // If shortcuts popup is open, handle its keyboard events + if matches!(self.focus, Focus::HelpPopup) { + match key.code { + KeyCode::Esc => { + if let Some(help_popup) = self + .components + .iter_mut() + .find_map(|c| c.as_any_mut().downcast_mut::()) + { + help_popup.set_visible(false); + } + self.focus = self.previous_focus; + } + KeyCode::Up | KeyCode::Char('k') => { + if let Some(help_popup) = self + .components + .iter_mut() + .find_map(|c| c.as_any_mut().downcast_mut::()) + { + help_popup.scroll_up(); + } + return Ok(false); + } + KeyCode::Down | KeyCode::Char('j') => { + if let Some(help_popup) = self + .components + .iter_mut() + .find_map(|c| c.as_any_mut().downcast_mut::()) + { + help_popup.scroll_down(); + } + return Ok(false); + } + _ => {} + } + return Ok(false); + } + + // Get tasks list component for handling key events + if let Some(tasks_list) = self + .components + .iter_mut() + .find_map(|c| c.as_any_mut().downcast_mut::()) + { + // Handle Up/Down keys for scrolling first + if matches!(tasks_list.get_focus(), Focus::MultipleOutput(_)) { + match key.code { + KeyCode::Up | KeyCode::Down => { + tasks_list.handle_key_event(key).ok(); + return Ok(false); + } + KeyCode::Char('k') | KeyCode::Char('j') + if !tasks_list.is_interactive_mode() => + { + tasks_list.handle_key_event(key).ok(); + return Ok(false); + } + _ => {} + } + } + + match tasks_list.get_focus() { + Focus::MultipleOutput(_) => { + if tasks_list.is_interactive_mode() { + // Send all other keys to the task list (and ultimately through the terminal pane to the PTY) + tasks_list.handle_key_event(key).ok(); + } else { + // Handle navigation and special actions + match key.code { + KeyCode::Tab => { + tasks_list.focus_next(); + self.focus = tasks_list.get_focus(); + } + KeyCode::BackTab => { + tasks_list.focus_previous(); + self.focus = tasks_list.get_focus(); + } + // Add our new shortcuts here + KeyCode::Char('c') => { + tasks_list.handle_key_event(key).ok(); + } + KeyCode::Char('u') | KeyCode::Char('d') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + tasks_list.handle_key_event(key).ok(); + } + KeyCode::Char('b') => { + tasks_list.toggle_task_list(); + self.focus = tasks_list.get_focus(); + } + _ => { + // Forward other keys for interactivity, scrolling (j/k) etc + tasks_list.handle_key_event(key).ok(); + } + } + } + return Ok(false); + } + _ => { + // Handle spacebar toggle regardless of focus + if key.code == KeyCode::Char(' ') { + tasks_list.toggle_output_visibility(); + return Ok(false); // Skip other key handling + } + + let is_filter_mode = tasks_list.filter_mode; + + match self.focus { + Focus::TaskList => match key.code { + KeyCode::Char('j') if !is_filter_mode => { + tasks_list.next(); + } + KeyCode::Down => { + tasks_list.next(); + } + KeyCode::Char('k') if !is_filter_mode => { + tasks_list.previous(); + } + KeyCode::Up => { + tasks_list.previous(); + } + KeyCode::Left => { + tasks_list.previous_page(); + } + KeyCode::Right => { + tasks_list.next_page(); + } + KeyCode::Esc => { + if matches!(self.focus, Focus::HelpPopup) { + if let Some(help_popup) = + self.components.iter_mut().find_map(|c| { + c.as_any_mut().downcast_mut::() + }) + { + help_popup.set_visible(false); + } + self.focus = self.previous_focus; + } else { + // Only clear filter when help popup is not in focus + + tasks_list.clear_filter(); + } + } + KeyCode::Char(c) => { + if tasks_list.filter_mode { + tasks_list.add_filter_char(c); + } else { + match c { + '/' => { + if tasks_list.filter_mode { + tasks_list.exit_filter_mode(); + } else { + tasks_list.enter_filter_mode(); + } + } + c => { + if tasks_list.filter_mode { + tasks_list.add_filter_char(c); + } else { + match c { + 'j' => tasks_list.next(), + 'k' => tasks_list.previous(), + '1' => tasks_list + .assign_current_task_to_pane(0), + '2' => tasks_list + .assign_current_task_to_pane(1), + '0' => tasks_list.clear_all_panes(), + 'h' => tasks_list.previous_page(), + 'l' => tasks_list.next_page(), + 'b' => { + tasks_list.toggle_task_list(); + self.focus = tasks_list.get_focus(); + } + _ => {} + } + } + } + } + } + } + KeyCode::Backspace => { + if tasks_list.filter_mode { + tasks_list.remove_filter_char(); + } + } + KeyCode::Tab => { + if tasks_list.has_visible_panes() { + tasks_list.focus_next(); + self.focus = tasks_list.get_focus(); + } + } + KeyCode::BackTab => { + if tasks_list.has_visible_panes() { + tasks_list.focus_previous(); + self.focus = tasks_list.get_focus(); + } + } + _ => {} + }, + Focus::MultipleOutput(_idx) => match key.code { + KeyCode::Tab => { + tasks_list.focus_next(); + self.focus = tasks_list.get_focus(); + } + KeyCode::BackTab => { + tasks_list.focus_previous(); + self.focus = tasks_list.get_focus(); + } + _ => {} + }, + Focus::HelpPopup => { + // Shortcuts popup has its own key handling above + } + Focus::CountdownPopup => { + // Countdown popup has its own key handling above + } + } + } + } + } + } + tui::Event::Mouse(mouse) => { + // Record that the user has interacted with the app + self.user_has_interacted = true; + + if let Some(tasks_list) = self + .components + .iter_mut() + .find_map(|c| c.as_any_mut().downcast_mut::()) + { + match mouse.kind { + MouseEventKind::ScrollUp => { + if matches!(tasks_list.get_focus(), Focus::MultipleOutput(_)) { + tasks_list + .handle_key_event(KeyEvent::new( + KeyCode::Up, + KeyModifiers::empty(), + )) + .ok(); + } else if matches!(tasks_list.get_focus(), Focus::TaskList) { + tasks_list.previous(); + } + } + MouseEventKind::ScrollDown => { + if matches!(tasks_list.get_focus(), Focus::MultipleOutput(_)) { + tasks_list + .handle_key_event(KeyEvent::new( + KeyCode::Down, + KeyModifiers::empty(), + )) + .ok(); + } else if matches!(tasks_list.get_focus(), Focus::TaskList) { + tasks_list.next(); + } + } + _ => {} + } + } + } + _ => {} + } + + for component in self.components.iter_mut() { + if let Some(action) = component.handle_events(Some(event.clone()))? { + action_tx.send(action)?; + } + } + + Ok(false) + } + + pub fn handle_action( + &mut self, + tui: &mut Tui, + action: Action, + action_tx: &UnboundedSender, + ) { + if action != Action::Tick && action != Action::Render { + debug!("{action:?}"); + } + match action { + // Quit immediately + Action::Quit => self.quit_at = Some(std::time::Instant::now()), + // Cancel quitting + Action::CancelQuit => { + self.quit_at = None; + self.focus = self.previous_focus; + } + Action::Resize(w, h) => { + tui.resize(Rect::new(0, 0, w, h)).ok(); + + // Ensure the help popup is resized correctly + if let Some(help_popup) = self + .components + .iter_mut() + .find_map(|c| c.as_any_mut().downcast_mut::()) + { + help_popup.handle_resize(w, h); + } + + // Propagate resize to PTY instances + if let Some(tasks_list) = self + .components + .iter_mut() + .find_map(|c| c.as_any_mut().downcast_mut::()) + { + tasks_list.handle_resize(Some((w, h))).ok(); + } + tui.draw(|f| { + for component in self.components.iter_mut() { + let r = component.draw(f, f.area()); + if let Err(e) = r { + action_tx + .send(Action::Error(format!("Failed to draw: {:?}", e))) + .ok(); + } + } + }) + .ok(); + } + Action::Render => { + tui.draw(|f| { + let area = f.area(); + + // Check for minimum viable viewport size at the app level + if area.height < 10 || area.width < 40 { + let message = Line::from(vec![ + Span::raw(" "), + Span::styled( + " NX ", + Style::reset() + .add_modifier(Modifier::BOLD) + .bg(Color::Red) + .fg(Color::Black), + ), + Span::raw(" "), + Span::raw("Please make your terminal viewport larger in order to view the terminal UI"), + ]); + + // Create empty lines for vertical centering + let empty_line = Line::from(""); + let mut lines = vec![]; + + // Add empty lines to center vertically + let vertical_padding = (area.height as usize).saturating_sub(3) / 2; + for _ in 0..vertical_padding { + lines.push(empty_line.clone()); + } + + // Add the message + lines.push(message); + + let paragraph = Paragraph::new(lines) + .alignment(Alignment::Center); + f.render_widget(paragraph, area); + return; + } + + // Only render components if viewport is large enough + // Draw main components with dimming if a popup is focused + let current_focus = self.focus(); + for component in self.components.iter_mut() { + if let Some(tasks_list) = + component.as_any_mut().downcast_mut::() + { + tasks_list.set_dimmed(matches!(current_focus, Focus::HelpPopup | Focus::CountdownPopup)); + tasks_list.set_focus(current_focus); + } + let r = component.draw(f, f.area()); + if let Err(e) = r { + action_tx + .send(Action::Error(format!("Failed to draw: {:?}", e))) + .ok(); + } + } + }).ok(); + } + _ => {} + } + + // Update components + for component in self.components.iter_mut() { + if let Ok(Some(new_action)) = component.update(action.clone()) { + action_tx.send(new_action).ok(); + } + } + } + + pub fn set_done_callback( + &mut self, + done_callback: ThreadsafeFunction<(), ErrorStrategy::Fatal>, + ) { + self.done_callback = Some(done_callback); + } + + pub fn call_done_callback(&self) { + if let Some(cb) = &self.done_callback { + cb.call( + (), + napi::threadsafe_function::ThreadsafeFunctionCallMode::Blocking, + ); + } + } + + pub fn focus(&self) -> Focus { + self.focus + } + + pub fn set_cloud_message(&mut self, message: Option) { + if let Some(tasks_list) = self + .components + .iter_mut() + .find_map(|c| c.as_any_mut().downcast_mut::()) + { + tasks_list.set_cloud_message(message); + } + } +} diff --git a/packages/nx/src/native/tui/components.rs b/packages/nx/src/native/tui/components.rs new file mode 100644 index 0000000000..187a8d86e0 --- /dev/null +++ b/packages/nx/src/native/tui/components.rs @@ -0,0 +1,52 @@ +use color_eyre::eyre::Result; +use crossterm::event::{KeyEvent, MouseEvent}; +use ratatui::layout::Rect; +use std::any::Any; +use tokio::sync::mpsc::UnboundedSender; + +use super::{ + action::Action, + tui::{Event, Frame}, +}; + +pub mod countdown_popup; +pub mod help_popup; +pub mod help_text; +pub mod pagination; +pub mod task_selection_manager; +pub mod tasks_list; +pub mod terminal_pane; + +pub trait Component: Any + Send { + #[allow(unused_variables)] + fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { + Ok(()) + } + fn init(&mut self) -> Result<()> { + Ok(()) + } + fn handle_events(&mut self, event: Option) -> Result> { + let r = match event { + Some(Event::Key(key_event)) => self.handle_key_events(key_event)?, + Some(Event::Mouse(mouse_event)) => self.handle_mouse_events(mouse_event)?, + _ => None, + }; + Ok(r) + } + #[allow(unused_variables)] + fn handle_key_events(&mut self, key: KeyEvent) -> Result> { + Ok(None) + } + #[allow(unused_variables)] + fn handle_mouse_events(&mut self, mouse: MouseEvent) -> Result> { + Ok(None) + } + #[allow(unused_variables)] + fn update(&mut self, action: Action) -> Result> { + Ok(None) + } + fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()>; + + fn as_any(&self) -> &dyn Any; + fn as_any_mut(&mut self) -> &mut dyn Any; +} diff --git a/packages/nx/src/native/tui/components/countdown_popup.rs b/packages/nx/src/native/tui/components/countdown_popup.rs new file mode 100644 index 0000000000..d35d3774df --- /dev/null +++ b/packages/nx/src/native/tui/components/countdown_popup.rs @@ -0,0 +1,299 @@ +use color_eyre::eyre::Result; +use ratatui::{ + layout::{Alignment, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{ + Block, BorderType, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, + ScrollbarState, + }, + Frame, +}; +use std::any::Any; +use std::time::{Duration, Instant}; + +use super::Component; + +pub struct CountdownPopup { + visible: bool, + start_time: Option, + duration: Duration, + scroll_offset: usize, + scrollbar_state: ScrollbarState, + content_height: usize, + viewport_height: usize, +} + +impl CountdownPopup { + pub fn new() -> Self { + Self { + visible: false, + start_time: None, + duration: Duration::from_secs(3), + scroll_offset: 0, + scrollbar_state: ScrollbarState::default(), + content_height: 0, + viewport_height: 0, + } + } + + pub fn is_scrollable(&self) -> bool { + self.content_height > self.viewport_height + } + + pub fn start_countdown(&mut self, duration_secs: u64) { + self.visible = true; + self.start_time = Some(Instant::now()); + self.duration = Duration::from_secs(duration_secs); + self.scroll_offset = 0; + self.scrollbar_state = ScrollbarState::default(); + } + + pub fn cancel_countdown(&mut self) { + self.visible = false; + self.start_time = None; + } + + pub fn should_quit(&self) -> bool { + if let Some(start_time) = self.start_time { + return start_time.elapsed() >= self.duration; + } + false + } + + pub fn set_visible(&mut self, visible: bool) { + self.visible = visible; + if !visible { + self.start_time = None; + } + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + pub fn scroll_up(&mut self) { + if self.scroll_offset > 0 { + self.scroll_offset -= 1; + // Update scrollbar state with new position + self.scrollbar_state = self + .scrollbar_state + .content_length(self.content_height) + .viewport_content_length(self.viewport_height) + .position(self.scroll_offset); + } + } + + pub fn scroll_down(&mut self) { + let max_scroll = self.content_height.saturating_sub(self.viewport_height); + if self.scroll_offset < max_scroll { + self.scroll_offset += 1; + // Update scrollbar state with new position + self.scrollbar_state = self + .scrollbar_state + .content_length(self.content_height) + .viewport_content_length(self.viewport_height) + .position(self.scroll_offset); + } + } + + pub fn render(&mut self, f: &mut Frame<'_>, area: Rect) { + let popup_height = 9; + let popup_width = 70; + + // Make sure we don't exceed the available area + let popup_height = popup_height.min(area.height.saturating_sub(4)); + let popup_width = popup_width.min(area.width.saturating_sub(4)); + + // Calculate the top-left position to center the popup + let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2; + let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2; + + // Create popup area with fixed dimensions + let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height); + + // Calculate seconds remaining + let seconds_remaining = if let Some(start_time) = self.start_time { + let elapsed = start_time.elapsed(); + if elapsed >= self.duration { + 0 + } else { + (self.duration - elapsed).as_secs() + } + } else { + 0 + }; + + let time_remaining = seconds_remaining + 1; + + let content = vec![ + Line::from(vec![ + Span::styled("• Press ", Style::default().fg(Color::DarkGray)), + Span::styled("any key", Style::default().fg(Color::Cyan)), + Span::styled( + " to keep the TUI running and interactively explore the results.", + Style::default().fg(Color::DarkGray), + ), + ]), + Line::from(""), + Line::from(vec![ + Span::styled( + "• Learn how to configure auto-exit and more in the docs: ", + Style::default().fg(Color::DarkGray), + ), + Span::styled( + // NOTE: I tried OSC 8 sequences here but they broke the layout, see: https://github.com/ratatui/ratatui/issues/1028 + "https://nx.dev/terminal-ui", + Style::default().fg(Color::Cyan), + ), + ]), + ]; + + let block = Block::default() + .title(Line::from(vec![ + Span::raw(" "), + Span::styled( + " NX ", + Style::default() + .add_modifier(Modifier::BOLD) + .bg(Color::Cyan) + .fg(Color::Black), + ), + Span::styled(" Exiting in ", Style::default().fg(Color::White)), + Span::styled( + format!("{}", time_remaining), + Style::default().fg(Color::Cyan), + ), + Span::styled("... ", Style::default().fg(Color::White)), + ])) + .title_alignment(Alignment::Left) + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(Style::default().fg(Color::Cyan)) + .padding(Padding::proportional(1)); + + // Get the inner area + let inner_area = block.inner(popup_area); + self.viewport_height = inner_area.height as usize; + + // Calculate content height based on line wrapping + let wrapped_height = content + .iter() + .map(|line| { + let line_width = line.width() as u16; + if line_width == 0 { + 1 // Empty lines still take up one row + } else { + (line_width.saturating_sub(1) / inner_area.width).saturating_add(1) as usize + } + }) + .sum(); + self.content_height = wrapped_height; + + // Calculate scrollbar state + let scrollable_rows = self.content_height.saturating_sub(self.viewport_height); + let needs_scrollbar = scrollable_rows > 0; + + // Update scrollbar state + self.scrollbar_state = if needs_scrollbar { + self.scrollbar_state + .content_length(scrollable_rows) + .viewport_content_length(self.viewport_height) + .position(self.scroll_offset) + } else { + ScrollbarState::default() + }; + + // Create scrollable paragraph + let scroll_start = self.scroll_offset; + let scroll_end = (self.scroll_offset + self.viewport_height).min(content.len()); + let visible_content = content[scroll_start..scroll_end].to_vec(); + + let popup = Paragraph::new(visible_content) + .block(block.clone()) + .wrap(ratatui::widgets::Wrap { trim: true }); + + // Render popup + f.render_widget(Clear, popup_area); + f.render_widget(popup, popup_area); + + // Render scrollbar if needed + if needs_scrollbar { + // Add padding text at top and bottom of scrollbar + let top_text = Line::from(vec![Span::raw(" ")]); + let bottom_text = Line::from(vec![Span::raw(" ")]); + + let text_width = 2; // Width of " " + + // Top right padding + let top_right_area = Rect { + x: popup_area.x + popup_area.width - text_width as u16 - 3, + y: popup_area.y, + width: text_width as u16 + 2, + height: 1, + }; + + // Bottom right padding + let bottom_right_area = Rect { + x: popup_area.x + popup_area.width - text_width as u16 - 3, + y: popup_area.y + popup_area.height - 1, + width: text_width as u16 + 2, + height: 1, + }; + + // Render padding text + f.render_widget( + Paragraph::new(top_text) + .alignment(Alignment::Right) + .style(Style::default().fg(Color::Cyan)), + top_right_area, + ); + + f.render_widget( + Paragraph::new(bottom_text) + .alignment(Alignment::Right) + .style(Style::default().fg(Color::Cyan)), + bottom_right_area, + ); + + let scrollbar = Scrollbar::default() + .orientation(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")) + .style(Style::default().fg(Color::Cyan)); + + f.render_stateful_widget(scrollbar, popup_area, &mut self.scrollbar_state); + } + } +} + +impl Clone for CountdownPopup { + fn clone(&self) -> Self { + Self { + visible: self.visible, + start_time: self.start_time, + duration: self.duration, + scroll_offset: self.scroll_offset, + scrollbar_state: self.scrollbar_state, + content_height: self.content_height, + viewport_height: self.viewport_height, + } + } +} + +impl Component for CountdownPopup { + fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> { + if self.visible { + self.render(f, rect); + } + Ok(()) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} diff --git a/packages/nx/src/native/tui/components/help_popup.rs b/packages/nx/src/native/tui/components/help_popup.rs new file mode 100644 index 0000000000..33711a4bd1 --- /dev/null +++ b/packages/nx/src/native/tui/components/help_popup.rs @@ -0,0 +1,347 @@ +use color_eyre::eyre::Result; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{ + Block, BorderType, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, + ScrollbarState, + }, +}; +use std::any::Any; + +use super::{Component, Frame}; + +pub struct HelpPopup { + scroll_offset: usize, + scrollbar_state: ScrollbarState, + content_height: usize, + viewport_height: usize, + visible: bool, +} + +impl HelpPopup { + pub fn new() -> Self { + Self { + scroll_offset: 0, + scrollbar_state: ScrollbarState::default(), + content_height: 0, + viewport_height: 0, + visible: false, + } + } + + pub fn set_visible(&mut self, visible: bool) { + self.visible = visible; + } + + // Ensure the scroll state is reset to avoid recalc issues + pub fn handle_resize(&mut self, _width: u16, _height: u16) { + self.scroll_offset = 0; + self.scrollbar_state = ScrollbarState::default(); + } + + pub fn scroll_up(&mut self) { + if self.scroll_offset > 0 { + self.scroll_offset -= 1; + // Update scrollbar state with new position + self.scrollbar_state = self + .scrollbar_state + .content_length(self.content_height) + .viewport_content_length(self.viewport_height) + .position(self.scroll_offset); + } + } + + pub fn scroll_down(&mut self) { + let max_scroll = self.content_height.saturating_sub(self.viewport_height); + if self.scroll_offset < max_scroll { + self.scroll_offset += 1; + // Update scrollbar state with new position + self.scrollbar_state = self + .scrollbar_state + .content_length(self.content_height) + .viewport_content_length(self.viewport_height) + .position(self.scroll_offset); + } + } + + pub fn render(&mut self, f: &mut Frame<'_>, area: Rect) { + let percent_y = 85; + let percent_x = 70; + + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + + let popup_area = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1]; + + let keybindings = vec![ + // Misc + ("?", "Toggle this popup"), + ("+c", "Quit the TUI"), + ("", ""), + // Navigation + ("↑ or k", "Navigate/scroll task output up"), + ("↓ or j", "Navigate/scroll task output down"), + ("+u", "Scroll task output up"), + ("+d", "Scroll task output down"), + ("← or h", "Navigate left"), + ("→ or l", "Navigate right"), + ("", ""), + // Task List Controls + ("/", "Filter tasks based on search term"), + ("", "Clear filter"), + ("", ""), + // Output Controls + ("", "Quick toggle a single output pane"), + ("b", "Toggle task list visibility"), + ("1", "Pin task to be shown in output pane 1"), + ("2", "Pin task to be shown in output pane 2"), + ( + "", + "Move focus between task list and output panes 1 and 2", + ), + ("c", "Copy focused output to clipboard"), + ("", ""), + // Interactive Mode + ("i", "Interact with a continuous task when it is in focus"), + ("+z", "Stop interacting with a continuous task"), + ]; + + let mut content: Vec = vec![ + // Welcome text + Line::from(vec![ + Span::styled( + "Thanks for using Nx! To get the most out of this terminal UI, please check out the docs: ", + Style::default().fg(Color::White), + ), + Span::styled( + // NOTE: I tried OSC 8 sequences here but they broke the layout, see: https://github.com/ratatui/ratatui/issues/1028 + "https://nx.dev/terminal-ui", + Style::default().fg(Color::Cyan), + ), + ]), + Line::from(vec![ + Span::styled( + "If you are finding Nx useful, please consider giving it a star on GitHub, it means a lot: ", + Style::default().fg(Color::White), + ), + Span::styled( + // NOTE: I tried OSC 8 sequences here but they broke the layout, see: https://github.com/ratatui/ratatui/issues/1028 + "https://github.com/nrwl/nx", + Style::default().fg(Color::Cyan), + ), + ]), + Line::from(""), // Empty line for spacing + Line::from(vec![Span::styled( + "Available keyboard shortcuts:", + Style::default().fg(Color::DarkGray), + )]), + Line::from(""), // Empty line for spacing + ]; + + // Add keybindings to content + content.extend( + keybindings + .into_iter() + .map(|(key, desc)| { + if key.is_empty() { + Line::from("") + } else { + // Split the key text on " or " if it exists + let key_parts: Vec<&str> = key.split(" or ").collect(); + let mut spans = Vec::new(); + + // Calculate the total visible length (excluding color codes) + let visible_length = if key_parts.len() > 1 { + key_parts.iter().map(|s| s.len()).sum::() + 2 + // for alignment + } else { + key.len() + }; + + // Add each key part with the appropriate styling + for (i, part) in key_parts.iter().enumerate() { + if i > 0 { + spans.push(Span::styled( + " or ", + Style::default().fg(Color::DarkGray), + )); + } + spans.push(Span::styled( + part.to_string(), + Style::default().fg(Color::Cyan), + )); + } + + // Add padding to align all descriptions + let padding = " ".repeat(11usize.saturating_sub(visible_length)); + spans.push(Span::raw(padding)); + + // Add the separator and description + spans.push(Span::styled("= ", Style::default().fg(Color::DarkGray))); + spans.push(Span::styled(desc, Style::default().fg(Color::White))); + + Line::from(spans) + } + }) + .collect::>(), + ); + + // Update content height based on actual content + let block = Block::default() + .title(Line::from(vec![ + Span::raw(" "), + Span::styled( + " NX ", + Style::default() + .add_modifier(Modifier::BOLD) + .bg(Color::Cyan) + .fg(Color::Black), + ), + Span::styled(" Help ", Style::default().fg(Color::White)), + ])) + .title_alignment(Alignment::Left) + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(Style::default().fg(Color::Cyan)) + .padding(Padding::proportional(1)); + + let inner_area = block.inner(popup_area); + self.viewport_height = inner_area.height as usize; + + // Calculate wrapped height by measuring each line + let wrapped_height = content + .iter() + .map(|line| { + // Get total width of all spans in the line + let line_width = line.width() as u16; + // Calculate how many rows this line will take up when wrapped + if line_width == 0 { + 1 // Empty lines still take up one row + } else { + (line_width.saturating_sub(1) / inner_area.width).saturating_add(1) as usize + } + }) + .sum(); + self.content_height = wrapped_height; + + // Calculate scrollbar state using the same logic as task list output panes + let scrollable_rows = self.content_height.saturating_sub(self.viewport_height); + let needs_scrollbar = scrollable_rows > 0; + + // Reset scrollbar state if no scrolling needed + self.scrollbar_state = if needs_scrollbar { + let position = self.scroll_offset; + self.scrollbar_state + .content_length(scrollable_rows) + .viewport_content_length(self.viewport_height) + .position(position) + } else { + ScrollbarState::default() + }; + + // Create scrollable paragraph + let scroll_start = self.scroll_offset; + let scroll_end = (self.scroll_offset + self.viewport_height).min(content.len()); + let visible_content = content[scroll_start..scroll_end].to_vec(); + + let popup = Paragraph::new(visible_content) + .block(block) + .alignment(Alignment::Left) + .wrap(ratatui::widgets::Wrap { trim: true }); + + f.render_widget(Clear, popup_area); + f.render_widget(popup, popup_area); + + // Render scrollbar if needed + if needs_scrollbar { + // Add padding text at top and bottom of scrollbar + let top_text = Line::from(vec![Span::raw(" ")]); + let bottom_text = Line::from(vec![Span::raw(" ")]); + + let text_width = 2; // Width of " " + + // Top right padding + let top_right_area = Rect { + x: popup_area.x + popup_area.width - text_width as u16 - 3, + y: popup_area.y, + width: text_width as u16 + 2, + height: 1, + }; + + // Bottom right padding + let bottom_right_area = Rect { + x: popup_area.x + popup_area.width - text_width as u16 - 3, + y: popup_area.y + popup_area.height - 1, + width: text_width as u16 + 2, + height: 1, + }; + + // Render padding text + f.render_widget( + Paragraph::new(top_text) + .alignment(Alignment::Right) + .style(Style::default().fg(Color::Cyan)), + top_right_area, + ); + + f.render_widget( + Paragraph::new(bottom_text) + .alignment(Alignment::Right) + .style(Style::default().fg(Color::Cyan)), + bottom_right_area, + ); + + let scrollbar = Scrollbar::default() + .orientation(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")) + .style(Style::default().fg(Color::Cyan)); + + f.render_stateful_widget(scrollbar, popup_area, &mut self.scrollbar_state); + } + } +} + +impl Clone for HelpPopup { + fn clone(&self) -> Self { + Self { + scroll_offset: self.scroll_offset, + scrollbar_state: self.scrollbar_state, + content_height: self.content_height, + viewport_height: self.viewport_height, + visible: self.visible, + } + } +} + +impl Component for HelpPopup { + fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> { + if self.visible { + self.render(f, rect); + } + Ok(()) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} diff --git a/packages/nx/src/native/tui/components/help_text.rs b/packages/nx/src/native/tui/components/help_text.rs new file mode 100644 index 0000000000..a84fc10336 --- /dev/null +++ b/packages/nx/src/native/tui/components/help_text.rs @@ -0,0 +1,83 @@ +use ratatui::{ + layout::{Alignment, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +pub struct HelpText { + collapsed_mode: bool, + is_dimmed: bool, + align_left: bool, +} + +impl HelpText { + pub fn new(collapsed_mode: bool, is_dimmed: bool, align_left: bool) -> Self { + Self { + collapsed_mode, + is_dimmed, + align_left, + } + } + + pub fn set_collapsed_mode(&mut self, collapsed: bool) { + self.collapsed_mode = collapsed; + } + + pub fn render(&self, f: &mut Frame<'_>, area: Rect) { + let base_style = if self.is_dimmed { + Style::default().add_modifier(Modifier::DIM) + } else { + Style::default() + }; + + if self.collapsed_mode { + // Show minimal hint + let hint = vec![ + Span::styled("quit: ", base_style.fg(Color::DarkGray)), + Span::styled("+c", base_style.fg(Color::Cyan)), + Span::styled(" ", base_style.fg(Color::DarkGray)), + Span::styled("help: ", base_style.fg(Color::DarkGray)), + Span::styled("? ", base_style.fg(Color::Cyan)), + ]; + f.render_widget( + Paragraph::new(Line::from(hint)).alignment(if self.align_left { + Alignment::Left + } else { + Alignment::Right + }), + area, + ); + } else { + // Show full shortcuts + let shortcuts = vec![ + Span::styled("quit: ", base_style.fg(Color::DarkGray)), + Span::styled("+c", base_style.fg(Color::Cyan)), + Span::styled(" ", base_style.fg(Color::DarkGray)), + Span::styled("help: ", base_style.fg(Color::DarkGray)), + Span::styled("?", base_style.fg(Color::Cyan)), + Span::styled(" ", base_style.fg(Color::DarkGray)), + Span::styled("navigate: ", base_style.fg(Color::DarkGray)), + Span::styled("↑ ↓", base_style.fg(Color::Cyan)), + Span::styled(" ", base_style.fg(Color::DarkGray)), + Span::styled("filter: ", base_style.fg(Color::DarkGray)), + Span::styled("/", base_style.fg(Color::Cyan)), + Span::styled(" ", base_style.fg(Color::DarkGray)), + Span::styled("pin output: ", base_style.fg(Color::DarkGray)), + Span::styled("", base_style.fg(Color::DarkGray)), + Span::styled("1", base_style.fg(Color::Cyan)), + Span::styled(" or ", base_style.fg(Color::DarkGray)), + Span::styled("2", base_style.fg(Color::Cyan)), + Span::styled(" ", base_style.fg(Color::DarkGray)), + Span::styled("focus output: ", base_style.fg(Color::DarkGray)), + Span::styled("", base_style.fg(Color::Cyan)), + ]; + + f.render_widget( + Paragraph::new(Line::from(shortcuts)).alignment(Alignment::Center), + area, + ); + } + } +} diff --git a/packages/nx/src/native/tui/components/pagination.rs b/packages/nx/src/native/tui/components/pagination.rs new file mode 100644 index 0000000000..9fc364be26 --- /dev/null +++ b/packages/nx/src/native/tui/components/pagination.rs @@ -0,0 +1,64 @@ +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +pub struct Pagination { + current_page: usize, + total_pages: usize, +} + +impl Pagination { + pub fn new(current_page: usize, total_pages: usize) -> Self { + Self { + current_page, + total_pages, + } + } + + pub fn render(&self, f: &mut Frame<'_>, area: Rect, is_dimmed: bool) { + let base_style = if is_dimmed { + Style::default().add_modifier(Modifier::DIM) + } else { + Style::default() + }; + + let mut spans = vec![]; + + // Ensure we have at least 1 page + let total_pages = self.total_pages.max(1); + let current_page = self.current_page.min(total_pages - 1); + + // Left arrow - dim if we're on the first page + let left_arrow = if current_page == 0 { + Span::styled("←", base_style.fg(Color::Cyan).add_modifier(Modifier::DIM)) + } else { + Span::styled("←", base_style.fg(Color::Cyan)) + }; + spans.push(left_arrow); + + // Page numbers + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("{}/{}", current_page + 1, total_pages), + base_style.fg(Color::DarkGray), + )); + spans.push(Span::raw(" ")); + + // Right arrow - dim if we're on the last page + let right_arrow = if current_page >= total_pages.saturating_sub(1) { + Span::styled("→", base_style.fg(Color::Cyan).add_modifier(Modifier::DIM)) + } else { + Span::styled("→", base_style.fg(Color::Cyan)) + }; + spans.push(right_arrow); + + let pagination_line = Line::from(spans); + let pagination = Paragraph::new(pagination_line); + + f.render_widget(pagination, area); + } +} diff --git a/packages/nx/src/native/tui/components/task_selection_manager.rs b/packages/nx/src/native/tui/components/task_selection_manager.rs new file mode 100644 index 0000000000..2c6ba3266b --- /dev/null +++ b/packages/nx/src/native/tui/components/task_selection_manager.rs @@ -0,0 +1,464 @@ +pub struct TaskSelectionManager { + // The list of task names in their current visual order, None represents empty rows + entries: Vec>, + // The currently selected task name + selected_task_name: Option, + // Current page and pagination settings + current_page: usize, + items_per_page: usize, + // Selection mode determines how the selection behaves when entries change + selection_mode: SelectionMode, +} + +/// Controls how task selection behaves when entries are updated or reordered +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum SelectionMode { + /// Track a specific task by name regardless of its position in the list + /// Used when a task is pinned or in spacebar mode + TrackByName, + + /// Track selection by position/index in the list + /// Used when no tasks are pinned and not in spacebar mode + TrackByPosition, +} + +impl TaskSelectionManager { + pub fn new(items_per_page: usize) -> Self { + Self { + entries: Vec::new(), + selected_task_name: None, + current_page: 0, + items_per_page, + selection_mode: SelectionMode::TrackByName, + } + } + + /// Sets the selection mode + pub fn set_selection_mode(&mut self, mode: SelectionMode) { + self.selection_mode = mode; + } + + /// Gets the current selection mode + pub fn get_selection_mode(&self) -> SelectionMode { + self.selection_mode + } + + pub fn update_entries(&mut self, entries: Vec>) { + match self.selection_mode { + SelectionMode::TrackByName => self.update_entries_track_by_name(entries), + SelectionMode::TrackByPosition => self.update_entries_track_by_position(entries), + } + } + + /// Updates entries while trying to preserve the selected task by name + fn update_entries_track_by_name(&mut self, entries: Vec>) { + // Keep track of currently selected task name + let selected = self.selected_task_name.clone(); + + // Update the entries + self.entries = entries; + + // Ensure current page is valid before validating selection + self.validate_current_page(); + + // If we had a selection, try to find it in the new list + if let Some(task_name) = selected { + // First check if the task still exists in the entries + let task_still_exists = self + .entries + .iter() + .any(|entry| entry.as_ref() == Some(&task_name)); + + if task_still_exists { + // Task is still in the list, keep it selected with the same name + self.selected_task_name = Some(task_name); + + // Update the current page to ensure the selected task is visible + if let Some(idx) = self.get_selected_index() { + self.current_page = idx / self.items_per_page; + } + } else { + // If task is no longer in the list, select first available task + self.select_first_available(); + } + } else { + // No previous selection, select first available task + self.select_first_available(); + } + + // Validate selection for current page + self.validate_selection_for_current_page(); + } + + /// Updates entries while trying to preserve the selected position in the list + fn update_entries_track_by_position(&mut self, entries: Vec>) { + // Get the current selection position within the page + let page_index = self.get_selected_index_in_current_page(); + + // Update the entries + self.entries = entries; + + // Ensure current page is valid + self.validate_current_page(); + + // If we had a selection and there are entries, try to maintain the position + if let Some(idx) = page_index { + let start = self.current_page * self.items_per_page; + let end = (start + self.items_per_page).min(self.entries.len()); + + if start < end { + // Convert page index to absolute index + let absolute_idx = start + idx; + + // Find the next non-empty entry at or after the position + for i in absolute_idx..end { + if let Some(Some(name)) = self.entries.get(i) { + self.selected_task_name = Some(name.clone()); + return; + } + } + + // If we can't find one after, try before + for i in (start..absolute_idx).rev() { + if let Some(Some(name)) = self.entries.get(i) { + self.selected_task_name = Some(name.clone()); + return; + } + } + } + + // If we couldn't find anything on the current page, select first available + self.select_first_available(); + } else { + // No previous selection, select first available task + self.select_first_available(); + } + } + + pub fn select(&mut self, task_name: Option) { + match task_name { + Some(name) if self.entries.iter().any(|e| e.as_ref() == Some(&name)) => { + self.selected_task_name = Some(name); + // Update current page to show selected task + if let Some(idx) = self + .entries + .iter() + .position(|e| e.as_deref() == self.selected_task_name.as_deref()) + { + self.current_page = idx / self.items_per_page; + } + } + _ => { + self.selected_task_name = None; + } + } + } + + pub fn select_task(&mut self, task_id: String) { + self.selected_task_name = Some(task_id); + } + + pub fn next(&mut self) { + if let Some(current_idx) = self.get_selected_index() { + // Find next non-empty entry + for idx in (current_idx + 1)..self.entries.len() { + if self.entries[idx].is_some() { + self.selected_task_name = self.entries[idx].clone(); + // Update page if needed + self.current_page = idx / self.items_per_page; + return; + } + } + } else { + self.select_first_available(); + } + } + + pub fn previous(&mut self) { + if let Some(current_idx) = self.get_selected_index() { + // Find previous non-empty entry + for idx in (0..current_idx).rev() { + if self.entries[idx].is_some() { + self.selected_task_name = self.entries[idx].clone(); + // Update page if needed + self.current_page = idx / self.items_per_page; + return; + } + } + } else { + self.select_first_available(); + } + } + + pub fn next_page(&mut self) { + let total_pages = self.total_pages(); + if self.current_page < total_pages - 1 { + self.current_page += 1; + self.validate_selection_for_current_page(); + } + } + + pub fn previous_page(&mut self) { + if self.current_page > 0 { + self.current_page -= 1; + self.validate_selection_for_current_page(); + } + } + + pub fn get_current_page_entries(&self) -> Vec> { + let start = self.current_page * self.items_per_page; + let end = (start + self.items_per_page).min(self.entries.len()); + self.entries[start..end].to_vec() + } + + pub fn is_selected(&self, task_name: &str) -> bool { + self.selected_task_name + .as_ref() + .map_or(false, |selected| selected == task_name) + } + + pub fn get_selected_task_name(&self) -> Option<&String> { + self.selected_task_name.as_ref() + } + + pub fn total_pages(&self) -> usize { + (self.entries.len() + self.items_per_page - 1) / self.items_per_page + } + + pub fn get_current_page(&self) -> usize { + self.current_page + } + + fn select_first_available(&mut self) { + self.selected_task_name = self.entries.iter().find_map(|e| e.clone()); + // Ensure selected task is on current page + self.validate_selection_for_current_page(); + } + + fn validate_current_page(&mut self) { + let total_pages = self.total_pages(); + if total_pages == 0 { + self.current_page = 0; + } else { + self.current_page = self.current_page.min(total_pages - 1); + } + } + + fn validate_selection_for_current_page(&mut self) { + if let Some(task_name) = &self.selected_task_name { + let start = self.current_page * self.items_per_page; + let end = (start + self.items_per_page).min(self.entries.len()); + + // Check if selected task is on current page + if start < end + && !self.entries[start..end] + .iter() + .any(|e| e.as_ref() == Some(task_name)) + { + // If not, select first available task on current page + self.selected_task_name = self.entries[start..end].iter().find_map(|e| e.clone()); + } + } + } + + pub fn get_selected_index(&self) -> Option { + if let Some(task_name) = &self.selected_task_name { + self.entries + .iter() + .position(|entry| entry.as_ref() == Some(task_name)) + } else { + None + } + } + + pub fn get_selected_index_in_current_page(&self) -> Option { + if let Some(task_name) = &self.selected_task_name { + let current_page_entries = self.get_current_page_entries(); + current_page_entries + .iter() + .position(|entry| entry.as_ref() == Some(task_name)) + } else { + None + } + } + + pub fn set_items_per_page(&mut self, items_per_page: usize) { + // Ensure we never set items_per_page to 0 + self.items_per_page = items_per_page.max(1); + self.validate_current_page(); + self.validate_selection_for_current_page(); + } + + pub fn get_items_per_page(&self) -> usize { + self.items_per_page + } +} + +impl Default for TaskSelectionManager { + fn default() -> Self { + Self::new(5) // Default to 5 items per page + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_manager() { + let manager = TaskSelectionManager::new(5); + assert_eq!(manager.get_selected_task_name(), None); + assert_eq!(manager.get_current_page(), 0); + assert_eq!(manager.get_selection_mode(), SelectionMode::TrackByName); + } + + #[test] + fn test_update_entries_track_by_name() { + let mut manager = TaskSelectionManager::new(2); + manager.set_selection_mode(SelectionMode::TrackByName); + + // Initial entries + let entries = vec![Some("Task 1".to_string()), None, Some("Task 2".to_string())]; + manager.update_entries(entries); + assert_eq!( + manager.get_selected_task_name(), + Some(&"Task 1".to_string()) + ); + + // Update entries with same tasks but different order + let entries = vec![Some("Task 2".to_string()), None, Some("Task 1".to_string())]; + manager.update_entries(entries); + + // Selection should still be Task 1 despite order change + assert_eq!( + manager.get_selected_task_name(), + Some(&"Task 1".to_string()) + ); + } + + #[test] + fn test_update_entries_track_by_position() { + let mut manager = TaskSelectionManager::new(2); + manager.set_selection_mode(SelectionMode::TrackByPosition); + + // Initial entries + let entries = vec![Some("Task 1".to_string()), None, Some("Task 2".to_string())]; + manager.update_entries(entries); + assert_eq!( + manager.get_selected_task_name(), + Some(&"Task 1".to_string()) + ); + + // Update entries with different tasks but same structure + let entries = vec![Some("Task 3".to_string()), None, Some("Task 4".to_string())]; + manager.update_entries(entries); + + // Selection should be Task 3 (same position as Task 1 was) + assert_eq!( + manager.get_selected_task_name(), + Some(&"Task 3".to_string()) + ); + } + + #[test] + fn test_select() { + let mut manager = TaskSelectionManager::new(2); + let entries = vec![Some("Task 1".to_string()), None, Some("Task 2".to_string())]; + manager.update_entries(entries); + manager.select(Some("Task 2".to_string())); + assert_eq!( + manager.get_selected_task_name(), + Some(&"Task 2".to_string()) + ); + assert_eq!(manager.get_current_page(), 1); // Should move to page containing Task 2 + } + + #[test] + fn test_navigation() { + let mut manager = TaskSelectionManager::new(2); + let entries = vec![ + Some("Task 1".to_string()), + None, + Some("Task 2".to_string()), + Some("Task 3".to_string()), + ]; + manager.update_entries(entries); + + // Test next + assert_eq!( + manager.get_selected_task_name(), + Some(&"Task 1".to_string()) + ); + manager.next(); + assert_eq!( + manager.get_selected_task_name(), + Some(&"Task 2".to_string()) + ); + + // Test previous + manager.previous(); + assert_eq!( + manager.get_selected_task_name(), + Some(&"Task 1".to_string()) + ); + } + + #[test] + fn test_pagination() { + let mut manager = TaskSelectionManager::new(2); + let entries = vec![ + Some("Task 1".to_string()), + Some("Task 2".to_string()), + Some("Task 3".to_string()), + Some("Task 4".to_string()), + ]; + manager.update_entries(entries); + + assert_eq!(manager.total_pages(), 2); + assert_eq!(manager.get_current_page(), 0); + + // Test next page + manager.next_page(); + assert_eq!(manager.get_current_page(), 1); + let page_entries = manager.get_current_page_entries(); + assert_eq!(page_entries.len(), 2); + assert_eq!(page_entries[0], Some("Task 3".to_string())); + + // Test previous page + manager.previous_page(); + assert_eq!(manager.get_current_page(), 0); + let page_entries = manager.get_current_page_entries(); + assert_eq!(page_entries[0], Some("Task 1".to_string())); + } + + #[test] + fn test_is_selected() { + let mut manager = TaskSelectionManager::new(2); + let entries = vec![Some("Task 1".to_string()), Some("Task 2".to_string())]; + manager.update_entries(entries); + + assert!(manager.is_selected("Task 1")); + assert!(!manager.is_selected("Task 2")); + } + + #[test] + fn test_handle_position_tracking_empty_entries() { + let mut manager = TaskSelectionManager::new(2); + manager.set_selection_mode(SelectionMode::TrackByPosition); + + // Initial entries + let entries = vec![Some("Task 1".to_string()), Some("Task 2".to_string())]; + manager.update_entries(entries); + assert_eq!( + manager.get_selected_task_name(), + Some(&"Task 1".to_string()) + ); + + // Update with empty entries + let entries: Vec> = vec![]; + manager.update_entries(entries); + + // No entries, so no selection + assert_eq!(manager.get_selected_task_name(), None); + } +} diff --git a/packages/nx/src/native/tui/components/tasks_list.rs b/packages/nx/src/native/tui/components/tasks_list.rs new file mode 100644 index 0000000000..270163a338 --- /dev/null +++ b/packages/nx/src/native/tui/components/tasks_list.rs @@ -0,0 +1,2262 @@ +use color_eyre::eyre::Result; +use crossterm::event::KeyEvent; +use napi::bindgen_prelude::External; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table}, + Frame, +}; +use std::collections::HashMap; +use std::io::Write; +use std::sync::{Arc, Mutex, RwLock}; +use std::{any::Any, io}; +use vt100_ctt::Parser; + +use crate::native::tui::utils::{is_cache_hit, normalize_newlines, sort_task_items}; +use crate::native::tui::{ + action::Action, app::Focus, components::Component, pty::PtyInstance, utils, +}; +use crate::native::{ + pseudo_terminal::pseudo_terminal::{ParserArc, WriterArc}, + tasks::types::{Task, TaskResult}, +}; + +use super::pagination::Pagination; +use super::task_selection_manager::{SelectionMode, TaskSelectionManager}; +use super::terminal_pane::{TerminalPane, TerminalPaneData}; +use super::{help_text::HelpText, terminal_pane::TerminalPaneState}; + +const CACHE_STATUS_LOCAL_KEPT_EXISTING: &str = "Kept Existing"; +const CACHE_STATUS_LOCAL: &str = "Local"; +const CACHE_STATUS_REMOTE: &str = "Remote"; +const CACHE_STATUS_NOT_YET_KNOWN: &str = "..."; +const CACHE_STATUS_NOT_APPLICABLE: &str = "-"; +const DURATION_NOT_YET_KNOWN: &str = "..."; + +// This is just a fallback value, the real value will be set via start_command on the lifecycle +const DEFAULT_MAX_PARALLEL: usize = 3; + +/// Represents an individual task with its current state and execution details. +pub struct TaskItem { + // Public to aid with sorting utility and testing + pub name: String, + duration: String, + cache_status: String, + // Public to aid with sorting utility and testing + pub status: TaskStatus, + terminal_output: String, + continuous: bool, + start_time: Option, + // Public to aid with sorting utility and testing + pub end_time: Option, +} + +impl Clone for TaskItem { + fn clone(&self) -> Self { + Self { + name: self.name.clone(), + duration: self.duration.clone(), + cache_status: self.cache_status.clone(), + status: self.status.clone(), + continuous: self.continuous, + terminal_output: self.terminal_output.clone(), + start_time: self.start_time, + end_time: self.end_time, + } + } +} + +impl TaskItem { + pub fn new(name: String, continuous: bool) -> Self { + Self { + name, + duration: if continuous { + "Continuous".to_string() + } else { + "...".to_string() + }, + cache_status: if continuous { + // We know upfront that the cache status will not be applicable + CACHE_STATUS_NOT_APPLICABLE.to_string() + } else { + CACHE_STATUS_NOT_YET_KNOWN.to_string() + }, + status: TaskStatus::NotStarted, + continuous, + terminal_output: String::new(), + start_time: None, + end_time: None, + } + } + + pub fn update_status(&mut self, status: TaskStatus) { + self.status = status; + // Update the cache_status label that gets printed in the UI + if self.continuous { + self.cache_status = CACHE_STATUS_NOT_APPLICABLE.to_string(); + } else { + self.cache_status = match status { + TaskStatus::InProgress => CACHE_STATUS_NOT_YET_KNOWN.to_string(), + TaskStatus::LocalCacheKeptExisting => CACHE_STATUS_LOCAL_KEPT_EXISTING.to_string(), + TaskStatus::LocalCache => CACHE_STATUS_LOCAL.to_string(), + TaskStatus::RemoteCache => CACHE_STATUS_REMOTE.to_string(), + _ => CACHE_STATUS_NOT_APPLICABLE.to_string(), + } + } + } +} + +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum TaskStatus { + // Explicit statuses that can come from the task runner + Success, + Failure, + Skipped, + LocalCacheKeptExisting, + LocalCache, + RemoteCache, + // Internal-only statuses for UI state management + // These will never come from the task runner - they are managed by our Rust code + NotStarted, + InProgress, +} + +impl std::str::FromStr for TaskStatus { + type Err = String; + + fn from_str(s: &str) -> Result { + // We only parse external statuses that can come from the task runner + // Internal statuses (NotStarted, InProgress) are managed by our Rust code + match s.to_lowercase().as_str() { + "success" => Ok(Self::Success), + "failure" => Ok(Self::Failure), + "skipped" => Ok(Self::Skipped), + "local-cache-kept-existing" => Ok(Self::LocalCacheKeptExisting), + "local-cache" => Ok(Self::LocalCache), + "remote-cache" => Ok(Self::RemoteCache), + // We don't accept InProgress or NotStarted from external sources + "in-progress" | "in_progress" | "running" | "not-started" | "not_started" + | "pending" => Err(format!( + "Status '{}' cannot be set externally - it is managed internally by the UI", + s + )), + _ => Err(format!("Unknown task status: {}", s)), + } + } +} + +/// A list component that displays and manages tasks in a terminal UI. +/// Provides filtering, sorting, and output display capabilities. +pub struct TasksList { + // task id -> pty instance + pub pty_instances: HashMap>, + selection_manager: TaskSelectionManager, + tasks: Vec, // Source of truth - all tasks + filtered_names: Vec, // Names of tasks that match the filter + throbber_counter: usize, + pub filter_mode: bool, + filter_text: String, + filter_persisted: bool, // Whether the filter is in a persisted state + focus: Focus, + pane_tasks: [Option; 2], // Tasks assigned to panes 1 and 2 (0-indexed) + focused_pane: Option, // Currently focused pane (if any) + is_dimmed: bool, + spacebar_mode: bool, // Whether we're in spacebar mode (output follows selection) + terminal_pane_data: [TerminalPaneData; 2], + task_list_hidden: bool, + cloud_message: Option, + max_parallel: usize, // Maximum number of parallel tasks + title_text: String, + resize_debounce_timer: Option, // Timer for debouncing resize events + pending_resize: Option<(u16, u16)>, // Pending resize dimensions +} + +impl TasksList { + /// Creates a new TasksList with the given tasks. + /// Converts the input tasks into TaskItems and initializes the UI state. + pub fn new(tasks: Vec, pinned_tasks: Vec, title_text: String) -> Self { + let mut task_items = Vec::new(); + + for task in tasks { + task_items.push(TaskItem::new(task.id, task.continuous.unwrap_or(false))); + } + + let filtered_names = Vec::new(); + let mut selection_manager = TaskSelectionManager::new(5); // Default 5 items per page + + let mut focus = Focus::TaskList; + let mut focused_pane = None; + + let main_terminal_pane_data = TerminalPaneData::new(); + if let Some(main_task) = pinned_tasks.first() { + selection_manager.select_task(main_task.clone()); + // Auto-focus the main task + focus = Focus::MultipleOutput(0); + focused_pane = Some(0); + } + + let mut iter = pinned_tasks.into_iter().take(2); + let pane_tasks = [iter.next(), iter.next()]; + + Self { + pty_instances: HashMap::new(), + selection_manager, + filtered_names, + tasks: task_items, + throbber_counter: 0, + filter_mode: false, + filter_text: String::new(), + filter_persisted: false, + focus, + pane_tasks, + focused_pane, + is_dimmed: false, + spacebar_mode: false, + terminal_pane_data: [main_terminal_pane_data, TerminalPaneData::new()], + task_list_hidden: false, + cloud_message: None, + max_parallel: DEFAULT_MAX_PARALLEL, + title_text, + resize_debounce_timer: None, + pending_resize: None, + } + } + + pub fn set_max_parallel(&mut self, max_parallel: Option) { + self.max_parallel = max_parallel.unwrap_or(DEFAULT_MAX_PARALLEL as u32) as usize; + } + + /// Moves the selection to the next task in the list. + /// If in spacebar mode, updates the output pane to show the newly selected task. + pub fn next(&mut self) { + self.selection_manager.next(); + self.update_pane_visibility_after_selection_change(); + } + + /// Moves the selection to the previous task in the list. + /// If in spacebar mode, updates the output pane to show the newly selected task. + pub fn previous(&mut self) { + self.selection_manager.previous(); + self.update_pane_visibility_after_selection_change(); + } + + /// Moves to the next page of tasks. + /// Does nothing if there are no filtered tasks. + pub fn next_page(&mut self) { + if self.filtered_names.is_empty() { + return; + } + self.selection_manager.next_page(); + self.update_pane_visibility_after_selection_change(); + } + + /// Moves to the previous page of tasks. + /// Does nothing if there are no filtered tasks. + pub fn previous_page(&mut self) { + if self.filtered_names.is_empty() { + return; + } + self.selection_manager.previous_page(); + self.update_pane_visibility_after_selection_change(); + } + + /// Updates the output pane visibility after a task selection change. + /// Only affects the display if in spacebar mode. + fn update_pane_visibility_after_selection_change(&mut self) { + // Only update pane visibility if we're in spacebar mode + if self.spacebar_mode { + if let Some(task_name) = self.selection_manager.get_selected_task_name() { + self.pane_tasks[0] = Some(task_name.clone()); + // Re-evaluate the optimal size of the terminal pane and pty because the newly selected task may never have been shown before + let _ = self.handle_resize(None); + } + } + } + + /// Creates a list of task entries with separators between different status groups. + /// Groups tasks into in-progress, completed, and pending, with None values as separators. + /// NEEDS ANALYSIS: Consider if this complex grouping logic should be moved to a dedicated type. + fn create_entries_with_separator(&self, filtered_names: &[String]) -> Vec> { + // Create vectors for each status group + let mut in_progress = Vec::new(); + let mut completed = Vec::new(); + let mut pending = Vec::new(); + + // Single iteration to categorize tasks + for task_name in filtered_names { + if let Some(task) = self.tasks.iter().find(|t| &t.name == task_name) { + match task.status { + TaskStatus::InProgress => in_progress.push(task_name.clone()), + TaskStatus::NotStarted => pending.push(task_name.clone()), + _ => completed.push(task_name.clone()), + } + } + } + + let mut entries = Vec::new(); + + // Check if there are any tasks that need to be run + let has_tasks_to_run = !in_progress.is_empty() || !pending.is_empty(); + + // Only show the parallel section if there are tasks in progress or pending + if has_tasks_to_run { + // Create a fixed section for in-progress tasks (self.max_parallel slots) + // Add actual in-progress tasks + entries.extend(in_progress.iter().map(|name| Some(name.clone()))); + + // Fill remaining slots with None up to self.max_parallel + let in_progress_count = in_progress.len(); + if in_progress_count < self.max_parallel { + // When we have fewer InProgress tasks than self.max_parallel, fill the remaining slots + // with empty placeholder rows to maintain the fixed height + entries.extend(std::iter::repeat(None).take(self.max_parallel - in_progress_count)); + } + + // Always add a separator after the parallel tasks section with a bottom cap + // This will be marked for special styling with the bottom box corner + entries.push(None); + } + + // Add completed tasks + entries.extend(completed.iter().map(|name| Some(name.clone()))); + + // Add separator before pending tasks if there are any pending tasks and completed tasks exist + if !pending.is_empty() && !completed.is_empty() { + entries.push(None); + } + + // Add pending tasks + entries.extend(pending.into_iter().map(Some)); + + entries + } + + // Add a helper method to safely check if we should show the parallel section + fn should_show_parallel_section(&self) -> bool { + // Only show the parallel section if we're on the first page and have tasks in progress or pending + let is_first_page = self.selection_manager.get_current_page() == 0; + let has_active_tasks = self + .tasks + .iter() + .any(|t| matches!(t.status, TaskStatus::InProgress | TaskStatus::NotStarted)); + + is_first_page && has_active_tasks && !self.is_loading_state() + } + + // Add a helper method to check if we're in the initial loading state + fn is_loading_state(&self) -> bool { + // We're in loading state if all tasks are NotStarted and there are no InProgress tasks + !self.tasks.is_empty() + && self + .tasks + .iter() + .all(|t| matches!(t.status, TaskStatus::NotStarted)) + && !self + .tasks + .iter() + .any(|t| matches!(t.status, TaskStatus::InProgress)) + } + + /// Recalculates the number of items that can be displayed per page based on the available height. + /// Updates the selection manager with the new page size and current entries. + fn recalculate_pages(&mut self, available_height: u16) { + // Update selection manager's items per page + self.selection_manager + .set_items_per_page(available_height as usize); + + // Update entries in selection manager with separator + let entries = self.create_entries_with_separator(&self.filtered_names); + self.selection_manager.update_entries(entries); + } + + /// Enters filter mode for task filtering. + /// If there is existing filter text that isn't persisted, persists it instead. + pub fn enter_filter_mode(&mut self) { + if !self.filter_text.is_empty() && !self.filter_persisted { + // If we have filter text and it's not persisted, pressing / should persist it + self.filter_persisted = true; + self.filter_mode = false; + } else { + // Otherwise enter normal filter mode + self.filter_persisted = false; + self.filter_mode = true; + } + } + + /// Exits filter mode and clears the persisted state. + pub fn exit_filter_mode(&mut self) { + self.filter_mode = false; + self.filter_persisted = false; + } + + /// Clears the current filter and resets filter-related state. + pub fn clear_filter(&mut self) { + self.filter_mode = false; + self.filter_persisted = false; + self.filter_text.clear(); + self.apply_filter(); + } + + /// Adds a character to the filter text if not in persisted mode. + /// Special handling for '/' character which can trigger filter persistence. + pub fn add_filter_char(&mut self, c: char) { + // Never add '/' character to filter text + if c == '/' { + if !self.filter_text.is_empty() && !self.filter_persisted { + // If we have filter text and it's not persisted, pressing / should persist it + self.filter_persisted = true; + self.filter_mode = false; + } + return; + } + + // Otherwise, only add the character if we're not in persisted mode + if !self.filter_persisted { + self.filter_text.push(c); + self.apply_filter(); + } + } + + /// Removes the last character from the filter text. + pub fn remove_filter_char(&mut self) { + self.filter_text.pop(); + self.apply_filter(); + } + + /// Applies the current filter text to the task list. + /// Updates filtered tasks and selection manager entries. + /// NEEDS ANALYSIS: Consider splitting the filter logic from the UI update logic. + pub fn apply_filter(&mut self) { + // Set the appropriate selection mode based on our current state + let should_track_by_name = self.spacebar_mode || self.has_visible_panes(); + let mode = if should_track_by_name { + SelectionMode::TrackByName + } else { + SelectionMode::TrackByPosition + }; + self.selection_manager.set_selection_mode(mode); + + // Apply filter + if self.filter_text.is_empty() { + self.filtered_names = self.tasks.iter().map(|t| t.name.clone()).collect(); + } else { + self.filtered_names = self + .tasks + .iter() + .filter(|item| { + item.name + .to_lowercase() + .contains(&self.filter_text.to_lowercase()) + }) + .map(|t| t.name.clone()) + .collect(); + } + + // Update entries in selection manager with separator + let entries = self.create_entries_with_separator(&self.filtered_names); + self.selection_manager.update_entries(entries); + + // Update spacebar mode output if active + if self.focused_pane.is_none() && self.pane_tasks[0].is_some() { + if self.filtered_names.is_empty() { + self.pane_tasks[0] = None; + } else if let Some(task_name) = self.selection_manager.get_selected_task_name() { + self.pane_tasks[0] = Some(task_name.clone()); + } + } + } + + pub fn set_focus(&mut self, focus: Focus) { + self.focus = focus; + // Clear multi-output focus when returning to task list + if matches!(focus, Focus::TaskList) { + self.focused_pane = None; + } + } + + /// Toggles the visibility of the output pane for the currently selected task. + /// In spacebar mode, the output follows the task selection. + pub fn toggle_output_visibility(&mut self) { + // Ensure task list is visible after every spacebar interaction + self.task_list_hidden = false; + + if let Some(task_name) = self.selection_manager.get_selected_task_name() { + if self.has_visible_panes() { + // Always clear all panes when toggling with spacebar + self.clear_all_panes(); + self.spacebar_mode = false; + // Update selection mode to position-based + self.selection_manager + .set_selection_mode(SelectionMode::TrackByPosition); + } else { + // Show current task in pane 1 in spacebar mode + self.pane_tasks = [Some(task_name.clone()), None]; + self.focused_pane = None; + self.spacebar_mode = true; // Enter spacebar mode + + // Update selection mode to name-based when entering spacebar mode + self.selection_manager + .set_selection_mode(SelectionMode::TrackByName); + + // Re-evaluate the optimal size of the terminal pane and pty + let _ = self.handle_resize(None); + } + } + } + + /// Checks if the current view has any visible output panes. + pub fn has_visible_panes(&self) -> bool { + self.pane_tasks.iter().any(|t| t.is_some()) + } + + /// Clears all output panes and resets their associated state. + pub fn clear_all_panes(&mut self) { + self.pane_tasks = [None, None]; + self.focused_pane = None; + self.focus = Focus::TaskList; + self.spacebar_mode = false; + // When all panes are cleared, use position-based selection + self.selection_manager + .set_selection_mode(SelectionMode::TrackByPosition); + } + + pub fn assign_current_task_to_pane(&mut self, pane_idx: usize) { + if let Some(task_name) = self.selection_manager.get_selected_task_name() { + // If we're in spacebar mode and this is pane 0, convert to pinned mode + if self.spacebar_mode && pane_idx == 0 { + self.spacebar_mode = false; + self.focused_pane = Some(0); + // When converting from spacebar to pinned, stay in name-tracking mode + self.selection_manager + .set_selection_mode(SelectionMode::TrackByName); + } else { + // Check if the task is already pinned to the pane + if self.pane_tasks[pane_idx].as_deref() == Some(task_name.as_str()) { + // Unpin the task if it's already pinned + self.pane_tasks[pane_idx] = None; + + // Adjust focused pane if necessary + if !self.has_visible_panes() { + self.focused_pane = None; + self.focus = Focus::TaskList; + self.spacebar_mode = false; + // When all panes are cleared, use position-based selection + self.selection_manager + .set_selection_mode(SelectionMode::TrackByPosition); + } + } else { + // Pin the task to the specified pane + self.pane_tasks[pane_idx] = Some(task_name.clone()); + self.focused_pane = Some(pane_idx); + self.focus = Focus::TaskList; + self.spacebar_mode = false; // Exit spacebar mode when pinning + // When pinning a task, use name-based selection + self.selection_manager + .set_selection_mode(SelectionMode::TrackByName); + } + } + + // Always re-evaluate the optimal size of the terminal pane(s) and pty(s) + let _ = self.handle_resize(None); + } + } + + pub fn focus_next(&mut self) { + let num_panes = self.pane_tasks.iter().filter(|t| t.is_some()).count(); + if num_panes == 0 { + return; // No panes to focus + } + + self.focus = match self.focus { + Focus::TaskList => { + // Move to first visible pane + if let Some(first_pane) = self.pane_tasks.iter().position(|t| t.is_some()) { + Focus::MultipleOutput(first_pane) + } else { + Focus::TaskList + } + } + Focus::MultipleOutput(current_pane) => { + // Find next visible pane or go back to task list + let next_pane = (current_pane + 1..2).find(|&idx| self.pane_tasks[idx].is_some()); + + match next_pane { + Some(pane) => Focus::MultipleOutput(pane), + None => { + // If the task list is hidden, try and go back to the previous pane if there is one, otherwise do nothing + if self.task_list_hidden { + if current_pane > 0 { + Focus::MultipleOutput(current_pane - 1) + } else { + return; + } + } else { + Focus::TaskList + } + } + } + } + Focus::HelpPopup => Focus::TaskList, + Focus::CountdownPopup => Focus::TaskList, + }; + } + + pub fn focus_previous(&mut self) { + let num_panes = self.pane_tasks.iter().filter(|t| t.is_some()).count(); + if num_panes == 0 { + return; // No panes to focus + } + + self.focus = match self.focus { + Focus::TaskList => { + // When on task list, go to the rightmost (highest index) pane + if let Some(last_pane) = (0..2).rev().find(|&idx| self.pane_tasks[idx].is_some()) { + Focus::MultipleOutput(last_pane) + } else { + Focus::TaskList + } + } + Focus::MultipleOutput(current_pane) => { + if current_pane > 0 { + // Try to go to previous pane + if let Some(prev_pane) = (0..current_pane) + .rev() + .find(|&idx| self.pane_tasks[idx].is_some()) + { + Focus::MultipleOutput(prev_pane) + } else if !self.task_list_hidden { + // Go to task list if it's visible + Focus::TaskList + } else { + // If task list is hidden, wrap around to rightmost pane + if let Some(last_pane) = + (0..2).rev().find(|&idx| self.pane_tasks[idx].is_some()) + { + Focus::MultipleOutput(last_pane) + } else { + // Shouldn't happen (would mean no panes) + return; + } + } + } else { + // We're at leftmost pane (index 0) + if !self.task_list_hidden { + // Go to task list if it's visible + Focus::TaskList + } else if num_panes > 1 { + // If task list hidden and multiple panes, wrap to rightmost pane + if let Some(last_pane) = + (1..2).rev().find(|&idx| self.pane_tasks[idx].is_some()) + { + Focus::MultipleOutput(last_pane) + } else { + // Stay on current pane if can't find another one + Focus::MultipleOutput(current_pane) + } + } else { + // Only one pane and task list hidden, nowhere to go + Focus::MultipleOutput(current_pane) + } + } + } + Focus::HelpPopup => Focus::TaskList, + Focus::CountdownPopup => Focus::TaskList, + }; + } + + /// Gets the table style based on the current focus state. + /// Returns a dimmed style when focus is not on the task list. + fn get_table_style(&self) -> Style { + match self.focus { + Focus::MultipleOutput(_) | Focus::HelpPopup | Focus::CountdownPopup => { + Style::default().dim() + } + Focus::TaskList => Style::default(), + } + } + + /// Gets the current focus state of the component. + pub fn get_focus(&self) -> Focus { + self.focus + } + + /// Forward key events to the currently focused pane, if any. + pub fn handle_key_event(&mut self, key: KeyEvent) -> io::Result<()> { + if let Focus::MultipleOutput(pane_idx) = self.focus { + let terminal_pane_data = &mut self.terminal_pane_data[pane_idx]; + terminal_pane_data.handle_key_event(key) + } else { + Ok(()) + } + } + + /// Returns true if the currently focused pane is in interactive mode. + pub fn is_interactive_mode(&self) -> bool { + match self.focus { + Focus::MultipleOutput(pane_idx) => self.terminal_pane_data[pane_idx].is_interactive(), + _ => false, + } + } + + /// Handles window resize events by updating PTY dimensions. + pub fn handle_resize(&mut self, area_size: Option<(u16, u16)>) -> io::Result<()> { + // Store the new dimensions as pending + self.pending_resize = area_size; + + // Get current time in milliseconds + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(); + + // If we have a timer and it's not expired yet, just return + if let Some(timer) = self.resize_debounce_timer { + if now < timer { + return Ok(()); + } + } + + // Set a new timer for 500ms from now + self.resize_debounce_timer = Some(now + 500); + + // Process the resize + self.process_resize() + } + + /// Actually processes the resize event by updating PTY dimensions. + fn process_resize(&mut self) -> io::Result<()> { + let (width, height) = self.pending_resize.unwrap_or( + // Fallback to detecting the overall terminal size + crossterm::terminal::size() + // And ultimately fallback so a sane default + .unwrap_or((80, 24)), + ); + + // Calculate terminal pane area based on task list visibility and number of panes + let total_panes = self.pane_tasks.iter().filter(|t| t.is_some()).count(); + + let terminal_pane_area = if self.task_list_hidden { + // When task list is hidden, use full width for panes + if total_panes == 2 { + Rect::new(0, 0, width / 2, height) // 50:50 split for two panes + } else if total_panes == 1 { + Rect::new(0, 0, width, height) // 100% width for one pane + } else { + Rect::new(0, 0, width, height) + } + } else { + // When task list is visible, allocate space accordingly + if self.pane_tasks[1].is_some() || total_panes == 2 { + Rect::new(0, 0, width / 3, height) // One-third of width for two terminal panes (the other third is for the task list) + } else if total_panes == 1 { + Rect::new(0, 0, (width / 3) * 2, height) // Two-thirds of width for one terminal pane (the other third is for the task list) + } else { + Rect::new(0, 0, width, height) + } + }; + + let mut needs_sort = false; + + // Handle resize for each visible pane's pty + for (_, task_name) in self.pane_tasks.iter().enumerate() { + if let Some(task_name) = task_name { + if let Some(task) = self.tasks.iter_mut().find(|t| t.name == *task_name) { + if let Some(pty) = self.pty_instances.get_mut(&task.name) { + let (pty_height, pty_width) = + TerminalPane::calculate_pty_dimensions(terminal_pane_area); + + // Get current dimensions before resize + let old_rows = if let Some(screen) = pty.clone().get_screen() { + let (rows, _) = screen.size(); + rows + } else { + 0 + }; + let mut pty_clone = pty.as_ref().clone(); + pty_clone.resize(pty_height, pty_width)?; + + // If dimensions changed, mark for sort + if old_rows != pty_height { + needs_sort = true; + } + } + } + } + } + + // Sort tasks if needed after all resizing is complete + if needs_sort { + self.sort_tasks(); + } + + Ok(()) + } + + fn sort_tasks(&mut self) { + // Set the appropriate selection mode based on our current state + let should_track_by_name = self.spacebar_mode || self.has_visible_panes(); + let mode = if should_track_by_name { + SelectionMode::TrackByName + } else { + SelectionMode::TrackByPosition + }; + self.selection_manager.set_selection_mode(mode); + + // Sort the tasks + sort_task_items(&mut self.tasks); + + // Update filtered indices to match new order + self.filtered_names = self.tasks.iter().map(|t| t.name.clone()).collect(); + + if !self.filter_text.is_empty() { + // Apply filter but don't sort again + self.filtered_names = self + .tasks + .iter() + .filter(|item| { + item.name + .to_lowercase() + .contains(&self.filter_text.to_lowercase()) + }) + .map(|t| t.name.clone()) + .collect(); + } + + // Update the entries in the selection manager + let entries = self.create_entries_with_separator(&self.filtered_names); + self.selection_manager.update_entries(entries); + } + + /// Creates header cells for the task list table. + /// Shows either filter input or task status based on current state. + fn get_header_cells(&self, collapsed_mode: bool, narrow_viewport: bool) -> Vec { + let should_dim = matches!( + self.focus, + Focus::MultipleOutput(_) | Focus::HelpPopup | Focus::CountdownPopup + ); + let status_style = if should_dim { + Style::default().fg(Color::DarkGray).dim() + } else { + Style::default().fg(Color::DarkGray) + }; + + // Determine if all tasks are completed and the status color to use + let all_tasks_completed = !self.tasks.is_empty() + && self.tasks.iter().all(|t| { + matches!( + t.status, + TaskStatus::Success + | TaskStatus::Failure + | TaskStatus::Skipped + | TaskStatus::LocalCache + | TaskStatus::LocalCacheKeptExisting + | TaskStatus::RemoteCache + ) + }); + + let header_color = if all_tasks_completed { + let has_failures = self + .tasks + .iter() + .any(|t| matches!(t.status, TaskStatus::Failure)); + if has_failures { + Color::Red + } else { + Color::Green + } + } else { + Color::Cyan + }; + + // Leave first cell empty for the logo + let status_cell = Cell::from("").style(status_style); + + // Completion status text is now shown with the logo in the first cell + // Just provide an empty second cell + let status_text = String::new(); + + if collapsed_mode { + vec![status_cell, Cell::from(status_text)] + } else if narrow_viewport { + vec![ + status_cell, + Cell::from(status_text), + Cell::from(Line::from("Duration").right_aligned()).style( + Style::default() + .fg(header_color) + .add_modifier(Modifier::BOLD), + ), + ] + } else { + vec![ + status_cell, + Cell::from(status_text), + Cell::from(Line::from("Cache").right_aligned()).style( + Style::default() + .fg(header_color) + .add_modifier(Modifier::BOLD), + ), + Cell::from(Line::from("Duration").right_aligned()).style( + Style::default() + .fg(header_color) + .add_modifier(Modifier::BOLD), + ), + ] + } + } + + /// Sets whether the component should be displayed in a dimmed state. + pub fn set_dimmed(&mut self, is_dimmed: bool) { + self.is_dimmed = is_dimmed; + } + + /// Updates their status to InProgress and triggers a sort. + pub fn start_tasks(&mut self, tasks: Vec) { + for task in tasks { + if let Some(task_item) = self.tasks.iter_mut().find(|t| t.name == task.id) { + task_item.update_status(TaskStatus::InProgress); + } + } + self.sort_tasks(); + } + + pub fn end_tasks(&mut self, task_results: Vec) { + for task_result in task_results { + if let Some(task) = self + .tasks + .iter_mut() + .find(|t| t.name == task_result.task.id) + { + let parsed_status = task_result.status.parse().unwrap(); + task.update_status(parsed_status); + + if task_result.task.start_time.is_some() && task_result.task.end_time.is_some() { + task.start_time = Some(task_result.task.start_time.unwrap() as u128); + task.end_time = Some(task_result.task.end_time.unwrap() as u128); + task.duration = utils::format_duration_since( + task_result.task.start_time.unwrap() as u128, + task_result.task.end_time.unwrap() as u128, + ); + } + + // If the task never had a pty, it must mean that it was run outside of the pseudo-terminal. + // We create a new parser and writer for the task and register it and then write the final output to the parser + if !self.pty_instances.contains_key(&task.name) { + let (parser, parser_and_writer) = Self::create_empty_parser_and_noop_writer(); + if let Some(task_result_output) = task_result.terminal_output { + Self::write_output_to_parser(parser, task_result_output); + } + let task_name = task.name.clone(); + self.create_and_register_pty_instance(&task_name, parser_and_writer); + } + } + } + self.sort_tasks(); + // Re-evaluate the optimal size of the terminal pane and pty because the newly available task outputs might already be being displayed + let _ = self.handle_resize(None); + } + + pub fn update_task_status(&mut self, task_id: String, status: TaskStatus) { + if let Some(task) = self.tasks.iter_mut().find(|t| t.name == task_id) { + task.update_status(status); + self.sort_tasks(); + } + } + + /// Toggles the visibility of the task list panel + pub fn toggle_task_list(&mut self) { + // Only allow hiding if at least one pane is visible, otherwise the screen will be blank + if self.has_visible_panes() { + self.task_list_hidden = !self.task_list_hidden; + // Move focus to the next output pane + if matches!(self.focus, Focus::TaskList) { + self.focus_next(); + } + } + let _ = self.handle_resize(None); + } + + pub fn set_cloud_message(&mut self, message: Option) { + self.cloud_message = message; + } + + // TODO: move to app level when the focus and pty data handling are moved up + pub fn create_and_register_pty_instance( + &mut self, + task_id: &str, + parser_and_writer: External<(ParserArc, WriterArc)>, + ) { + // Access the contents of the External + let parser_and_writer_clone = parser_and_writer.clone(); + let (parser, writer) = &parser_and_writer_clone; + let pty = Arc::new( + PtyInstance::new(task_id.to_string(), parser.clone(), writer.clone()) + .map_err(|e| napi::Error::from_reason(format!("Failed to create PTY: {}", e))) + .unwrap(), + ); + + self.pty_instances.insert(task_id.to_string(), pty.clone()); + } + + // TODO: move to app level when the focus and pty data handling are moved up + pub fn create_empty_parser_and_noop_writer() -> (ParserArc, External<(ParserArc, WriterArc)>) { + // Use sane defaults for rows, cols and scrollback buffer size. The dimensions will be adjusted dynamically later. + let parser = Arc::new(RwLock::new(Parser::new(24, 80, 10000))); + let writer: Arc>> = + Arc::new(Mutex::new(Box::new(std::io::sink()))); + (parser.clone(), External::new((parser, writer))) + } + + // TODO: move to app level when the focus and pty data handling are moved up + // Writes the given output to the given parser, used for the case where a task is a cache hit, or when it is run outside of the rust pseudo-terminal + pub fn write_output_to_parser(parser: ParserArc, output: String) { + let normalized_output = normalize_newlines(output.as_bytes()); + parser + .write() + .unwrap() + .write_all(&normalized_output) + .unwrap(); + } +} + +impl Component for TasksList { + fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { + let collapsed_mode = self.has_visible_panes(); + + // Calculate the width for the task list + let task_list_width = if self.task_list_hidden { + 0 + } else if collapsed_mode { + area.width / 3 + } else { + area.width + }; + + // Create the main layout chunks + let main_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints(if self.task_list_hidden { + if self.pane_tasks.iter().filter(|t| t.is_some()).count() > 1 { + vec![Constraint::Percentage(50), Constraint::Percentage(50)] + } else { + vec![Constraint::Percentage(100)] + } + } else if collapsed_mode { + vec![ + Constraint::Length(task_list_width), + Constraint::Min(0), // Remaining space for output(s) + ] + } else { + vec![Constraint::Fill(1)] + }) + .split(area); + + // Only draw task list if not hidden + if !self.task_list_hidden { + let task_list_area = main_chunks[0]; + + let has_short_viewport = task_list_area.height < 12; + + // Create layout for title, table and bottom elements + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(if has_short_viewport { + vec![ + Constraint::Fill(1), // Table gets most space + Constraint::Length(2), // Filter display (when active) + Constraint::Length(1), // Empty line between filter and pagination + Constraint::Length(1), // Bottom bar (pagination) + ] + } else if task_list_area.width < 60 { + vec![ + Constraint::Fill(1), // Table gets most space + Constraint::Length(2), // Filter display (when active) + Constraint::Length(1), // Empty line between filter and pagination + Constraint::Length(2), // Bottom bar (2 units for stacked layout) + ] + } else { + vec![ + Constraint::Fill(1), // Table gets most space + Constraint::Length(2), // Filter display (when active) + Constraint::Length(1), // Empty line between filter and pagination + Constraint::Length(1), // Bottom bar + ] + }) + .split(task_list_area); + + let table_area = chunks[0]; + let filter_area = chunks[1]; + + let pagination_area = chunks[3]; // Bottom bar area - now contains the cloud message rendering + + // Reserve space for pagination and borders + self.recalculate_pages(table_area.height.saturating_sub(4)); + + let visible_entries = self.selection_manager.get_current_page_entries(); + let selected_style = Style::default().add_modifier(Modifier::BOLD); + let normal_style = Style::default(); + + // Determine if all tasks are completed + let all_tasks_completed = !self.tasks.is_empty() + && self.tasks.iter().all(|t| { + matches!( + t.status, + TaskStatus::Success + | TaskStatus::Failure + | TaskStatus::Skipped + | TaskStatus::LocalCache + | TaskStatus::LocalCacheKeptExisting + | TaskStatus::RemoteCache + ) + }); + + // Determine the color of the NX logo based on task status + let logo_color = if self.tasks.is_empty() { + // No tasks + Color::Cyan + } else if all_tasks_completed { + // All tasks are completed, check if any failed + let has_failures = self + .tasks + .iter() + .any(|t| matches!(t.status, TaskStatus::Failure)); + if has_failures { + Color::Red + } else { + Color::Green + } + } else { + // Tasks are still running + Color::Cyan + }; + + let narrow_viewport = area.width < 90; + + // Get header cells using the existing method but add NX logo to first cell + let mut header_cells = self.get_header_cells(collapsed_mode, narrow_viewport); + + // Get the style based on whether all tasks are completed + let title_color = if all_tasks_completed { + // Use the logo color for the title text as well + logo_color + } else { + Color::White + }; + + // Apply modifiers based on focus state + let title_style = if self.is_dimmed + || matches!(self.focus, Focus::MultipleOutput(_) | Focus::HelpPopup) + { + // Keep the color but add dim modifier + Style::default() + .fg(title_color) + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::DIM) + } else { + // Normal style with bold + Style::default() + .fg(title_color) + .add_modifier(Modifier::BOLD) + }; + + // Replace the first cell with a new one containing the NX logo and title + if !header_cells.is_empty() { + // Determine if we need to add the vertical line with top corner + let show_parallel = self.should_show_parallel_section(); + let is_first_page = self.selection_manager.get_current_page() == 0; + let running = self + .tasks + .iter() + .filter(|t| matches!(t.status, TaskStatus::InProgress)) + .count(); + + // First cell: Just the NX logo and box corner if needed + let mut first_cell_spans = vec![Span::styled( + " NX ", + title_style.bold().bg(logo_color).fg(Color::Black), + )]; + + // Add box corner if needed + if show_parallel && is_first_page && running > 0 && !self.is_loading_state() { + first_cell_spans.push(Span::raw(" ")); + // Top corner of the box + } + + // Second cell: Put the title text in the task name column + let mut second_cell_spans = vec![]; + + // Add title with appropriate styling + if all_tasks_completed { + // Get the total time if available + if let (Some(first_start), Some(last_end)) = ( + self.tasks.iter().filter_map(|t| t.start_time).min(), + self.tasks.iter().filter_map(|t| t.end_time).max(), + ) { + // Create text with separate spans for completed message and time + let title_segment = format!("Completed {} ", self.title_text); + let time_str = utils::format_duration_since(first_start, last_end); + + second_cell_spans.push(Span::styled(title_segment, title_style)); + second_cell_spans.push(Span::styled( + format!("({})", time_str), + Style::default().dim(), + )); + } else { + second_cell_spans.push(Span::styled( + format!("Completed {}", self.title_text), + title_style, + )); + } + } else { + second_cell_spans.push(Span::styled( + format!("Running {}...", self.title_text), + title_style, + )); + } + + // Update the cells + header_cells[0] = Cell::from(Line::from(first_cell_spans)); + if header_cells.len() > 1 { + header_cells[1] = Cell::from(Line::from(second_cell_spans)); + } + } + + let header = Row::new(header_cells) + .style(normal_style) + .height(1) + .top_margin(1) // Restore margin above header + .bottom_margin(0); // Keep zero margin below header to avoid gaps + + // Create rows including filter summary if needed + let mut all_rows = Vec::new(); + + // Add an empty row right after the header to create visual spacing + // while maintaining the seamless vertical line if we're showing the parallel section + if self.should_show_parallel_section() { + let is_first_page = self.selection_manager.get_current_page() == 0; + + let empty_cells = if collapsed_mode { + vec![ + Cell::from(Line::from(vec![ + // Space for selection indicator + Span::raw(" "), + // Add vertical line for visual continuity, only on first page + if is_first_page { + Span::styled("│", Style::default().fg(Color::Cyan)) + } else { + Span::raw(" ") + }, + Span::raw(" "), + ])), + Cell::from(""), + ] + } else if narrow_viewport { + vec![ + Cell::from(Line::from(vec![ + // Space for selection indicator + Span::raw(" "), + // Add vertical line for visual continuity, only on first page + if is_first_page { + Span::styled("│", Style::default().fg(Color::Cyan)) + } else { + Span::raw(" ") + }, + Span::raw(" "), + ])), + Cell::from(""), + Cell::from(""), + ] + } else { + vec![ + Cell::from(Line::from(vec![ + // Space for selection indicator + Span::raw(" "), + // Add vertical line for visual continuity, only on first page + if is_first_page { + Span::styled("│", Style::default().fg(Color::Cyan)) + } else { + Span::raw(" ") + }, + Span::raw(" "), + ])), + Cell::from(""), + Cell::from(""), + Cell::from(""), + ] + }; + all_rows.push(Row::new(empty_cells).height(1).style(normal_style)); + } else { + // Even when there's no parallel section, add an empty row for consistent spacing + // but don't include any vertical line styling + let empty_cells = if collapsed_mode { + vec![ + Cell::from(" "), // Just spaces for indentation, no vertical line + Cell::from(""), + ] + } else if narrow_viewport { + vec![ + Cell::from(" "), // Just spaces for indentation, no vertical line + Cell::from(""), + Cell::from(""), + ] + } else { + vec![ + Cell::from(" "), // Just spaces for indentation, no vertical line + Cell::from(""), + Cell::from(""), + Cell::from(""), + ] + }; + all_rows.push(Row::new(empty_cells).height(1).style(normal_style)); + } + + // Add task rows + all_rows.extend(visible_entries.iter().enumerate().map(|(row_idx, entry)| { + if let Some(task_name) = entry { + // Find the task in the filtered list + if let Some(task) = self.tasks.iter().find(|t| &t.name == task_name) { + let is_selected = self.selection_manager.is_selected(&task_name); + + // Use the helper method to check if we should show the parallel section + let show_parallel = self.should_show_parallel_section(); + + // Only consider rows for the parallel section if appropriate + let is_in_parallel_section = show_parallel && row_idx < self.max_parallel; + + let status_cell = match task.status { + TaskStatus::Success => Cell::from(Line::from(vec![ + Span::raw(if is_selected { ">" } else { " " }), + Span::raw(" "), + Span::styled("✔", Style::default().fg(Color::Green)), + Span::raw(" "), + ])), + TaskStatus::Failure => Cell::from(Line::from(vec![ + Span::raw(if is_selected { ">" } else { " " }), + Span::raw(" "), + Span::styled("✖", Style::default().fg(Color::Red)), + Span::raw(" "), + ])), + TaskStatus::Skipped => Cell::from(Line::from(vec![ + Span::raw(if is_selected { ">" } else { " " }), + Span::raw(" "), + Span::styled("⏭", Style::default().fg(Color::Yellow)), + Span::raw(" "), + ])), + TaskStatus::LocalCacheKeptExisting => Cell::from(Line::from(vec![ + Span::raw(if is_selected { ">" } else { " " }), + Span::raw(" "), + Span::styled("◼", Style::default().fg(Color::Green)), + Span::raw(" "), + ])), + TaskStatus::LocalCache => Cell::from(Line::from(vec![ + Span::raw(if is_selected { ">" } else { " " }), + Span::raw(" "), + Span::styled("◼", Style::default().fg(Color::Green)), + Span::raw(" "), + ])), + TaskStatus::RemoteCache => Cell::from(Line::from(vec![ + Span::raw(if is_selected { ">" } else { " " }), + Span::raw(" "), + Span::styled("▼", Style::default().fg(Color::Green)), + Span::raw(" "), + ])), + TaskStatus::InProgress => { + let throbber_chars = + ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let throbber_char = + throbber_chars[self.throbber_counter % throbber_chars.len()]; + + let mut spans = + vec![Span::raw(if is_selected { ">" } else { " " })]; + + // Add vertical line for parallel section if needed (always takes 1 character width) + if is_in_parallel_section + && self.selection_manager.get_current_page() == 0 + { + spans.push(Span::styled("│", Style::default().fg(Color::Cyan))); + } else { + spans.push(Span::raw(" ")); + } + + // Add the spinner with consistent spacing + spans.push(Span::styled( + throbber_char.to_string(), + Style::default().fg(Color::LightCyan), + )); + + // Add trailing space to maintain consistent width + spans.push(Span::raw(" ")); + + Cell::from(Line::from(spans)) + } + TaskStatus::NotStarted => Cell::from(Line::from(vec![ + Span::raw(if is_selected { ">" } else { " " }), + // No need for parallel section check for pending tasks + Span::raw(" "), + Span::styled("·", Style::default().fg(Color::DarkGray)), + Span::raw(" "), + ])), + }; + + let name = { + // Show output indicators if the task is pinned to a pane (but not in spacebar mode) + let output_indicators = if !self.spacebar_mode { + self.pane_tasks + .iter() + .enumerate() + .filter_map(|(idx, task)| { + if task.as_deref() == Some(task_name.as_str()) { + Some(format!("[Pinned output {}]", idx + 1)) + } else { + None + } + }) + .collect::>() + .join(" ") + } else { + String::new() + }; + + if !output_indicators.is_empty() { + let line = Line::from(vec![ + Span::raw(task_name), + Span::raw(" "), + Span::styled(output_indicators, Style::default().dim()), + ]); + Cell::from(line) + } else { + Cell::from(task_name.clone()) + } + }; + + let mut row_cells = vec![status_cell, name]; + + if !collapsed_mode { + if narrow_viewport { + // In narrow viewport mode (not collapsed), show only duration column + let duration_cell = Cell::from( + Line::from(match task.duration.as_str() { + "" | "Continuous" | DURATION_NOT_YET_KNOWN => { + vec![Span::styled( + task.duration.clone(), + if is_selected { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default().dim() + }, + )] + } + _ => vec![Span::styled( + task.duration.clone(), + if is_selected { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default() + }, + )], + }) + .right_aligned(), + ); + + row_cells.push(duration_cell); + } else { + // In full width mode, show both cache and duration columns + // Cache status cell + let cache_cell = Cell::from( + Line::from(match task.cache_status.as_str() { + CACHE_STATUS_NOT_YET_KNOWN + | CACHE_STATUS_NOT_APPLICABLE => { + vec![Span::styled( + task.cache_status.clone(), + if is_selected { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default().dim() + }, + )] + } + _ => vec![Span::styled( + task.cache_status.clone(), + if is_selected { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default() + }, + )], + }) + .right_aligned(), + ); + + // Duration cell + let duration_cell = Cell::from( + Line::from(match task.duration.as_str() { + "" | "Continuous" | DURATION_NOT_YET_KNOWN => { + vec![Span::styled( + task.duration.clone(), + if is_selected { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default().dim() + }, + )] + } + _ => vec![Span::styled( + task.duration.clone(), + if is_selected { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default() + }, + )], + }) + .right_aligned(), + ); + + row_cells.push(cache_cell); + row_cells.push(duration_cell); + } + } else { + // No cache/duration cells in collapsed mode + } + + Row::new(row_cells).height(1).style(if is_selected { + selected_style + } else { + normal_style + }) + } else { + // This shouldn't happen, but provide a fallback + Row::new(vec![Cell::from("")]).height(1) + } + } else { + // Handle None entries (separators) + // Check if this is within the parallel section + let show_parallel = self.should_show_parallel_section(); + let is_in_parallel_section = show_parallel && row_idx < self.max_parallel; + + // Check if this is the bottom cap (the separator after the last parallel task) + let is_bottom_cap = show_parallel && row_idx == self.max_parallel; + + if is_in_parallel_section { + // Add a vertical line for separators in the parallel section, only on first page + let is_first_page = self.selection_manager.get_current_page() == 0; + + let empty_cells = if collapsed_mode { + vec![ + Cell::from(Line::from(vec![ + // Space for selection indicator (fixed width of 2) + Span::raw(" "), + // Add space and vertical line for parallel section (fixed position) + if is_first_page { + Span::styled("│", Style::default().fg(Color::Cyan)) + } else { + Span::raw(" ") + }, + Span::styled("· ", Style::default().dim()), + ])), + Cell::from(Span::styled( + "Waiting for task...", + Style::default().dim(), + )), + ] + } else if narrow_viewport { + vec![ + Cell::from(Line::from(vec![ + // Space for selection indicator (fixed width of 2) + Span::raw(" "), + // Add space and vertical line for parallel section (fixed position) + if is_first_page { + Span::styled("│", Style::default().fg(Color::Cyan)) + } else { + Span::raw(" ") + }, + Span::styled("· ", Style::default().dim()), + ])), + Cell::from(Span::styled( + "Waiting for task...", + Style::default().dim(), + )), + Cell::from(""), + ] + } else { + vec![ + Cell::from(Line::from(vec![ + // Space for selection indicator (fixed width of 2) + Span::raw(" "), + // Add space and vertical line for parallel section (fixed position) + if is_first_page { + Span::styled("│", Style::default().fg(Color::Cyan)) + } else { + Span::raw(" ") + }, + Span::styled("· ", Style::default().dim()), + ])), + Cell::from(Span::styled( + "Waiting for task...", + Style::default().dim(), + )), + Cell::from(""), + Cell::from(""), + ] + }; + Row::new(empty_cells).height(1).style(normal_style) + } else if is_bottom_cap { + // Add the bottom corner cap at the end of the parallel section, only on first page + let is_first_page = self.selection_manager.get_current_page() == 0; + + let empty_cells = if collapsed_mode { + vec![ + Cell::from(Line::from(vec![ + // Space for selection indicator (fixed width of 2) + Span::raw(" "), + // Add bottom corner for the box, or just spaces if not on first page + if is_first_page { + Span::styled("└", Style::default().fg(Color::Cyan)) + } else { + Span::raw(" ") + }, + Span::raw(" "), + ])), + Cell::from(""), + ] + } else if narrow_viewport { + vec![ + Cell::from(Line::from(vec![ + // Space for selection indicator (fixed width of 2) + Span::raw(" "), + // Add bottom corner for the box, or just spaces if not on first page + if is_first_page { + Span::styled("└", Style::default().fg(Color::Cyan)) + } else { + Span::raw(" ") + }, + Span::raw(" "), + ])), + Cell::from(""), + Cell::from(""), + ] + } else { + vec![ + Cell::from(Line::from(vec![ + // Space for selection indicator (fixed width of 2) + Span::raw(" "), + // Add bottom corner for the box, or just spaces if not on first page + if is_first_page { + Span::styled("└", Style::default().fg(Color::Cyan)) + } else { + Span::raw(" ") + }, + Span::raw(" "), + ])), + Cell::from(""), + Cell::from(""), + Cell::from(""), + ] + }; + Row::new(empty_cells).height(1).style(normal_style) + } else { + // Regular separator row outside the parallel section + let empty_cells = if collapsed_mode { + vec![Cell::from(""), Cell::from("")] + } else if narrow_viewport { + vec![Cell::from(""), Cell::from(""), Cell::from("")] + } else { + vec![ + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + ] + }; + Row::new(empty_cells).height(1).style(normal_style) + } + } + })); + + let constraints = if collapsed_mode { + vec![Constraint::Length(6), Constraint::Fill(1)] + } else if narrow_viewport { + vec![ + Constraint::Length(6), // Status icon with NX logo + Constraint::Fill(1), // Task name with title + // No cache status for narrow viewports + Constraint::Length(15), // Duration (increased width) + ] + } else { + vec![ + Constraint::Length(6), // Status icon with NX logo + Constraint::Fill(1), // Task name with title + Constraint::Length(30), // Cache status (increased width) + Constraint::Length(15), // Duration (increased width) + ] + }; + + let t = Table::new(all_rows, &constraints) + .header(header) + .block(Block::default()) + .style(self.get_table_style()); + + f.render_widget(t, table_area); + + // After rendering the table, render the filter text if active + if self.filter_mode || !self.filter_text.is_empty() { + let hidden_tasks = self.tasks.len() - self.filtered_names.len(); + + // Render exactly as it was before, just at the bottom + // Add proper indentation to align with task content - 4 spaces matches the task indentation + let filter_text = format!(" Filter: {}", self.filter_text); + + // Determine if filter text should be dimmed based on focus + let should_dim = matches!( + self.focus, + Focus::MultipleOutput(_) | Focus::HelpPopup | Focus::CountdownPopup + ); + + let filter_style = if should_dim { + Style::default().fg(Color::Yellow).dim() + } else { + Style::default().fg(Color::Yellow) + }; + + let instruction_text = if hidden_tasks > 0 { + if self.filter_persisted { + format!( + " -> {} tasks filtered out. Press / to edit, to clear", + hidden_tasks + ) + } else { + format!( + " -> {} tasks filtered out. Press / to persist, to clear", + hidden_tasks + ) + } + } else if self.filter_persisted { + " Press / to edit filter".to_string() + } else { + " Press to clear filter".to_string() + }; + + // Render the full filter information exactly as it was before + let filter_lines = vec![ + Line::from(vec![Span::styled(filter_text, filter_style)]), + Line::from(vec![Span::styled(instruction_text, filter_style)]), + ]; + + let filter_paragraph = Paragraph::new(filter_lines).alignment(Alignment::Left); + + f.render_widget(filter_paragraph, filter_area); + } + + // Render cloud message in its dedicated area if it exists + let needs_vertical_bottom_layout = narrow_viewport || has_short_viewport; + + // Bottom bar layout + let bottom_layout = if needs_vertical_bottom_layout { + // Stack vertically when area is limited + Layout::default() + .direction(Direction::Vertical) + .constraints(vec![ + Constraint::Length(1), // Pagination + Constraint::Length(1), // Help text + ]) + .split(pagination_area) + } else { + // Original horizontal layout - use the full width for a single area + Layout::default() + .direction(Direction::Horizontal) + .constraints(vec![ + Constraint::Fill(1), // Full width for both pagination and help text + ]) + .split(pagination_area) + }; + + // Determine if bottom bar elements should be dimmed + let should_dim = matches!( + self.focus, + Focus::MultipleOutput(_) | Focus::HelpPopup | Focus::CountdownPopup + ); + + // Get pagination info + let total_pages = self.selection_manager.total_pages(); + let current_page = self.selection_manager.get_current_page(); + + // Create combined bottom bar with pagination on left and help text centered + let pagination = Pagination::new(current_page, total_pages); + + // Create help text component + let help_text = HelpText::new( + collapsed_mode || self.cloud_message.is_some(), + should_dim, + false, + ); // Use collapsed mode when cloud message is present + + // Always draw pagination + if needs_vertical_bottom_layout { + // For vertical layout, render pagination and help text separately + let pagination_area = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(2), // Left padding to align with content + Constraint::Min(12), // Space for pagination + Constraint::Fill(1), // Remaining space + ]) + .split(bottom_layout[0])[1]; + + pagination.render(f, pagination_area, should_dim); + + // Only show help text if not dimmed + if !self.is_dimmed { + help_text.render(f, bottom_layout[1]); + } + } else { + // For horizontal layout, create a three-part layout: pagination on left, help text in middle, cloud message on right + let has_cloud_message = self.cloud_message.is_some(); + let bottom_bar_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints(if has_cloud_message { + [ + Constraint::Length(12), // Width for pagination (with padding) + Constraint::Length(24), // Smaller width for help text when cloud message is present + Constraint::Fill(1), // Cloud message gets most of the remaining space + Constraint::Length(2), // Right-side padding for breathing room + ] + } else { + [ + Constraint::Length(15), // Width for pagination (with padding) + Constraint::Fill(1), // Help text gets all remaining space + Constraint::Length(1), // Minimal width when no cloud message + Constraint::Length(0), // No right padding needed when no cloud message + ] + }) + .split(bottom_layout[0]); + + // Render pagination in its area + let pagination_area = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(2), // Left padding to align with task content + Constraint::Length(10), // Width for pagination + Constraint::Fill(1), // Remaining space + ]) + .split(bottom_bar_layout[0])[1]; + + pagination.render(f, pagination_area, should_dim); + + // Only show help text if not dimmed + if !self.is_dimmed { + // Let the help text use its dedicated area + help_text.render(f, bottom_bar_layout[1]); + } + + // Render cloud message if it exists + if let Some(message) = &self.cloud_message { + // Only proceed with cloud message rendering if: + // - We're not in collapsed mode, OR + // - The message contains a URL + let should_show_message = !collapsed_mode || message.contains("https://"); + + if should_show_message { + // Get available width for the cloud message + let available_width = bottom_bar_layout[2].width as usize; + + // Create text with URL styling if needed + let message_line = if let Some(url_pos) = message.find("https://") { + let prefix = &message[0..url_pos]; + let url = &message[url_pos..]; + + // Determine styles based on dimming state + let prefix_style = if should_dim { + Style::default().fg(Color::DarkGray).dim() + } else { + Style::default().fg(Color::DarkGray) + }; + + let url_style = if should_dim { + Style::default().fg(Color::LightCyan).underlined().dim() + } else { + Style::default().fg(Color::LightCyan).underlined() + }; + + // In collapsed mode or with limited width, prioritize showing the URL + if collapsed_mode || available_width < 30 { + // Show only the URL, completely omit prefix if needed + if url.len() > available_width.saturating_sub(3) { + // URL is too long, we need to truncate it + let shortened_url = if url.contains("nx.app") { + // For nx.app links, try to preserve the run ID at the end + let parts: Vec<&str> = url.split('/').collect(); + if parts.len() > 4 { + // Try to show the domain and run ID + format!("{}/../{}", parts[0], parts[parts.len() - 1]) + } else { + // Just truncate + format!( + "{}...", + &url[..available_width + .saturating_sub(3) + .min(url.len())] + ) + } + } else { + // For other URLs, just truncate + format!( + "{}...", + &url[..available_width + .saturating_sub(3) + .min(url.len())] + ) + }; + + Line::from(vec![Span::styled(shortened_url, url_style)]) + } else { + // URL fits, show it all + Line::from(vec![Span::styled(url, url_style)]) + } + } else { + // Normal mode with enough space - try to show prefix and URL + if prefix.len() + url.len() > available_width.saturating_sub(3) { + // Not enough space for both, prioritize URL + let shortened_url = if url.contains("nx.app") { + // For nx.app links, try to preserve the run ID at the end + let parts: Vec<&str> = url.split('/').collect(); + if parts.len() > 4 { + // Try to show the domain and run ID + format!("{}/../{}", parts[0], parts[parts.len() - 1]) + } else { + // Just truncate + format!( + "{}...", + &url[..available_width + .saturating_sub(3) + .min(url.len())] + ) + } + } else { + // For other URLs, just truncate + format!( + "{}...", + &url[..available_width + .saturating_sub(3) + .min(url.len())] + ) + }; + + // If we still have space for a bit of prefix, show it + let remaining_space = + available_width.saturating_sub(shortened_url.len() + 3); + if remaining_space > 5 && !prefix.is_empty() { + let shortened_prefix = if prefix.len() > remaining_space { + format!( + "{}...", + &prefix[..remaining_space.saturating_sub(3)] + ) + } else { + prefix.to_string() + }; + + Line::from(vec![ + Span::styled(shortened_prefix, prefix_style), + Span::styled(shortened_url, url_style), + ]) + } else { + // No space for prefix, just show URL + Line::from(vec![Span::styled(shortened_url, url_style)]) + } + } else { + // Enough space for both prefix and URL + Line::from(vec![ + Span::styled(prefix, prefix_style), + Span::styled(url, url_style), + ]) + } + } + } else { + // Handle non-URL messages (only shown in non-collapsed mode) + let display_message = if message.len() > available_width { + format!("{}...", &message[..available_width.saturating_sub(3)]) + } else { + message.clone() + }; + + let message_style = if should_dim { + Style::default().fg(Color::DarkGray).dim() + } else { + Style::default().fg(Color::DarkGray) + }; + + Line::from(vec![Span::styled(display_message, message_style)]) + }; + + let cloud_message_paragraph = + Paragraph::new(message_line).alignment(Alignment::Right); + f.render_widget(cloud_message_paragraph, bottom_bar_layout[2]); + } + } + } + } + + // Handle output areas + if collapsed_mode || self.task_list_hidden { + let output_area = if self.task_list_hidden { + // Use the full area when task list is hidden + area + } else { + main_chunks[1] + }; + + let num_active_panes = self.pane_tasks.iter().filter(|t| t.is_some()).count(); + + match num_active_panes { + 0 => (), // No panes to render + 1 => { + if self.pane_tasks[1].is_some() { + let output_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .spacing(2) + .split(output_area); + + // Render placeholder for pane 1 + let placeholder = Paragraph::new("Press 1 on a task to show it here") + .block( + Block::default() + .title(" Output 1 ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)), + ) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center); + + f.render_widget(placeholder, output_chunks[0]); + + // Get task data before rendering + if let Some(task_name) = &self.pane_tasks[1] { + if let Some(task) = self.tasks.iter_mut().find(|t| t.name == *task_name) + { + let mut terminal_pane_data = &mut self.terminal_pane_data[1]; + terminal_pane_data.is_continuous = task.continuous; + terminal_pane_data.is_cache_hit = is_cache_hit(task.status); + + let mut has_pty = false; + if let Some(pty) = self.pty_instances.get(task_name) { + terminal_pane_data.pty = Some(pty.clone()); + has_pty = true; + } + + let is_focused = match self.focus { + Focus::MultipleOutput(focused_pane_idx) => { + 1 == focused_pane_idx + } + _ => false, + }; + + let mut state = TerminalPaneState::new( + task.name.clone(), + task.status, + task.continuous, + is_focused, + has_pty, + ); + + let terminal_pane = TerminalPane::new() + .pty_data(&mut terminal_pane_data) + .continuous(task.continuous); + + f.render_stateful_widget( + terminal_pane, + output_chunks[1], + &mut state, + ); + } + } + } else if let Some((pane_idx, Some(task_name))) = self + .pane_tasks + .iter() + .enumerate() + .find(|(_, t)| t.is_some()) + { + if let Some(task) = self.tasks.iter_mut().find(|t| t.name == *task_name) { + let mut terminal_pane_data = &mut self.terminal_pane_data[pane_idx]; + terminal_pane_data.is_continuous = task.continuous; + terminal_pane_data.is_cache_hit = is_cache_hit(task.status); + + let mut has_pty = false; + if let Some(pty) = self.pty_instances.get(task_name) { + terminal_pane_data.pty = Some(pty.clone()); + has_pty = true; + } + + let is_focused = match self.focus { + Focus::MultipleOutput(focused_pane_idx) => 0 == focused_pane_idx, + _ => false, + }; + + let mut state = TerminalPaneState::new( + task.name.clone(), + task.status, + task.continuous, + is_focused, + has_pty, + ); + + let terminal_pane = TerminalPane::new() + .pty_data(&mut terminal_pane_data) + .continuous(task.continuous); + + f.render_stateful_widget(terminal_pane, output_area, &mut state); + } + } + } + _ => { + let output_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .spacing(2) + .split(output_area); + + for (pane_idx, chunk) in output_chunks.iter().enumerate() { + if let Some(task_name) = &self.pane_tasks[pane_idx] { + if let Some(task) = self.tasks.iter_mut().find(|t| t.name == *task_name) + { + let mut terminal_pane_data = &mut self.terminal_pane_data[pane_idx]; + terminal_pane_data.is_continuous = task.continuous; + terminal_pane_data.is_cache_hit = is_cache_hit(task.status); + + let mut has_pty = false; + if let Some(pty) = self.pty_instances.get(task_name) { + terminal_pane_data.pty = Some(pty.clone()); + has_pty = true; + } + + let is_focused = match self.focus { + Focus::MultipleOutput(focused_pane_idx) => { + pane_idx == focused_pane_idx + } + _ => false, + }; + + let mut state = TerminalPaneState::new( + task.name.clone(), + task.status, + task.continuous, + is_focused, + has_pty, + ); + + let terminal_pane = TerminalPane::new() + .pty_data(&mut terminal_pane_data) + .continuous(task.continuous); + + f.render_stateful_widget(terminal_pane, *chunk, &mut state); + } + } else { + let placeholder = + Paragraph::new("Press 1 or 2 on a task to show it here") + .block( + Block::default() + .title(format!("Output {}", pane_idx + 1)) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)), + ) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center); + + f.render_widget(placeholder, *chunk); + } + } + } + } + } + + Ok(()) + } + + /// Updates the component state in response to an action. + /// Returns an optional follow-up action. + fn update(&mut self, action: Action) -> Result> { + match action { + Action::Tick => { + self.throbber_counter = self.throbber_counter.wrapping_add(1); + + // Check if we have a pending resize that needs to be processed + if let Some(timer) = self.resize_debounce_timer { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(); + + if now >= timer { + // Timer expired, process the resize + if self.pending_resize.is_some() { + self.process_resize()?; + } + self.resize_debounce_timer = None; + self.pending_resize = None; + } + } + } + Action::Resize(w, h) => { + self.handle_resize(Some((w, h)))?; + } + Action::EnterFilterMode => { + if self.filter_mode { + self.exit_filter_mode(); + } else { + self.enter_filter_mode(); + } + } + Action::ClearFilter => { + self.clear_filter(); + } + Action::AddFilterChar(c) => { + if self.filter_mode { + self.add_filter_char(c); + } + } + Action::RemoveFilterChar => { + if self.filter_mode { + self.remove_filter_char(); + } + } + _ => {} + } + Ok(None) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +impl Default for TasksList { + fn default() -> Self { + Self { + pty_instances: HashMap::new(), + selection_manager: TaskSelectionManager::default(), + tasks: Vec::new(), + filtered_names: Vec::new(), + throbber_counter: 0, + filter_mode: false, + filter_text: String::new(), + filter_persisted: false, + focus: Focus::TaskList, + pane_tasks: [None, None], + focused_pane: None, + is_dimmed: false, + spacebar_mode: false, + terminal_pane_data: [TerminalPaneData::default(), TerminalPaneData::default()], + task_list_hidden: false, + cloud_message: None, + max_parallel: DEFAULT_MAX_PARALLEL, + title_text: String::new(), + resize_debounce_timer: None, + pending_resize: None, + } + } +} diff --git a/packages/nx/src/native/tui/components/terminal_pane.rs b/packages/nx/src/native/tui/components/terminal_pane.rs new file mode 100644 index 0000000000..c94a742c5e --- /dev/null +++ b/packages/nx/src/native/tui/components/terminal_pane.rs @@ -0,0 +1,493 @@ +use arboard::Clipboard; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{ + Block, BorderType, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation, + ScrollbarState, StatefulWidget, Widget, + }, +}; +use std::{io, sync::Arc}; +use tui_term::widget::PseudoTerminal; + +use crate::native::tui::pty::PtyInstance; + +use super::tasks_list::TaskStatus; + +pub struct TerminalPaneData { + pub pty: Option>, + pub is_interactive: bool, + pub is_continuous: bool, + pub is_cache_hit: bool, +} + +impl TerminalPaneData { + pub fn new() -> Self { + Self { + pty: None, + is_interactive: false, + is_continuous: false, + is_cache_hit: false, + } + } + + pub fn handle_key_event(&mut self, key: KeyEvent) -> io::Result<()> { + if let Some(pty) = &mut self.pty { + let mut pty_mut = pty.as_ref().clone(); + match key.code { + // Handle arrow key based scrolling regardless of interactive mode + KeyCode::Up => { + pty_mut.scroll_up(); + return Ok(()); + } + KeyCode::Down => { + pty_mut.scroll_down(); + return Ok(()); + } + // Handle j/k for scrolling when not in interactive mode + KeyCode::Char('k') | KeyCode::Char('j') if !self.is_interactive => { + match key.code { + KeyCode::Char('k') => pty_mut.scroll_up(), + KeyCode::Char('j') => pty_mut.scroll_down(), + _ => {} + } + return Ok(()); + } + // Handle ctrl+u and ctrl+d for scrolling when not in interactive mode + KeyCode::Char('u') + if key.modifiers.contains(KeyModifiers::CONTROL) && !self.is_interactive => + { + // Scroll up a somewhat arbitrary "chunk" (12 lines) + for _ in 0..12 { + pty_mut.scroll_up(); + } + return Ok(()); + } + KeyCode::Char('d') + if key.modifiers.contains(KeyModifiers::CONTROL) && !self.is_interactive => + { + // Scroll down a somewhat arbitrary "chunk" (12 lines) + for _ in 0..12 { + pty_mut.scroll_down(); + } + return Ok(()); + } + // Handle 'c' for copying when not in interactive mode + KeyCode::Char('c') if !self.is_interactive => { + if let Some(screen) = pty.get_screen() { + // Unformatted output (no ANSI escape codes) + let output = screen.all_contents(); + match Clipboard::new() { + Ok(mut clipboard) => { + clipboard.set_text(output).ok(); + } + Err(_) => { + // TODO: Is there a way to handle this error? Maybe a new kind of error popup? + } + } + } + return Ok(()); + } + // Handle 'i' to enter interactive mode for non cache hit tasks + KeyCode::Char('i') if !self.is_cache_hit && !self.is_interactive => { + self.set_interactive(true); + return Ok(()); + } + // Handle Ctrl+Z to exit interactive mode + KeyCode::Char('z') + if key.modifiers == KeyModifiers::CONTROL + && !self.is_cache_hit + && self.is_interactive => + { + self.set_interactive(false); + return Ok(()); + } + // Only send input to PTY if we're in interactive mode + _ if self.is_interactive => match key.code { + KeyCode::Char(c) => { + pty_mut.write_input(c.to_string().as_bytes())?; + } + KeyCode::Enter => { + pty_mut.write_input(b"\r")?; + } + KeyCode::Esc => { + pty_mut.write_input(&[0x1b])?; + } + KeyCode::Backspace => { + pty_mut.write_input(&[0x7f])?; + } + _ => {} + }, + _ => {} + } + } + Ok(()) + } + + pub fn set_interactive(&mut self, interactive: bool) { + self.is_interactive = interactive; + } + + pub fn is_interactive(&self) -> bool { + self.is_interactive + } +} + +impl Default for TerminalPaneData { + fn default() -> Self { + Self::new() + } +} + +pub struct TerminalPaneState { + pub task_name: String, + pub task_status: TaskStatus, + pub is_continuous: bool, + pub is_focused: bool, + pub scroll_offset: usize, + pub scrollbar_state: ScrollbarState, + pub has_pty: bool, +} + +impl TerminalPaneState { + pub fn new( + task_name: String, + task_status: TaskStatus, + is_continuous: bool, + is_focused: bool, + has_pty: bool, + ) -> Self { + Self { + task_name, + task_status, + is_continuous, + is_focused, + scroll_offset: 0, + scrollbar_state: ScrollbarState::default(), + has_pty, + } + } +} + +pub struct TerminalPane<'a> { + pty_data: Option<&'a mut TerminalPaneData>, + is_continuous: bool, +} + +impl<'a> TerminalPane<'a> { + pub fn new() -> Self { + Self { + pty_data: None, + is_continuous: false, + } + } + + pub fn pty_data(mut self, data: &'a mut TerminalPaneData) -> Self { + self.pty_data = Some(data); + self + } + + pub fn continuous(mut self, continuous: bool) -> Self { + self.is_continuous = continuous; + self + } + + fn get_status_icon(&self, status: TaskStatus) -> Span { + match status { + TaskStatus::Success => Span::styled( + " ✔ ", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + TaskStatus::LocalCacheKeptExisting | TaskStatus::LocalCache => Span::styled( + " ◼ ", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + TaskStatus::RemoteCache => Span::styled( + " ▼ ", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + TaskStatus::Failure => Span::styled( + " ✖ ", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + TaskStatus::Skipped => Span::styled( + " ⏭ ", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + TaskStatus::InProgress => Span::styled( + " ● ", + Style::default() + .fg(Color::LightCyan) + .add_modifier(Modifier::BOLD), + ), + TaskStatus::NotStarted => Span::styled( + " · ", + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ), + } + } + + fn get_base_style(&self, status: TaskStatus) -> Style { + Style::default().fg(match status { + TaskStatus::Success + | TaskStatus::LocalCacheKeptExisting + | TaskStatus::LocalCache + | TaskStatus::RemoteCache => Color::Green, + TaskStatus::Failure => Color::Red, + TaskStatus::Skipped => Color::Yellow, + TaskStatus::InProgress => Color::LightCyan, + TaskStatus::NotStarted => Color::DarkGray, + }) + } + + /// Calculates appropriate pty dimensions by applying relevant borders and padding adjustments to the given area + pub fn calculate_pty_dimensions(area: Rect) -> (u16, u16) { + // Account for borders and padding correctly + let pty_height = area + .height + .saturating_sub(2) // borders + .saturating_sub(2); // padding (1 top + 1 bottom) + let pty_width = area + .width + .saturating_sub(2) // borders + .saturating_sub(4) // padding (2 left + 2 right) + .saturating_sub(1); // 1 extra (based on empirical testing) to ensure characters are not cut off + + // Ensure minimum sizes + let pty_height = pty_height.max(3); + let pty_width = pty_width.max(20); + + (pty_height, pty_width) + } + + /// Returns whether currently in interactive mode. + pub fn is_currently_interactive(&self) -> bool { + self.pty_data + .as_ref() + .map(|data| data.is_interactive) + .unwrap_or(false) + } +} + +impl<'a> StatefulWidget for TerminalPane<'a> { + type State = TerminalPaneState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let base_style = self.get_base_style(state.task_status); + let border_style = if state.is_focused { + base_style + } else { + base_style.add_modifier(Modifier::DIM) + }; + + let status_icon = self.get_status_icon(state.task_status); + let block = Block::default() + .title(Line::from(vec![ + status_icon.clone(), + Span::raw(format!("{} ", state.task_name)) + .style(Style::default().fg(Color::White)), + ])) + .title_alignment(Alignment::Left) + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(border_style) + .padding(Padding::new(2, 2, 1, 1)); + + // If task hasn't started yet, show pending message + if matches!(state.task_status, TaskStatus::NotStarted) { + let message = vec![Line::from(vec![Span::styled( + "Task is pending...", + Style::default().fg(Color::DarkGray), + )])]; + + let paragraph = Paragraph::new(message) + .block(block) + .alignment(Alignment::Center) + .style(Style::default()); + + Widget::render(paragraph, area, buf); + return; + } + + // If the task is in progress, we need to check if a pty instance is available, and if not + // it implies that the task is being run outside the pseudo-terminal and all we can do is + // wait for the task results to arrive + if matches!(state.task_status, TaskStatus::InProgress) && !state.has_pty { + let message = vec![Line::from(vec![Span::styled( + "Waiting for task results...", + if state.is_focused { + self.get_base_style(TaskStatus::InProgress) + } else { + self.get_base_style(TaskStatus::InProgress) + .add_modifier(Modifier::DIM) + }, + )])]; + + let paragraph = Paragraph::new(message) + .block(block) + .alignment(Alignment::Center) + .style(Style::default()); + + Widget::render(paragraph, area, buf); + return; + } + + let inner_area = block.inner(area); + + if let Some(pty_data) = &self.pty_data { + if let Some(pty) = &pty_data.pty { + if let Some(screen) = pty.get_screen() { + let viewport_height = inner_area.height; + let current_scroll = pty.get_scroll_offset(); + + let total_content_rows = pty.get_total_content_rows(); + let scrollable_rows = + total_content_rows.saturating_sub(viewport_height as usize); + let needs_scrollbar = scrollable_rows > 0; + + // Reset scrollbar state if no scrolling needed + state.scrollbar_state = if needs_scrollbar { + let position = scrollable_rows.saturating_sub(current_scroll); + state + .scrollbar_state + .content_length(scrollable_rows) + .viewport_content_length(viewport_height as usize) + .position(position) + } else { + ScrollbarState::default() + }; + + let pseudo_term = PseudoTerminal::new(&screen).block(block); + Widget::render(pseudo_term, area, buf); + + // Only render scrollbar if needed + if needs_scrollbar { + let scrollbar = Scrollbar::default() + .orientation(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")) + .style(border_style); + + scrollbar.render(area, buf, &mut state.scrollbar_state); + } + + // Show interactive/readonly status for focused, non-cache hit, tasks + if state.is_focused && !pty_data.is_cache_hit { + // Bottom right status + let bottom_text = if self.is_currently_interactive() { + Line::from(vec![ + Span::raw(" "), + Span::styled("+z", Style::default().fg(Color::Cyan)), + Span::styled( + " to exit interactive ", + Style::default().fg(Color::White), + ), + ]) + } else { + Line::from(vec![ + Span::raw(" "), + Span::styled("i", Style::default().fg(Color::Cyan)), + Span::styled( + " to make interactive ", + Style::default().fg(Color::DarkGray), + ), + ]) + }; + + let text_width = bottom_text + .spans + .iter() + .map(|span| span.content.len()) + .sum::(); + + let bottom_right_area = Rect { + x: area.x + area.width - text_width as u16 - 3, + y: area.y + area.height - 1, + width: text_width as u16 + 2, + height: 1, + }; + + Paragraph::new(bottom_text) + .alignment(Alignment::Right) + .style(border_style) + .render(bottom_right_area, buf); + + // Top right status + let top_text = if self.is_currently_interactive() { + Line::from(vec![Span::styled( + " INTERACTIVE ", + Style::default().fg(Color::White), + )]) + } else { + Line::from(vec![Span::styled( + " NON-INTERACTIVE ", + Style::default().fg(Color::DarkGray), + )]) + }; + + let mode_width = top_text + .spans + .iter() + .map(|span| span.content.len()) + .sum::(); + + let top_right_area = Rect { + x: area.x + area.width - mode_width as u16 - 3, + y: area.y, + width: mode_width as u16 + 2, + height: 1, + }; + + Paragraph::new(top_text) + .alignment(Alignment::Right) + .style(border_style) + .render(top_right_area, buf); + } else if needs_scrollbar { + // Render padding for both top and bottom when scrollbar is present + let padding_text = Line::from(vec![Span::raw(" ")]); + let padding_width = 2; + + // Top padding + let top_right_area = Rect { + x: area.x + area.width - padding_width - 3, + y: area.y, + width: padding_width + 2, + height: 1, + }; + + Paragraph::new(padding_text.clone()) + .alignment(Alignment::Right) + .style(border_style) + .render(top_right_area, buf); + + // Bottom padding + let bottom_right_area = Rect { + x: area.x + area.width - padding_width - 3, + y: area.y + area.height - 1, + width: padding_width + 2, + height: 1, + }; + + Paragraph::new(padding_text) + .alignment(Alignment::Right) + .style(border_style) + .render(bottom_right_area, buf); + } + } + } + } + } +} diff --git a/packages/nx/src/native/tui/config.rs b/packages/nx/src/native/tui/config.rs new file mode 100644 index 0000000000..75ca3a8c33 --- /dev/null +++ b/packages/nx/src/native/tui/config.rs @@ -0,0 +1,62 @@ +#[derive(Clone)] +pub struct TuiCliArgs { + pub targets: Vec, + pub tui_auto_exit: Option, +} + +#[derive(Clone)] +pub enum AutoExit { + Boolean(bool), + Integer(u32), +} + +impl AutoExit { + pub const DEFAULT_COUNTDOWN_SECONDS: u32 = 3; + + // Return whether the TUI should exit automatically + pub fn should_exit_automatically(&self) -> bool { + match self { + // false means don't auto-exit + AutoExit::Boolean(false) => false, + // true means exit immediately (no countdown) + AutoExit::Boolean(true) => true, + // A number means exit after countdown + AutoExit::Integer(_) => true, + } + } + + // Get countdown seconds (if countdown is enabled) + pub fn countdown_seconds(&self) -> Option { + match self { + // false means no auto-exit, so no countdown + AutoExit::Boolean(false) => None, + // true means exit immediately, so no countdown + AutoExit::Boolean(true) => None, + // A number means show countdown for that many seconds + AutoExit::Integer(seconds) => Some(*seconds), + } + } +} + +pub struct TuiConfig { + pub auto_exit: AutoExit, +} + +impl TuiConfig { + /// Creates a new TuiConfig from nx.json config properties and CLI args + pub fn new(auto_exit: Option, cli_args: &TuiCliArgs) -> Self { + // Default to 3-second countdown if nothing is specified + let final_auto_exit = match auto_exit { + Some(config) => config, + None => AutoExit::Integer(AutoExit::DEFAULT_COUNTDOWN_SECONDS), + }; + // CLI args take precedence over programmatic config + let final_auto_exit = match &cli_args.tui_auto_exit { + Some(cli_value) => cli_value.clone(), + None => final_auto_exit, + }; + Self { + auto_exit: final_auto_exit, + } + } +} diff --git a/packages/nx/src/native/tui/lifecycle.rs b/packages/nx/src/native/tui/lifecycle.rs new file mode 100644 index 0000000000..1d8eee18cc --- /dev/null +++ b/packages/nx/src/native/tui/lifecycle.rs @@ -0,0 +1,262 @@ +use napi::bindgen_prelude::*; +use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction}; +use napi::JsObject; +use std::sync::{Arc, Mutex}; +use tracing::debug; + +use crate::native::logger::enable_logger; +use crate::native::pseudo_terminal::pseudo_terminal::{ParserArc, WriterArc}; +use crate::native::tasks::types::{Task, TaskResult}; + +use super::app::App; +use super::components::tasks_list::TaskStatus; +use super::config::{AutoExit, TuiCliArgs as RustTuiCliArgs, TuiConfig as RustTuiConfig}; +use super::tui::Tui; + +#[napi(object)] +#[derive(Clone)] +pub struct TuiCliArgs { + #[napi(ts_type = "string[] | undefined")] + pub targets: Option>, + + #[napi(ts_type = "boolean | number | undefined")] + pub tui_auto_exit: Option>, +} + +impl From for RustTuiCliArgs { + fn from(js: TuiCliArgs) -> Self { + let js_auto_exit = js.tui_auto_exit.map(|value| match value { + Either::A(bool_value) => AutoExit::Boolean(bool_value), + Either::B(int_value) => AutoExit::Integer(int_value), + }); + Self { + targets: js.targets.unwrap_or_default(), + tui_auto_exit: js_auto_exit, + } + } +} + +#[napi(object)] +pub struct TuiConfig { + #[napi(ts_type = "boolean | number | undefined")] + pub auto_exit: Option>, +} + +impl From<(TuiConfig, &RustTuiCliArgs)> for RustTuiConfig { + fn from((js_tui_config, rust_tui_cli_args): (TuiConfig, &RustTuiCliArgs)) -> Self { + let js_auto_exit = js_tui_config.auto_exit.map(|value| match value { + Either::A(bool_value) => AutoExit::Boolean(bool_value), + Either::B(int_value) => AutoExit::Integer(int_value), + }); + // Pass the converted JSON config value(s) and cli_args to instantiate the config with + RustTuiConfig::new(js_auto_exit, &rust_tui_cli_args) + } +} + +#[napi] +#[derive(Clone)] +pub struct AppLifeCycle { + app: Arc>, +} + +#[napi] +impl AppLifeCycle { + #[napi(constructor)] + pub fn new( + tasks: Vec, + pinned_tasks: Vec, + tui_cli_args: TuiCliArgs, + tui_config: TuiConfig, + title_text: String, + ) -> Self { + // Get the target names from nx_args.targets + let rust_tui_cli_args = tui_cli_args.into(); + + // Convert JSON TUI configuration to our Rust TuiConfig + let rust_tui_config = RustTuiConfig::from((tui_config, &rust_tui_cli_args)); + + Self { + app: Arc::new(std::sync::Mutex::new( + App::new( + tasks.into_iter().map(|t| t.into()).collect(), + pinned_tasks, + rust_tui_config, + title_text, + ) + .unwrap(), + )), + } + } + + #[napi] + pub fn start_command(&mut self, thread_count: Option) -> napi::Result<()> { + if let Ok(mut app) = self.app.lock() { + app.start_command(thread_count); + } + Ok(()) + } + + #[napi] + pub fn schedule_task(&mut self, _task: Task) -> napi::Result<()> { + // Always intentional noop + Ok(()) + } + + #[napi] + pub fn start_tasks(&mut self, tasks: Vec, _metadata: JsObject) -> napi::Result<()> { + if let Ok(mut app) = self.app.lock() { + app.start_tasks(tasks); + } + Ok(()) + } + + #[napi] + pub fn print_task_terminal_output( + &mut self, + task: Task, + status: String, + output: String, + ) -> napi::Result<()> { + if let Ok(mut app) = self.app.lock() { + app.print_task_terminal_output(task.id, status.parse().unwrap(), output); + } + Ok(()) + } + + #[napi] + pub fn end_tasks( + &mut self, + task_results: Vec, + _metadata: JsObject, + ) -> napi::Result<()> { + if let Ok(mut app) = self.app.lock() { + app.end_tasks(task_results); + } + Ok(()) + } + + #[napi] + pub fn end_command(&self) -> napi::Result<()> { + if let Ok(mut app) = self.app.lock() { + app.end_command(); + } + Ok(()) + } + + // Rust-only lifecycle method + #[napi(js_name = "__init")] + pub fn __init( + &self, + done_callback: ThreadsafeFunction<(), ErrorStrategy::Fatal>, + ) -> napi::Result<()> { + debug!("Initializing Terminal UI"); + enable_logger(); + + let app_mutex = self.app.clone(); + + // Initialize our Tui abstraction + let mut tui = Tui::new().map_err(|e| napi::Error::from_reason(e.to_string()))?; + tui.enter() + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + + std::panic::set_hook(Box::new(move |panic_info| { + // Restore the terminal to a clean state + if let Ok(mut t) = Tui::new() { + if let Err(r) = t.exit() { + debug!("Unable to exit Terminal: {:?}", r); + } + } + // Capture detailed backtraces in development, more concise in production + better_panic::Settings::auto() + .most_recent_first(false) + .lineno_suffix(true) + .verbosity(better_panic::Verbosity::Full) + .create_panic_handler()(panic_info); + })); + + debug!("Initialized Terminal UI"); + + // Set tick and frame rates + tui.tick_rate(10.0); + tui.frame_rate(60.0); + + // Initialize action channel + let (action_tx, mut action_rx) = tokio::sync::mpsc::unbounded_channel(); + debug!("Initialized Action Channel"); + + // Initialize components + if let Ok(mut app) = app_mutex.lock() { + // Store callback for cleanup + app.set_done_callback(done_callback); + + for component in app.components.iter_mut() { + component.register_action_handler(action_tx.clone()).ok(); + component.init().ok(); + } + } + debug!("Initialized Components"); + + napi::tokio::spawn(async move { + loop { + // Handle events using our Tui abstraction + if let Some(event) = tui.next().await { + if let Ok(mut app) = app_mutex.lock() { + if let Ok(true) = app.handle_event(event, &action_tx) { + tui.exit().ok(); + app.call_done_callback(); + break; + } + } + } + + // Process actions + while let Ok(action) = action_rx.try_recv() { + if let Ok(mut app) = app_mutex.lock() { + app.handle_action(&mut tui, action, &action_tx); + + // Check if we should quit based on the timer + if let Some(quit_time) = app.quit_at { + if std::time::Instant::now() >= quit_time { + debug!("Quitting TUI"); + tui.stop().ok(); + debug!("Exiting TUI"); + tui.exit().ok(); + debug!("Calling exit callback"); + app.call_done_callback(); + break; + } + } + } + } + } + }); + + Ok(()) + } + + #[napi] + pub fn register_running_task( + &mut self, + task_id: String, + parser_and_writer: External<(ParserArc, WriterArc)>, + ) { + let mut app = self.app.lock().unwrap(); + + app.register_running_task(task_id, parser_and_writer, TaskStatus::InProgress) + } + + // Rust-only lifecycle method + #[napi(js_name = "__setCloudMessage")] + pub async fn __set_cloud_message(&self, message: String) -> napi::Result<()> { + if let Ok(mut app) = self.app.lock() { + let _ = app.set_cloud_message(Some(message)); + } + Ok(()) + } +} + +#[napi] +pub fn restore_terminal() -> Result<()> { + // TODO: Maybe need some additional cleanup here in addition to the tui cleanup performed at the end of the render loop? + Ok(()) +} diff --git a/packages/nx/src/native/tui/mod.rs b/packages/nx/src/native/tui/mod.rs new file mode 100644 index 0000000000..5bc4143c2f --- /dev/null +++ b/packages/nx/src/native/tui/mod.rs @@ -0,0 +1,8 @@ +pub mod action; +pub mod app; +pub mod components; +pub mod config; +pub mod lifecycle; +pub mod pty; +pub mod tui; +pub mod utils; diff --git a/packages/nx/src/native/tui/pty.rs b/packages/nx/src/native/tui/pty.rs new file mode 100644 index 0000000000..4e4c34412a --- /dev/null +++ b/packages/nx/src/native/tui/pty.rs @@ -0,0 +1,120 @@ +use std::{ + io::{self, Write}, + sync::{Arc, Mutex, RwLock}, +}; +use vt100_ctt::Parser; + +use super::utils::normalize_newlines; + +#[derive(Clone)] +pub struct PtyInstance { + pub task_id: String, + pub parser: Arc>, + pub writer: Arc>>, + rows: u16, + cols: u16, +} + +impl PtyInstance { + pub fn new( + task_id: String, + parser: Arc>, + writer: Arc>>, + ) -> io::Result { + // Read the dimensions from the parser + let (rows, cols) = parser.read().unwrap().screen().size(); + Ok(Self { + task_id, + parser, + writer, + rows, + cols, + }) + } + + pub fn resize(&mut self, rows: u16, cols: u16) -> io::Result<()> { + // Ensure minimum sizes + let rows = rows.max(3); + let cols = cols.max(20); + + // Get current dimensions before resize + let old_rows = self.rows; + + // Update the stored dimensions + self.rows = rows; + self.cols = cols; + + // Create a new parser with the new dimensions while preserving state + if let Ok(mut parser_guard) = self.parser.write() { + let raw_output = parser_guard.get_raw_output().to_vec(); + + // Create new parser with new dimensions + let mut new_parser = Parser::new(rows, cols, 10000); + new_parser.process(&raw_output); + + // If we lost height, scroll up by that amount to maintain relative view position + if rows < old_rows { + // Set to 0 to ensure that the cursor is consistently at the bottom of the visible output on resize + new_parser.screen_mut().set_scrollback(0); + } + + *parser_guard = new_parser; + } + + Ok(()) + } + + pub fn write_input(&mut self, input: &[u8]) -> io::Result<()> { + if let Ok(mut writer_guard) = self.writer.lock() { + writer_guard.write_all(input)?; + writer_guard.flush()?; + } + + Ok(()) + } + + pub fn get_screen(&self) -> Option { + self.parser.read().ok().map(|p| p.screen().clone()) + } + + pub fn scroll_up(&mut self) { + if let Ok(mut parser) = self.parser.write() { + let current = parser.screen().scrollback(); + parser.screen_mut().set_scrollback(current + 1); + } + } + + pub fn scroll_down(&mut self) { + if let Ok(mut parser) = self.parser.write() { + let current = parser.screen().scrollback(); + if current > 0 { + parser.screen_mut().set_scrollback(current - 1); + } + } + } + + pub fn get_scroll_offset(&self) -> usize { + if let Ok(parser) = self.parser.read() { + return parser.screen().scrollback(); + } + 0 + } + + pub fn get_total_content_rows(&self) -> usize { + if let Ok(parser) = self.parser.read() { + let screen = parser.screen(); + screen.get_total_content_rows() + } else { + 0 + } + } + + /// Process output with an existing parser + pub fn process_output(parser: &RwLock, output: &[u8]) -> io::Result<()> { + if let Ok(mut parser_guard) = parser.write() { + let normalized = normalize_newlines(output); + parser_guard.process(&normalized); + } + Ok(()) + } +} diff --git a/packages/nx/src/native/tui/tui.rs b/packages/nx/src/native/tui/tui.rs new file mode 100644 index 0000000000..f024fbde12 --- /dev/null +++ b/packages/nx/src/native/tui/tui.rs @@ -0,0 +1,212 @@ +use color_eyre::eyre::Result; +use crossterm::{ + cursor, + event::{Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent}, + terminal::{EnterAlternateScreen, LeaveAlternateScreen}, +}; +use futures::{FutureExt, StreamExt}; +use ratatui::backend::CrosstermBackend as Backend; +use std::{ + ops::{Deref, DerefMut}, + time::Duration, +}; +use tokio::{ + sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, + task::JoinHandle, +}; +use tokio_util::sync::CancellationToken; +use tracing::debug; + +pub type Frame<'a> = ratatui::Frame<'a>; + +#[derive(Clone, Debug)] +pub enum Event { + Init, + Quit, + Error, + Closed, + Tick, + Render, + FocusGained, + FocusLost, + Paste(String), + Key(KeyEvent), + Mouse(MouseEvent), + Resize(u16, u16), +} + +pub struct Tui { + pub terminal: ratatui::Terminal>, + pub task: JoinHandle<()>, + pub cancellation_token: CancellationToken, + pub event_rx: UnboundedReceiver, + pub event_tx: UnboundedSender, + pub frame_rate: f64, + pub tick_rate: f64, +} + +impl Tui { + pub fn new() -> Result { + let tick_rate = 4.0; + let frame_rate = 60.0; + let terminal = ratatui::Terminal::new(Backend::new(std::io::stderr()))?; + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let cancellation_token = CancellationToken::new(); + let task = tokio::spawn(async {}); + Ok(Self { + terminal, + task, + cancellation_token, + event_rx, + event_tx, + frame_rate, + tick_rate, + }) + } + + pub fn tick_rate(&mut self, tick_rate: f64) { + self.tick_rate = tick_rate; + } + + pub fn frame_rate(&mut self, frame_rate: f64) { + self.frame_rate = frame_rate; + } + + pub fn start(&mut self) { + let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate); + let render_delay = std::time::Duration::from_secs_f64(1.0 / self.frame_rate); + self.cancel(); + self.cancellation_token = CancellationToken::new(); + let _cancellation_token = self.cancellation_token.clone(); + let _event_tx = self.event_tx.clone(); + self.task = tokio::spawn(async move { + let mut reader = crossterm::event::EventStream::new(); + let mut tick_interval = tokio::time::interval(tick_delay); + let mut render_interval = tokio::time::interval(render_delay); + _event_tx.send(Event::Init).unwrap(); + debug!("Start Listening for Crossterm Events"); + loop { + let crossterm_event = reader.next().fuse(); + tokio::select! { + _ = _cancellation_token.cancelled() => { + debug!("Got a cancellation token"); + break; + } + _ = tick_interval.tick() => { + _event_tx.send(Event::Tick).expect("cannot send event"); + }, + _ = render_interval.tick() => { + _event_tx.send(Event::Render).expect("cannot send event"); + }, + maybe_event = crossterm_event => { + debug!("Maybe Crossterm Event: {:?}", maybe_event); + match maybe_event { + Some(Ok(evt)) => { + debug!("Crossterm Event: {:?}", evt); + match evt { + CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => { + debug!("Key: {:?}", key); + _event_tx.send(Event::Key(key)).unwrap(); + }, + CrosstermEvent::Mouse(mouse) => { + _event_tx.send(Event::Mouse(mouse)).unwrap(); + }, + CrosstermEvent::Resize(x, y) => { + _event_tx.send(Event::Resize(x, y)).unwrap(); + }, + CrosstermEvent::FocusLost => { + _event_tx.send(Event::FocusLost).unwrap(); + }, + CrosstermEvent::FocusGained => { + _event_tx.send(Event::FocusGained).unwrap(); + }, + CrosstermEvent::Paste(s) => { + _event_tx.send(Event::Paste(s)).unwrap(); + }, + _ => { + debug!("Unhandled Crossterm Event: {:?}", evt); + continue; + } + } + } + Some(Err(e)) => { + debug!("Got an error event: {}", e); + _event_tx.send(Event::Error).unwrap(); + } + None => { + debug!("Crossterm Stream Stoped"); + break; + }, + } + }, + } + } + debug!("Crossterm Thread Finished") + }); + } + + pub fn stop(&self) -> Result<()> { + self.cancel(); + let mut counter = 0; + while !self.task.is_finished() { + std::thread::sleep(Duration::from_millis(1)); + counter += 1; + if counter > 50 { + self.task.abort(); + } + if counter > 100 { + // This log is hit most of the time, but this condition does not seem to be problematic in practice + // TODO: Investigate this moore deeply + // log::error!("Failed to abort task in 100 milliseconds for unknown reason"); + break; + } + } + Ok(()) + } + + pub fn enter(&mut self) -> Result<()> { + debug!("Enabling Raw Mode"); + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?; + self.start(); + Ok(()) + } + + pub fn exit(&mut self) -> Result<()> { + self.stop()?; + if crossterm::terminal::is_raw_mode_enabled()? { + self.flush()?; + crossterm::execute!(std::io::stderr(), LeaveAlternateScreen, cursor::Show)?; + crossterm::terminal::disable_raw_mode()?; + } + Ok(()) + } + + pub fn cancel(&self) { + self.cancellation_token.cancel(); + } + + pub async fn next(&mut self) -> Option { + self.event_rx.recv().await + } +} + +impl Deref for Tui { + type Target = ratatui::Terminal>; + + fn deref(&self) -> &Self::Target { + &self.terminal + } +} + +impl DerefMut for Tui { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.terminal + } +} + +impl Drop for Tui { + fn drop(&mut self) { + self.exit().unwrap(); + } +} diff --git a/packages/nx/src/native/tui/utils.rs b/packages/nx/src/native/tui/utils.rs new file mode 100644 index 0000000000..2e15171def --- /dev/null +++ b/packages/nx/src/native/tui/utils.rs @@ -0,0 +1,392 @@ +use crate::native::tui::components::tasks_list::{TaskItem, TaskStatus}; + +pub fn format_duration(duration_ms: u128) -> String { + if duration_ms == 0 { + "<1ms".to_string() + } else if duration_ms < 1000 { + format!("{}ms", duration_ms) + } else { + format!("{:.1}s", duration_ms as f64 / 1000.0) + } +} + +pub fn format_duration_since(start_ms: u128, end_ms: u128) -> String { + format_duration(end_ms.saturating_sub(start_ms)) +} + +/// Ensures that all newlines in the output are properly handled by converting +/// lone \n to \r\n sequences. This mimics terminal driver behavior. +pub fn normalize_newlines(input: &[u8]) -> Vec { + let mut output = Vec::with_capacity(input.len()); + let mut i = 0; + while i < input.len() { + if input[i] == b'\n' { + // If this \n isn't preceded by \r, add the \r + if i == 0 || input[i - 1] != b'\r' { + output.push(b'\r'); + } + } + output.push(input[i]); + i += 1; + } + output +} + +pub fn is_cache_hit(status: TaskStatus) -> bool { + matches!( + status, + TaskStatus::LocalCacheKeptExisting | TaskStatus::LocalCache | TaskStatus::RemoteCache + ) +} + +/// Sorts a list of TaskItems with a stable, total ordering. +/// +/// The sort order is: +/// 1. InProgress tasks first +/// 2. Failure tasks second +/// 3. Other completed tasks third (sorted by end_time if available) +/// 4. NotStarted tasks last +/// +/// Within each status category: +/// - For completed tasks: sort by end_time if available, then by name +/// - For other statuses: sort by name +pub fn sort_task_items(tasks: &mut [TaskItem]) { + tasks.sort_by(|a, b| { + // Map status to a numeric category for sorting + let status_to_category = |status: &TaskStatus| -> u8 { + match status { + TaskStatus::InProgress => 0, + TaskStatus::Failure => 1, + TaskStatus::Success + | TaskStatus::LocalCacheKeptExisting + | TaskStatus::LocalCache + | TaskStatus::RemoteCache + | TaskStatus::Skipped => 2, + TaskStatus::NotStarted => 3, + } + }; + + let a_category = status_to_category(&a.status); + let b_category = status_to_category(&b.status); + + // First compare by status category + if a_category != b_category { + return a_category.cmp(&b_category); + } + + // For completed tasks, sort by end_time if available + if a_category == 1 || a_category == 2 { + // Failure or Success categories + match (a.end_time, b.end_time) { + (Some(time_a), Some(time_b)) => { + let time_cmp = time_a.cmp(&time_b); + if time_cmp != std::cmp::Ordering::Equal { + return time_cmp; + } + } + (Some(_), None) => return std::cmp::Ordering::Less, + (None, Some(_)) => return std::cmp::Ordering::Greater, + (None, None) => {} + } + } + + // For all other cases or as a tiebreaker, sort by name + a.name.cmp(&b.name) + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + // Helper function to create a TaskItem for testing + fn create_task(name: &str, status: TaskStatus, end_time: Option) -> TaskItem { + let mut task = TaskItem::new(name.to_string(), false); + task.status = status; + task.end_time = end_time; + task + } + + #[test] + fn test_sort_by_status_category() { + let mut tasks = vec![ + create_task("task1", TaskStatus::NotStarted, None), + create_task("task2", TaskStatus::InProgress, None), + create_task("task3", TaskStatus::Success, Some(100)), + create_task("task4", TaskStatus::Failure, Some(200)), + ]; + + sort_task_items(&mut tasks); + + // Expected order: InProgress, Failure, Success, NotStarted + assert_eq!(tasks[0].status, TaskStatus::InProgress); + assert_eq!(tasks[1].status, TaskStatus::Failure); + assert_eq!(tasks[2].status, TaskStatus::Success); + assert_eq!(tasks[3].status, TaskStatus::NotStarted); + } + + #[test] + fn test_sort_completed_tasks_by_end_time() { + let mut tasks = vec![ + create_task("task1", TaskStatus::Success, Some(300)), + create_task("task2", TaskStatus::Success, Some(100)), + create_task("task3", TaskStatus::Success, Some(200)), + ]; + + sort_task_items(&mut tasks); + + // Should be sorted by end_time: 100, 200, 300 + assert_eq!(tasks[0].name, "task2"); + assert_eq!(tasks[1].name, "task3"); + assert_eq!(tasks[2].name, "task1"); + } + + #[test] + fn test_sort_with_missing_end_times() { + let mut tasks = vec![ + create_task("task1", TaskStatus::Success, None), + create_task("task2", TaskStatus::Success, Some(100)), + create_task("task3", TaskStatus::Success, None), + ]; + + sort_task_items(&mut tasks); + + // Tasks with end_time come before those without + assert_eq!(tasks[0].name, "task2"); + // Then alphabetical for those without end_time + assert_eq!(tasks[1].name, "task1"); + assert_eq!(tasks[2].name, "task3"); + } + + #[test] + fn test_sort_same_status_no_end_time_by_name() { + let mut tasks = vec![ + create_task("c", TaskStatus::NotStarted, None), + create_task("a", TaskStatus::NotStarted, None), + create_task("b", TaskStatus::NotStarted, None), + ]; + + sort_task_items(&mut tasks); + + // Should be sorted alphabetically: a, b, c + assert_eq!(tasks[0].name, "a"); + assert_eq!(tasks[1].name, "b"); + assert_eq!(tasks[2].name, "c"); + } + + #[test] + fn test_sort_mixed_statuses_and_end_times() { + let mut tasks = vec![ + create_task("z", TaskStatus::NotStarted, None), + create_task("y", TaskStatus::InProgress, None), + create_task("x", TaskStatus::Success, Some(300)), + create_task("w", TaskStatus::Failure, Some(200)), + create_task("v", TaskStatus::Success, None), + create_task("u", TaskStatus::InProgress, None), + create_task("t", TaskStatus::Failure, None), + create_task("s", TaskStatus::NotStarted, None), + ]; + + sort_task_items(&mut tasks); + + // Expected groups by status: + // 1. InProgress: "u", "y" (alphabetical) + // 2. Failure: "w" (with end_time), "t" (without end_time) + // 3. Success: "x" (with end_time), "v" (without end_time) + // 4. NotStarted: "s", "z" (alphabetical) + + // Check the order within each status group + let names: Vec<&str> = tasks.iter().map(|t| &t.name[..]).collect(); + + // First group: InProgress + assert_eq!(tasks[0].status, TaskStatus::InProgress); + assert_eq!(tasks[1].status, TaskStatus::InProgress); + assert!(names[0..2].contains(&"u")); + assert!(names[0..2].contains(&"y")); + assert_eq!(names[0], "u"); // Alphabetical within group + + // Second group: Failure + assert_eq!(tasks[2].status, TaskStatus::Failure); + assert_eq!(tasks[3].status, TaskStatus::Failure); + assert_eq!(names[2], "w"); // With end_time comes first + assert_eq!(names[3], "t"); // Without end_time comes second + + // Third group: Success + assert_eq!(tasks[4].status, TaskStatus::Success); + assert_eq!(tasks[5].status, TaskStatus::Success); + assert_eq!(names[4], "x"); // With end_time comes first + assert_eq!(names[5], "v"); // Without end_time comes second + + // Fourth group: NotStarted + assert_eq!(tasks[6].status, TaskStatus::NotStarted); + assert_eq!(tasks[7].status, TaskStatus::NotStarted); + assert_eq!(names[6], "s"); // Alphabetical within group + assert_eq!(names[7], "z"); + } + + #[test] + fn test_sort_with_same_end_times() { + let mut tasks = vec![ + create_task("c", TaskStatus::Success, Some(100)), + create_task("a", TaskStatus::Success, Some(100)), + create_task("b", TaskStatus::Success, Some(100)), + ]; + + sort_task_items(&mut tasks); + + // When end_times are the same, should sort by name + assert_eq!(tasks[0].name, "a"); + assert_eq!(tasks[1].name, "b"); + assert_eq!(tasks[2].name, "c"); + } + + #[test] + fn test_sort_empty_list() { + let mut tasks: Vec = vec![]; + + // Should not panic on empty list + sort_task_items(&mut tasks); + + assert!(tasks.is_empty()); + } + + #[test] + fn test_sort_single_task() { + let mut tasks = vec![create_task("task", TaskStatus::Success, Some(100))]; + + // Should not change a single-element list + sort_task_items(&mut tasks); + + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].name, "task"); + } + + #[test] + fn test_sort_stability_for_equal_elements() { + // Create tasks with identical properties + let mut tasks = vec![ + create_task("task1", TaskStatus::Success, Some(100)), + create_task("task1", TaskStatus::Success, Some(100)), + ]; + + // Mark the original positions + let original_names = tasks.iter().map(|t| t.name.clone()).collect::>(); + + // Sort should maintain original order for equal elements + sort_task_items(&mut tasks); + + let sorted_names = tasks.iter().map(|t| t.name.clone()).collect::>(); + assert_eq!(sorted_names, original_names); + } + + #[test] + fn test_sort_large_random_dataset() { + use rand::rngs::StdRng; + use rand::{Rng, SeedableRng}; + + // Use a fixed seed for reproducibility + let mut rng = StdRng::seed_from_u64(42); + + // Generate a large dataset with random properties + let statuses = [ + TaskStatus::InProgress, + TaskStatus::Failure, + TaskStatus::Success, + TaskStatus::NotStarted, + ]; + + let mut tasks: Vec = (0..1000) + .map(|i| { + let name = format!("task{}", i); + let status = statuses[rng.random_range(0..statuses.len())]; + let end_time = if rng.random_bool(0.7) { + Some(rng.random_range(100..10000)) + } else { + None + }; + + create_task(&name, status, end_time) + }) + .collect(); + + // Sort should not panic with large random dataset + sort_task_items(&mut tasks); + + // Verify the sort maintains the expected ordering rules + for i in 1..tasks.len() { + let a = &tasks[i - 1]; + let b = &tasks[i]; + + // Map status to category for comparison + let status_to_category = |status: &TaskStatus| -> u8 { + match status { + TaskStatus::InProgress => 0, + TaskStatus::Failure => 1, + TaskStatus::Success + | TaskStatus::LocalCacheKeptExisting + | TaskStatus::LocalCache + | TaskStatus::RemoteCache + | TaskStatus::Skipped => 2, + TaskStatus::NotStarted => 3, + } + }; + + let a_category = status_to_category(&a.status); + let b_category = status_to_category(&b.status); + + if a_category < b_category { + // If a's category is less than b's, that's correct + continue; + } else if a_category > b_category { + // If a's category is greater than b's, that's an error + panic!( + "Sort order violation: {:?} should come before {:?}", + b.name, a.name + ); + } + + // Same category, check end_time for completed tasks + if a_category == 1 || a_category == 2 { + match (a.end_time, b.end_time) { + (Some(time_a), Some(time_b)) => { + if time_a > time_b { + panic!("Sort order violation: task with end_time {} should come before task with end_time {}", time_b, time_a); + } else if time_a < time_b { + continue; + } + // If end times are equal, fall through to name check + } + (Some(_), None) => continue, // Correct order + (None, Some(_)) => panic!("Sort order violation: task with end_time should come before task without end_time"), + (None, None) => {} // Fall through to name check + } + } + + // If we get here, we're comparing names within the same category + // and with the same end_time status + if a.name > b.name { + panic!( + "Sort order violation: task named {} should come before task named {}", + b.name, a.name + ); + } + } + } + + #[test] + fn test_sort_edge_cases() { + // Test with extreme end_time values + let mut tasks = vec![ + create_task("a", TaskStatus::Success, Some(u128::MAX)), + create_task("b", TaskStatus::Success, Some(0)), + create_task("c", TaskStatus::Success, Some(u128::MAX / 2)), + ]; + + sort_task_items(&mut tasks); + + // Should sort by end_time: 0, MAX/2, MAX + assert_eq!(tasks[0].name, "b"); + assert_eq!(tasks[1].name, "c"); + assert_eq!(tasks[2].name, "a"); + } +} diff --git a/packages/nx/src/nx-cloud/nx-cloud-tasks-runner-shell.ts b/packages/nx/src/nx-cloud/nx-cloud-tasks-runner-shell.ts index 5c7db2736d..bdfd467964 100644 --- a/packages/nx/src/nx-cloud/nx-cloud-tasks-runner-shell.ts +++ b/packages/nx/src/nx-cloud/nx-cloud-tasks-runner-shell.ts @@ -1,16 +1,16 @@ -import { findAncestorNodeModules } from './resolution-helpers'; -import { - NxCloudClientUnavailableError, - NxCloudEnterpriseOutdatedError, - verifyOrUpdateNxCloudClient, -} from './update-manager'; +import { Task } from '../config/task-graph'; import { defaultTasksRunner, DefaultTasksRunnerOptions, } from '../tasks-runner/default-tasks-runner'; import { TasksRunner } from '../tasks-runner/tasks-runner'; import { output } from '../utils/output'; -import { Task } from '../config/task-graph'; +import { findAncestorNodeModules } from './resolution-helpers'; +import { + NxCloudClientUnavailableError, + NxCloudEnterpriseOutdatedError, + verifyOrUpdateNxCloudClient, +} from './update-manager'; export interface CloudTaskRunnerOptions extends DefaultTasksRunnerOptions { accessToken?: string; @@ -56,7 +56,7 @@ export const nxCloudTasksRunnerShell: TasksRunner< if (e instanceof NxCloudEnterpriseOutdatedError) { output.warn({ title: e.message, - bodyLines: ['Nx Cloud will not used for this command.', ...body], + bodyLines: ['Nx Cloud will not be used for this command.', ...body], }); } const results = await defaultTasksRunner(tasks, options, context); diff --git a/packages/nx/src/tasks-runner/create-task-graph.ts b/packages/nx/src/tasks-runner/create-task-graph.ts index 30a8c2b335..84083c63d7 100644 --- a/packages/nx/src/tasks-runner/create-task-graph.ts +++ b/packages/nx/src/tasks-runner/create-task-graph.ts @@ -49,7 +49,7 @@ export class ProcessTasks { target, configuration ); - const id = this.getId(projectName, target, resolvedConfiguration); + const id = createTaskId(projectName, target, resolvedConfiguration); const task = this.createTask( id, project, @@ -221,7 +221,7 @@ export class ProcessTasks { dependencyConfig.target, configuration ); - const selfTaskId = this.getId( + const selfTaskId = createTaskId( selfProject.name, dependencyConfig.target, resolvedConfiguration @@ -286,7 +286,7 @@ export class ProcessTasks { dependencyConfig.target, configuration ); - const depTargetId = this.getId( + const depTargetId = createTaskId( depProject.name, dependencyConfig.target, resolvedConfiguration @@ -325,7 +325,7 @@ export class ProcessTasks { } } else { // Create a dummy task for task.target.project... which simulates if depProject had dependencyConfig.target - const dummyId = this.getId( + const dummyId = createTaskId( depProject.name, task.target.project + '__' + @@ -408,18 +408,6 @@ export class ProcessTasks { ? configuration : defaultConfiguration; } - - getId( - project: string, - target: string, - configuration: string | undefined - ): string { - let id = `${project}:${target}`; - if (configuration) { - id += `:${configuration}`; - } - return id; - } } export function createTaskGraph( @@ -532,3 +520,15 @@ export function getNonDummyDeps( return [currentTask]; } } + +export function createTaskId( + project: string, + target: string, + configuration: string | undefined +): string { + let id = `${project}:${target}`; + if (configuration) { + id += `:${configuration}`; + } + return id; +} diff --git a/packages/nx/src/tasks-runner/default-tasks-runner.ts b/packages/nx/src/tasks-runner/default-tasks-runner.ts index fdf08e97a8..85ff5f09ec 100644 --- a/packages/nx/src/tasks-runner/default-tasks-runner.ts +++ b/packages/nx/src/tasks-runner/default-tasks-runner.ts @@ -135,16 +135,24 @@ export const defaultTasksRunner: TasksRunner< (options as any)['parallel'] = Number((options as any)['maxParallel'] || 3); } - await options.lifeCycle.startCommand(); + const maxParallel = + options['parallel'] + + Object.values(context.taskGraph.tasks).filter((t) => t.continuous).length; + const totalTasks = Object.values(context.taskGraph.tasks).length; + const threadCount = Math.min(maxParallel, totalTasks); + + await options.lifeCycle.startCommand(threadCount); try { - return await runAllTasks(tasks, options, context); + return await runAllTasks(options, { + ...context, + threadCount, + }); } finally { await options.lifeCycle.endCommand(); } }; async function runAllTasks( - tasks: Task[], options: DefaultTasksRunnerOptions, context: { initiatingProject?: string; @@ -154,6 +162,7 @@ async function runAllTasks( taskGraph: TaskGraph; hasher: TaskHasher; daemon: DaemonClient; + threadCount: number; } ): Promise<{ [id: string]: TaskStatus }> { const orchestrator = new TaskOrchestrator( @@ -163,6 +172,7 @@ async function runAllTasks( context.taskGraph, context.nxJson, options, + context.threadCount, context.nxArgs?.nxBail, context.daemon, context.nxArgs?.outputStyle diff --git a/packages/nx/src/tasks-runner/forked-process-task-runner.ts b/packages/nx/src/tasks-runner/forked-process-task-runner.ts index e14e773298..6155e9e1c1 100644 --- a/packages/nx/src/tasks-runner/forked-process-task-runner.ts +++ b/packages/nx/src/tasks-runner/forked-process-task-runner.ts @@ -8,11 +8,7 @@ import { join } from 'path'; import { BatchMessageType } from './batch/batch-messages'; import { stripIndents } from '../utils/strip-indents'; import { Task, TaskGraph } from '../config/task-graph'; -import { - getPseudoTerminal, - PseudoTerminal, - PseudoTtyProcess, -} from './pseudo-terminal'; +import { PseudoTerminal, PseudoTtyProcess } from './pseudo-terminal'; import { signalToCode } from '../utils/exit-codes'; import { ProjectGraph } from '../config/project-graph'; import { @@ -21,6 +17,7 @@ import { } from './running-tasks/node-child-process'; import { BatchProcess } from './running-tasks/batch-process'; import { RunningTask } from './running-tasks/running-task'; +import { RustPseudoTerminal } from '../native'; const forkScript = join(__dirname, './fork.js'); @@ -32,17 +29,14 @@ export class ForkedProcessTaskRunner { private readonly verbose = process.env.NX_VERBOSE_LOGGING === 'true'; private processes = new Set(); private finishedProcesses = new Set(); + private pseudoTerminals = new Set(); - private pseudoTerminal: PseudoTerminal | null = PseudoTerminal.isSupported() - ? getPseudoTerminal() - : null; - - constructor(private readonly options: DefaultTasksRunnerOptions) {} + constructor( + private readonly options: DefaultTasksRunnerOptions, + private readonly tuiEnabled: boolean + ) {} async init() { - if (this.pseudoTerminal) { - await this.pseudoTerminal.init(); - } this.setupProcessEventListeners(); } @@ -148,32 +142,45 @@ export class ForkedProcessTaskRunner { } ): Promise { const shouldPrefix = - streamOutput && process.env.NX_PREFIX_OUTPUT === 'true'; + streamOutput && + process.env.NX_PREFIX_OUTPUT === 'true' && + !this.tuiEnabled; // streamOutput would be false if we are running multiple targets // there's no point in running the commands in a pty if we are not streaming the output if ( - !this.pseudoTerminal || - disablePseudoTerminal || - !streamOutput || - shouldPrefix + PseudoTerminal.isSupported() && + !disablePseudoTerminal && + (this.tuiEnabled || (streamOutput && !shouldPrefix)) ) { - return this.forkProcessWithPrefixAndNotTTY(task, { - temporaryOutputPath, - streamOutput, - taskGraph, - env, - }); - } else { return this.forkProcessWithPseudoTerminal(task, { temporaryOutputPath, streamOutput, taskGraph, env, }); + } else { + return this.forkProcessWithPrefixAndNotTTY(task, { + temporaryOutputPath, + streamOutput, + taskGraph, + env, + }); } } + private async createPseudoTerminal() { + const terminal = new PseudoTerminal(new RustPseudoTerminal()); + + await terminal.init(); + + terminal.onMessageFromChildren((message: Serializable) => { + process.send(message); + }); + + return terminal; + } + private async forkProcessWithPseudoTerminal( task: Task, { @@ -188,13 +195,10 @@ export class ForkedProcessTaskRunner { env: NodeJS.ProcessEnv; } ): Promise { - const args = getPrintableCommandArgsForTask(task); - if (streamOutput) { - output.logCommand(args.join(' ')); - } - const childId = task.id; - const p = await this.pseudoTerminal.fork(childId, forkScript, { + const pseudoTerminal = await this.createPseudoTerminal(); + this.pseudoTerminals.add(pseudoTerminal); + const p = await pseudoTerminal.fork(childId, forkScript, { cwd: process.cwd(), execArgv: process.execArgv, jsEnv: env, @@ -218,6 +222,7 @@ export class ForkedProcessTaskRunner { if (code > 128) { process.exit(code); } + this.pseudoTerminals.delete(pseudoTerminal); this.processes.delete(p); this.writeTerminalOutput(temporaryOutputPath, terminalOutput); }); @@ -355,16 +360,10 @@ export class ForkedProcessTaskRunner { } private setupProcessEventListeners() { - if (this.pseudoTerminal) { - this.pseudoTerminal.onMessageFromChildren((message: Serializable) => { - process.send(message); - }); - } - const messageHandler = (message: Serializable) => { - if (this.pseudoTerminal) { - this.pseudoTerminal.sendMessageToChildren(message); - } + this.pseudoTerminals.forEach((p) => { + p.sendMessageToChildren(message); + }); this.processes.forEach((p) => { if ('connected' in p && p.connected && 'send' in p) { diff --git a/packages/nx/src/tasks-runner/is-tui-enabled.ts b/packages/nx/src/tasks-runner/is-tui-enabled.ts new file mode 100644 index 0000000000..6d1ebf518d --- /dev/null +++ b/packages/nx/src/tasks-runner/is-tui-enabled.ts @@ -0,0 +1,66 @@ +import type { NxJsonConfiguration } from '../config/nx-json'; +import { readNxJsonFromDisk } from '../devkit-internals'; + +let tuiEnabled = undefined; + +export function isTuiEnabled(nxJson?: NxJsonConfiguration) { + if (tuiEnabled !== undefined) { + return tuiEnabled; + } + + // If the current terminal/environment is not capable of displaying the TUI, we don't run it + const isWindows = process.platform === 'win32'; + const isCapable = process.stderr.isTTY && isUnicodeSupported(); + // Windows is not working well right now, temporarily disable it on Windows even if it has been specified as enabled + // TODO(@JamesHenry): Remove this check once Windows issues are fixed. + if (!isCapable || isWindows) { + tuiEnabled = false; + process.env.NX_TUI = 'false'; + return tuiEnabled; + } + + // The environment variable takes precedence over the nx.json config + if (typeof process.env.NX_TUI === 'string') { + tuiEnabled = process.env.NX_TUI === 'true' ? true : false; + return tuiEnabled; + } + + // Only read from disk if nx.json config is not already provided (and we have not been able to determine tuiEnabled based on the above checks) + if (!nxJson) { + nxJson = readNxJsonFromDisk(); + } + + // Respect user config + if (typeof nxJson.tui?.enabled === 'boolean') { + tuiEnabled = Boolean(nxJson.tui?.enabled); + } else { + // Default to enabling the TUI if the system is capable of displaying it + tuiEnabled = true; + } + + // Also set the environment variable for consistency and ease of checking on the rust side, for example + process.env.NX_TUI = tuiEnabled.toString(); + + return tuiEnabled; +} + +// Credit to https://github.com/sindresorhus/is-unicode-supported/blob/e0373335038856c63034c8eef6ac43ee3827a601/index.js +function isUnicodeSupported() { + const { env } = process; + const { TERM, TERM_PROGRAM } = env; + if (process.platform !== 'win32') { + return TERM !== 'linux'; // Linux console (kernel) + } + return ( + Boolean(env.WT_SESSION) || // Windows Terminal + Boolean(env.TERMINUS_SUBLIME) || // Terminus (<0.2.27) + env.ConEmuTask === '{cmd::Cmder}' || // ConEmu and cmder + TERM_PROGRAM === 'Terminus-Sublime' || + TERM_PROGRAM === 'vscode' || + TERM === 'xterm-256color' || + TERM === 'alacritty' || + TERM === 'rxvt-unicode' || + TERM === 'rxvt-unicode-256color' || + env.TERMINAL_EMULATOR === 'JetBrains-JediTerm' + ); +} diff --git a/packages/nx/src/tasks-runner/life-cycle.ts b/packages/nx/src/tasks-runner/life-cycle.ts index e7c870483a..778a7984a2 100644 --- a/packages/nx/src/tasks-runner/life-cycle.ts +++ b/packages/nx/src/tasks-runner/life-cycle.ts @@ -1,5 +1,7 @@ -import { TaskStatus } from './tasks-runner'; import { Task } from '../config/task-graph'; +import { ExternalObject } from '../native'; +import { RunningTask } from './running-tasks/running-task'; +import { TaskStatus } from './tasks-runner'; /** * The result of a completed {@link Task} @@ -20,8 +22,16 @@ export interface TaskMetadata { groupId: number; } +interface RustRunningTask extends RunningTask { + getResults(): Promise<{ code: number; terminalOutput: string }>; + + onExit(cb: (code: number, terminalOutput: string) => void): void; + + kill(signal?: NodeJS.Signals | number): Promise | void; +} + export interface LifeCycle { - startCommand?(): void | Promise; + startCommand?(parallel?: number): void | Promise; endCommand?(): void | Promise; @@ -53,15 +63,20 @@ export interface LifeCycle { status: TaskStatus, output: string ): void; + + registerRunningTask?( + taskId: string, + parserAndWriter: ExternalObject<[any, any]> + ); } export class CompositeLifeCycle implements LifeCycle { constructor(private readonly lifeCycles: LifeCycle[]) {} - async startCommand(): Promise { + async startCommand(parallel?: number): Promise { for (let l of this.lifeCycles) { if (l.startCommand) { - await l.startCommand(); + await l.startCommand(parallel); } } } @@ -132,4 +147,15 @@ export class CompositeLifeCycle implements LifeCycle { } } } + + async registerRunningTask( + taskId: string, + parserAndWriter: ExternalObject + ): Promise { + for (let l of this.lifeCycles) { + if (l.registerRunningTask) { + await l.registerRunningTask(taskId, parserAndWriter); + } + } + } } diff --git a/packages/nx/src/tasks-runner/life-cycles/task-history-life-cycle-old.ts b/packages/nx/src/tasks-runner/life-cycles/task-history-life-cycle-old.ts index ad8036a54b..7b0097829d 100644 --- a/packages/nx/src/tasks-runner/life-cycles/task-history-life-cycle-old.ts +++ b/packages/nx/src/tasks-runner/life-cycles/task-history-life-cycle-old.ts @@ -1,11 +1,12 @@ -import { serializeTarget } from '../../utils/serialize-target'; import { Task } from '../../config/task-graph'; -import { output } from '../../utils/output'; import { getHistoryForHashes, TaskRun, - writeTaskRunsToHistory as writeTaskRunsToHistory, + writeTaskRunsToHistory, } from '../../utils/legacy-task-history'; +import { output } from '../../utils/output'; +import { serializeTarget } from '../../utils/serialize-target'; +import { isTuiEnabled } from '../is-tui-enabled'; import { LifeCycle, TaskResult } from '../life-cycle'; export class LegacyTaskHistoryLifeCycle implements LifeCycle { @@ -54,6 +55,10 @@ export class LegacyTaskHistoryLifeCycle implements LifeCycle { ); } } + // Do not directly print output when using the TUI + if (isTuiEnabled()) { + return; + } if (flakyTasks.length > 0) { output.warn({ title: `Nx detected ${ diff --git a/packages/nx/src/tasks-runner/life-cycles/task-history-life-cycle.ts b/packages/nx/src/tasks-runner/life-cycles/task-history-life-cycle.ts index 46fd4df6f6..8a6131b11a 100644 --- a/packages/nx/src/tasks-runner/life-cycles/task-history-life-cycle.ts +++ b/packages/nx/src/tasks-runner/life-cycles/task-history-life-cycle.ts @@ -1,9 +1,10 @@ -import { serializeTarget } from '../../utils/serialize-target'; import { Task } from '../../config/task-graph'; -import { output } from '../../utils/output'; -import { LifeCycle, TaskResult } from '../life-cycle'; import type { TaskRun as NativeTaskRun } from '../../native'; +import { output } from '../../utils/output'; +import { serializeTarget } from '../../utils/serialize-target'; import { getTaskHistory, TaskHistory } from '../../utils/task-history'; +import { isTuiEnabled } from '../is-tui-enabled'; +import { LifeCycle, TaskResult } from '../life-cycle'; interface TaskRun extends NativeTaskRun { target: Task['target']; @@ -45,6 +46,10 @@ export class TaskHistoryLifeCycle implements LifeCycle { const flakyTasks = await this.taskHistory.getFlakyTasks( entries.map(([hash]) => hash) ); + // Do not directly print output when using the TUI + if (isTuiEnabled()) { + return; + } if (flakyTasks.length > 0) { output.warn({ title: `Nx detected ${ diff --git a/packages/nx/src/tasks-runner/life-cycles/tui-summary-life-cycle.ts b/packages/nx/src/tasks-runner/life-cycles/tui-summary-life-cycle.ts new file mode 100644 index 0000000000..917b00e83f --- /dev/null +++ b/packages/nx/src/tasks-runner/life-cycles/tui-summary-life-cycle.ts @@ -0,0 +1,394 @@ +import { EOL } from 'node:os'; +import { Task } from '../../config/task-graph'; +import { output } from '../../utils/output'; +import type { LifeCycle } from '../life-cycle'; +import type { TaskStatus } from '../tasks-runner'; +import { formatFlags, formatTargetsAndProjects } from './formatting-utils'; +import { prettyTime } from './pretty-time'; +import { viewLogsFooterRows } from './view-logs-utils'; +import figures = require('figures'); + +const LEFT_PAD = ` `; +const SPACER = ` `; +const EXTENDED_LEFT_PAD = ` `; + +export function getTuiTerminalSummaryLifeCycle({ + projectNames, + tasks, + args, + overrides, + initiatingProject, + resolveRenderIsDonePromise, +}: { + projectNames: string[]; + tasks: Task[]; + args: { targets?: string[]; configuration?: string; parallel?: number }; + overrides: Record; + initiatingProject: string; + resolveRenderIsDonePromise: (value: void) => void; +}) { + const lifeCycle = {} as Partial; + + const start = process.hrtime(); + const targets = args.targets; + const totalTasks = tasks.length; + + let totalCachedTasks = 0; + let totalSuccessfulTasks = 0; + let totalFailedTasks = 0; + let totalCompletedTasks = 0; + let timeTakenText: string; + + const failedTasks = new Set(); + const inProgressTasks = new Set(); + const tasksToTerminalOutputs: Record< + string, + { terminalOutput: string; taskStatus: TaskStatus } + > = {}; + const taskIdsInOrderOfCompletion: string[] = []; + + lifeCycle.startTasks = (tasks) => { + for (let t of tasks) { + inProgressTasks.add(t.id); + } + }; + + lifeCycle.printTaskTerminalOutput = (task, taskStatus, terminalOutput) => { + tasksToTerminalOutputs[task.id] = { terminalOutput, taskStatus }; + taskIdsInOrderOfCompletion.push(task.id); + }; + + lifeCycle.endTasks = (taskResults) => { + for (let t of taskResults) { + totalCompletedTasks++; + inProgressTasks.delete(t.task.id); + + switch (t.status) { + case 'remote-cache': + case 'local-cache': + case 'local-cache-kept-existing': + totalCachedTasks++; + totalSuccessfulTasks++; + break; + case 'success': + totalSuccessfulTasks++; + break; + case 'failure': + totalFailedTasks++; + failedTasks.add(t.task.id); + break; + } + } + }; + + lifeCycle.endCommand = () => { + timeTakenText = prettyTime(process.hrtime(start)); + resolveRenderIsDonePromise(); + }; + + const printSummary = () => { + const isRunOne = initiatingProject && targets?.length === 1; + + // Handles when the user interrupts the process + timeTakenText ??= prettyTime(process.hrtime(start)); + + if (totalTasks === 0) { + console.log(`\n${output.applyNxPrefix('gray', 'No tasks were run')}\n`); + return; + } + + if (isRunOne) { + printRunOneSummary(); + } else { + printRunManySummary(); + } + }; + + const printRunOneSummary = () => { + let lines: string[] = []; + const failure = totalSuccessfulTasks !== totalTasks; + + // Prints task outputs in the order they were completed + // above the summary, since run-one should print all task results. + for (const taskId of taskIdsInOrderOfCompletion) { + const { terminalOutput, taskStatus } = tasksToTerminalOutputs[taskId]; + output.logCommandOutput(taskId, taskStatus, terminalOutput); + } + + lines.push(...output.getVerticalSeparatorLines(failure ? 'red' : 'green')); + + if (!failure) { + const text = `Successfully ran ${formatTargetsAndProjects( + [initiatingProject], + targets, + tasks + )}`; + + const taskOverridesLines = []; + if (Object.keys(overrides).length > 0) { + taskOverridesLines.push(''); + taskOverridesLines.push( + `${EXTENDED_LEFT_PAD}${output.dim.green('With additional flags:')}` + ); + Object.entries(overrides) + .map(([flag, value]) => + output.dim.green(formatFlags(EXTENDED_LEFT_PAD, flag, value)) + ) + .forEach((arg) => taskOverridesLines.push(arg)); + } + + lines.push( + output.applyNxPrefix( + 'green', + output.colors.green(text) + output.dim(` (${timeTakenText})`) + ), + ...taskOverridesLines + ); + + if (totalCachedTasks > 0) { + lines.push( + output.dim( + `${EOL}Nx read the output from the cache instead of running the command for ${totalCachedTasks} out of ${totalTasks} tasks.` + ) + ); + } + lines = [output.colors.green(lines.join(EOL))]; + } else if (totalCompletedTasks === totalTasks) { + let text = `Ran target ${output.bold( + targets[0] + )} for project ${output.bold(initiatingProject)}`; + if (tasks.length > 1) { + text += ` and ${output.bold(tasks.length - 1)} task(s) they depend on`; + } + + const taskOverridesLines = []; + if (Object.keys(overrides).length > 0) { + taskOverridesLines.push(''); + taskOverridesLines.push( + `${EXTENDED_LEFT_PAD}${output.dim.red('With additional flags:')}` + ); + Object.entries(overrides) + .map(([flag, value]) => + output.dim.red(formatFlags(EXTENDED_LEFT_PAD, flag, value)) + ) + .forEach((arg) => taskOverridesLines.push(arg)); + } + + const viewLogs = viewLogsFooterRows(totalFailedTasks); + + lines = [ + output.colors.red([ + output.applyNxPrefix( + 'red', + output.colors.red(text) + output.dim(` (${timeTakenText})`) + ), + ...taskOverridesLines, + '', + `${LEFT_PAD}${output.colors.red( + figures.cross + )}${SPACER}${totalFailedTasks}${`/${totalCompletedTasks}`} failed`, + `${LEFT_PAD}${output.dim( + figures.tick + )}${SPACER}${totalSuccessfulTasks}${`/${totalCompletedTasks}`} succeeded ${output.dim( + `[${totalCachedTasks} read from cache]` + )}`, + ...viewLogs, + ]), + ]; + } else { + lines = [ + output.colors.red( + output.applyNxPrefix( + 'red', + output.colors.red( + `Cancelled running target ${output.bold( + targets[0] + )} for project ${output.bold(initiatingProject)}` + ) + output.dim(` (${timeTakenText})`) + ) + ), + ]; + } + + // adds some vertical space after the summary to avoid bunching against terminal + lines.push(''); + + console.log(lines.join(EOL)); + }; + + const printRunManySummary = () => { + console.log(''); + + const lines: string[] = []; + const failure = totalSuccessfulTasks !== totalTasks; + + for (const taskId of taskIdsInOrderOfCompletion) { + const { terminalOutput, taskStatus } = tasksToTerminalOutputs[taskId]; + if (taskStatus === 'failure') { + output.logCommandOutput(taskId, taskStatus, terminalOutput); + lines.push( + `${LEFT_PAD}${output.colors.red( + figures.cross + )}${SPACER}${output.colors.gray('nx run ')}${taskId}` + ); + } else { + lines.push( + `${LEFT_PAD}${output.colors.green( + figures.tick + )}${SPACER}${output.colors.gray('nx run ')}${taskId}` + ); + } + } + + lines.push(...output.getVerticalSeparatorLines(failure ? 'red' : 'green')); + + if (totalSuccessfulTasks === totalTasks) { + const successSummaryRows = []; + const text = `Successfully ran ${formatTargetsAndProjects( + projectNames, + targets, + tasks + )}`; + const taskOverridesRows = []; + if (Object.keys(overrides).length > 0) { + taskOverridesRows.push(''); + taskOverridesRows.push( + `${EXTENDED_LEFT_PAD}${output.dim.green('With additional flags:')}` + ); + Object.entries(overrides) + .map(([flag, value]) => + output.dim.green(formatFlags(EXTENDED_LEFT_PAD, flag, value)) + ) + .forEach((arg) => taskOverridesRows.push(arg)); + } + + successSummaryRows.push( + ...[ + output.applyNxPrefix( + 'green', + output.colors.green(text) + output.dim.white(` (${timeTakenText})`) + ), + ...taskOverridesRows, + ] + ); + if (totalCachedTasks > 0) { + successSummaryRows.push( + output.dim( + `${EOL}Nx read the output from the cache instead of running the command for ${totalCachedTasks} out of ${totalTasks} tasks.` + ) + ); + } + lines.push(successSummaryRows.join(EOL)); + } else { + const text = `${ + inProgressTasks.size ? 'Cancelled while running' : 'Ran' + } ${formatTargetsAndProjects(projectNames, targets, tasks)}`; + const taskOverridesRows = []; + if (Object.keys(overrides).length > 0) { + taskOverridesRows.push(''); + taskOverridesRows.push( + `${EXTENDED_LEFT_PAD}${output.dim.red('With additional flags:')}` + ); + Object.entries(overrides) + .map(([flag, value]) => + output.dim.red(formatFlags(EXTENDED_LEFT_PAD, flag, value)) + ) + .forEach((arg) => taskOverridesRows.push(arg)); + } + + const numFailedToPrint = 5; + const failedTasksForPrinting = Array.from(failedTasks).slice( + 0, + numFailedToPrint + ); + const failureSummaryRows = [ + output.applyNxPrefix( + 'red', + output.colors.red(text) + output.dim.white(` (${timeTakenText})`) + ), + ...taskOverridesRows, + '', + ]; + if (totalCompletedTasks > 0) { + if (totalSuccessfulTasks > 0) { + failureSummaryRows.push( + output.dim( + `${LEFT_PAD}${output.dim( + figures.tick + )}${SPACER}${totalSuccessfulTasks}${`/${totalCompletedTasks}`} succeeded ${output.dim( + `[${totalCachedTasks} read from cache]` + )}` + ), + '' + ); + } + if (totalFailedTasks > 0) { + failureSummaryRows.push( + `${LEFT_PAD}${output.colors.red( + figures.cross + )}${SPACER}${totalFailedTasks}${`/${totalCompletedTasks}`} targets failed, including the following:`, + '', + `${failedTasksForPrinting + .map( + (t) => + `${EXTENDED_LEFT_PAD}${output.colors.red( + '-' + )} ${output.formatCommand(t.toString())}` + ) + .join('\n')}`, + '' + ); + if (failedTasks.size > numFailedToPrint) { + failureSummaryRows.push( + output.dim( + `${EXTENDED_LEFT_PAD}...and ${ + failedTasks.size - numFailedToPrint + } more...` + ) + ); + } + } + if (totalCompletedTasks !== totalTasks) { + const remainingTasks = totalTasks - totalCompletedTasks; + if (inProgressTasks.size) { + failureSummaryRows.push( + `${LEFT_PAD}${output.colors.red(figures.ellipsis)}${SPACER}${ + inProgressTasks.size + }${`/${totalTasks}`} targets were in progress, including the following:`, + '', + `${Array.from(inProgressTasks) + .map( + (t) => + `${EXTENDED_LEFT_PAD}${output.colors.red( + '-' + )} ${output.formatCommand(t.toString())}` + ) + .join(EOL)}`, + '' + ); + } + if (remainingTasks - inProgressTasks.size > 0) { + failureSummaryRows.push( + output.dim( + `${LEFT_PAD}${output.colors.red(figures.ellipsis)}${SPACER}${ + remainingTasks - inProgressTasks.size + }${`/${totalTasks}`} targets had not started.` + ), + '' + ); + } + } + + failureSummaryRows.push(...viewLogsFooterRows(failedTasks.size)); + + lines.push(output.colors.red(failureSummaryRows.join(EOL))); + } + } + + // adds some vertical space after the summary to avoid bunching against terminal + lines.push(''); + + console.log(lines.join(EOL)); + }; + return { lifeCycle, printSummary }; +} diff --git a/packages/nx/src/tasks-runner/pseudo-terminal.spec.ts b/packages/nx/src/tasks-runner/pseudo-terminal.spec.ts index def3967731..114c4b3381 100644 --- a/packages/nx/src/tasks-runner/pseudo-terminal.spec.ts +++ b/packages/nx/src/tasks-runner/pseudo-terminal.spec.ts @@ -1,9 +1,9 @@ -import { getPseudoTerminal, PseudoTerminal } from './pseudo-terminal'; +import { createPseudoTerminal, PseudoTerminal } from './pseudo-terminal'; describe('PseudoTerminal', () => { let terminal: PseudoTerminal; - beforeAll(() => { - terminal = getPseudoTerminal(true); + beforeEach(() => { + terminal = createPseudoTerminal(true); }); afterAll(() => { @@ -46,21 +46,6 @@ describe('PseudoTerminal', () => { }); }); - it('should get results', async () => { - const childProcess = terminal.runCommand('echo "hello world"'); - - const results = await childProcess.getResults(); - - expect(results.code).toEqual(0); - expect(results.terminalOutput).toContain('hello world'); - const childProcess2 = terminal.runCommand('echo "hello jason"'); - - const results2 = await childProcess2.getResults(); - - expect(results2.code).toEqual(0); - expect(results2.terminalOutput).toContain('hello jason'); - }); - if (process.env.CI !== 'true') { it('should be tty', (done) => { const childProcess = terminal.runCommand( @@ -72,15 +57,4 @@ describe('PseudoTerminal', () => { }); }); } - - it('should run multiple commands', async () => { - let i = 0; - while (i < 10) { - const childProcess = terminal.runCommand('whoami', {}); - - await childProcess.getResults(); - - i++; - } - }); }); diff --git a/packages/nx/src/tasks-runner/pseudo-terminal.ts b/packages/nx/src/tasks-runner/pseudo-terminal.ts index 2e41d20157..2954baf76a 100644 --- a/packages/nx/src/tasks-runner/pseudo-terminal.ts +++ b/packages/nx/src/tasks-runner/pseudo-terminal.ts @@ -4,19 +4,37 @@ import { getForkedProcessOsSocketPath } from '../daemon/socket-utils'; import { Serializable } from 'child_process'; import * as os from 'os'; -let pseudoTerminal: PseudoTerminal; +// Register single event listeners for all pseudo-terminal instances +const pseudoTerminalShutdownCallbacks: Array<() => void> = []; +process.on('SIGINT', () => { + pseudoTerminalShutdownCallbacks.forEach((cb) => cb()); +}); +process.on('SIGTERM', () => { + pseudoTerminalShutdownCallbacks.forEach((cb) => cb()); +}); +process.on('SIGHUP', () => { + pseudoTerminalShutdownCallbacks.forEach((cb) => cb()); +}); +process.on('exit', () => { + pseudoTerminalShutdownCallbacks.forEach((cb) => cb()); +}); -export function getPseudoTerminal(skipSupportCheck: boolean = false) { +export function createPseudoTerminal(skipSupportCheck: boolean = false) { if (!skipSupportCheck && !PseudoTerminal.isSupported()) { throw new Error('Pseudo terminal is not supported on this platform.'); } - pseudoTerminal ??= new PseudoTerminal(new RustPseudoTerminal()); - + const pseudoTerminal = new PseudoTerminal(new RustPseudoTerminal()); + pseudoTerminalShutdownCallbacks.push( + pseudoTerminal.shutdown.bind(pseudoTerminal) + ); return pseudoTerminal; } +let id = 0; export class PseudoTerminal { - private pseudoIPCPath = getForkedProcessOsSocketPath(process.pid.toString()); + private pseudoIPCPath = getForkedProcessOsSocketPath( + process.pid.toString() + '-' + id++ + ); private pseudoIPC = new PseudoIPCServer(this.pseudoIPCPath); private initialized: boolean = false; @@ -25,9 +43,7 @@ export class PseudoTerminal { return process.stdout.isTTY && supportedPtyPlatform(); } - constructor(private rustPseudoTerminal: RustPseudoTerminal) { - this.setupProcessListeners(); - } + constructor(private rustPseudoTerminal: RustPseudoTerminal) {} async init() { if (this.initialized) { @@ -37,6 +53,12 @@ export class PseudoTerminal { this.initialized = true; } + shutdown() { + if (this.initialized) { + this.pseudoIPC.close(); + } + } + runCommand( command: string, { @@ -54,6 +76,7 @@ export class PseudoTerminal { } = {} ) { return new PseudoTtyProcess( + this.rustPseudoTerminal, this.rustPseudoTerminal.runCommand( command, cwd, @@ -84,6 +107,7 @@ export class PseudoTerminal { throw new Error('Call init() before forking processes'); } const cp = new PseudoTtyProcessWithSend( + this.rustPseudoTerminal, this.rustPseudoTerminal.fork( id, script, @@ -109,30 +133,6 @@ export class PseudoTerminal { onMessageFromChildren(callback: (message: Serializable) => void) { this.pseudoIPC.onMessageFromChildren(callback); } - - private setupProcessListeners() { - const shutdown = () => { - this.shutdownPseudoIPC(); - }; - process.on('SIGINT', () => { - this.shutdownPseudoIPC(); - }); - process.on('SIGTERM', () => { - this.shutdownPseudoIPC(); - }); - process.on('SIGHUP', () => { - this.shutdownPseudoIPC(); - }); - process.on('exit', () => { - this.shutdownPseudoIPC(); - }); - } - - private shutdownPseudoIPC() { - if (this.initialized) { - this.pseudoIPC.close(); - } - } } export class PseudoTtyProcess { @@ -143,7 +143,10 @@ export class PseudoTtyProcess { private terminalOutput = ''; - constructor(private childProcess: ChildProcess) { + constructor( + public rustPseudoTerminal: RustPseudoTerminal, + private childProcess: ChildProcess + ) { childProcess.onOutput((output) => { this.terminalOutput += output; this.outputCallbacks.forEach((cb) => cb(output)); @@ -187,15 +190,20 @@ export class PseudoTtyProcess { } } } + + getParserAndWriter() { + return this.childProcess.getParserAndWriter(); + } } export class PseudoTtyProcessWithSend extends PseudoTtyProcess { constructor( + public rustPseudoTerminal: RustPseudoTerminal, _childProcess: ChildProcess, private id: string, private pseudoIpc: PseudoIPCServer ) { - super(_childProcess); + super(rustPseudoTerminal, _childProcess); } send(message: Serializable) { diff --git a/packages/nx/src/tasks-runner/run-command.ts b/packages/nx/src/tasks-runner/run-command.ts index c978112b0d..3d654ccfd1 100644 --- a/packages/nx/src/tasks-runner/run-command.ts +++ b/packages/nx/src/tasks-runner/run-command.ts @@ -1,6 +1,8 @@ import { prompt } from 'enquirer'; +import { join } from 'node:path'; +import { stripVTControlCharacters } from 'node:util'; import * as ora from 'ora'; -import { join } from 'path'; +import type { Observable } from 'rxjs'; import { NxJsonConfiguration, readNxJson, @@ -16,13 +18,18 @@ import { hashTasksThatDoNotDependOnOutputsOfOtherTasks, } from '../hasher/hash-task'; import { IS_WASM } from '../native'; +import { + runPostTasksExecution, + runPreTasksExecution, +} from '../project-graph/plugins/tasks-execution-hooks'; import { createProjectGraphAsync } from '../project-graph/project-graph'; import { NxArgs } from '../utils/command-line-utils'; import { isRelativePath } from '../utils/fileutils'; +import { handleErrors } from '../utils/handle-errors'; import { isCI } from '../utils/is-ci'; import { isNxCloudUsed } from '../utils/nx-cloud-utils'; +import { printNxKey } from '../utils/nx-key'; import { output } from '../utils/output'; -import { handleErrors } from '../utils/handle-errors'; import { collectEnabledTaskSyncGeneratorsFromTaskGraph, flushSyncGeneratorChanges, @@ -33,7 +40,8 @@ import { processSyncGeneratorResultErrors, } from '../utils/sync-generators'; import { workspaceRoot } from '../utils/workspace-root'; -import { createTaskGraph } from './create-task-graph'; +import { createTaskGraph, createTaskId } from './create-task-graph'; +import { isTuiEnabled } from './is-tui-enabled'; import { CompositeLifeCycle, LifeCycle, @@ -48,8 +56,9 @@ import { StoreRunInformationLifeCycle } from './life-cycles/store-run-informatio import { TaskHistoryLifeCycle } from './life-cycles/task-history-life-cycle'; import { LegacyTaskHistoryLifeCycle } from './life-cycles/task-history-life-cycle-old'; import { TaskProfilingLifeCycle } from './life-cycles/task-profiling-life-cycle'; -import { TaskTimingsLifeCycle } from './life-cycles/task-timings-life-cycle'; import { TaskResultsLifeCycle } from './life-cycles/task-results-life-cycle'; +import { TaskTimingsLifeCycle } from './life-cycles/task-timings-life-cycle'; +import { getTuiTerminalSummaryLifeCycle } from './life-cycles/tui-summary-life-cycle'; import { findCycle, makeAcyclic, @@ -58,21 +67,221 @@ import { import { TasksRunner, TaskStatus } from './tasks-runner'; import { shouldStreamOutput } from './utils'; import chalk = require('chalk'); -import type { Observable } from 'rxjs'; -import { printNxKey } from '../utils/nx-key'; -import { - runPostTasksExecution, - runPreTasksExecution, -} from '../project-graph/plugins/tasks-execution-hooks'; + +const originalStdoutWrite = process.stdout.write.bind(process.stdout); +const originalStderrWrite = process.stderr.write.bind(process.stderr); +const originalConsoleLog = console.log.bind(console); +const originalConsoleError = console.error.bind(console); async function getTerminalOutputLifeCycle( initiatingProject: string, projectNames: string[], tasks: Task[], + taskGraph: TaskGraph, nxArgs: NxArgs, nxJson: NxJsonConfiguration, overrides: Record ): Promise<{ lifeCycle: LifeCycle; renderIsDone: Promise }> { + const overridesWithoutHidden = { ...overrides }; + delete overridesWithoutHidden['__overrides_unparsed__']; + + if (isTuiEnabled(nxJson)) { + const interceptedNxCloudLogs: (string | Uint8Array)[] = []; + + const createPatchedConsoleMethod = ( + originalMethod: typeof console.log | typeof console.error + ): typeof console.log | typeof console.error => { + return (...args: any[]) => { + // Check if the log came from the Nx Cloud client, otherwise invoke the original write method + const stackTrace = new Error().stack; + const isNxCloudLog = stackTrace.includes( + join(workspaceRoot, '.nx', 'cache', 'cloud') + ); + if (!isNxCloudLog) { + return originalMethod(...args); + } + // No-op the Nx Cloud client logs + }; + }; + // The cloud client calls console.log when NX_VERBOSE_LOGGING is set to true + console.log = createPatchedConsoleMethod(originalConsoleLog); + console.error = createPatchedConsoleMethod(originalConsoleError); + + const patchedWrite = (_chunk, _encoding, callback) => { + // Preserve original behavior around callback and return value, just in case + if (callback) { + callback(); + } + return true; + }; + + process.stdout.write = patchedWrite as any; + process.stderr.write = patchedWrite as any; + + const { AppLifeCycle, restoreTerminal } = await import('../native'); + let appLifeCycle; + + const isRunOne = initiatingProject != null; + + const pinnedTasks: string[] = []; + const taskText = tasks.length === 1 ? 'task' : 'tasks'; + const projectText = projectNames.length === 1 ? 'project' : 'projects'; + let titleText = ''; + + if (isRunOne) { + const mainTaskId = createTaskId( + initiatingProject, + nxArgs.targets[0], + nxArgs.configuration + ); + pinnedTasks.push(mainTaskId); + const mainContinuousDependencies = + taskGraph.continuousDependencies[mainTaskId]; + if (mainContinuousDependencies.length > 0) { + pinnedTasks.push(mainContinuousDependencies[0]); + } + const [project, target] = mainTaskId.split(':'); + titleText = `${target} ${project}`; + if (tasks.length > 1) { + titleText += `, and ${tasks.length - 1} requisite ${taskText}`; + } + } else { + titleText = + nxArgs.targets.join(', ') + + ` for ${projectNames.length} ${projectText}`; + if (tasks.length > projectNames.length) { + titleText += `, and ${ + tasks.length - projectNames.length + } requisite ${taskText}`; + } + } + + let resolveRenderIsDonePromise: (value: void) => void; + // Default renderIsDone that will be overridden if the TUI is used + let renderIsDone = new Promise( + (resolve) => (resolveRenderIsDonePromise = resolve) + ); + + const { lifeCycle: tsLifeCycle, printSummary } = + getTuiTerminalSummaryLifeCycle({ + projectNames, + tasks, + args: nxArgs, + overrides: overridesWithoutHidden, + initiatingProject, + resolveRenderIsDonePromise, + }); + + if (tasks.length === 0) { + renderIsDone = renderIsDone.then(() => { + // Revert the patched methods + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + console.log = originalConsoleLog; + console.error = originalConsoleError; + printSummary(); + }); + } + + const lifeCycles: LifeCycle[] = [tsLifeCycle]; + // Only run the TUI if there are tasks to run + if (tasks.length > 0) { + appLifeCycle = new AppLifeCycle( + tasks, + pinnedTasks, + nxArgs ?? {}, + nxJson.tui ?? {}, + titleText + ); + lifeCycles.unshift(appLifeCycle); + + /** + * Patch stdout.write and stderr.write methods to pass Nx Cloud client logs to the TUI via the lifecycle + */ + const createPatchedLogWrite = ( + originalWrite: typeof process.stdout.write | typeof process.stderr.write + ): typeof process.stdout.write | typeof process.stderr.write => { + // @ts-ignore + return (chunk, encoding, callback) => { + // Check if the log came from the Nx Cloud client, otherwise invoke the original write method + const stackTrace = new Error().stack; + const isNxCloudLog = stackTrace.includes( + join(workspaceRoot, '.nx', 'cache', 'cloud') + ); + if (isNxCloudLog) { + interceptedNxCloudLogs.push(chunk); + // Do not bother to store logs with only whitespace characters, they aren't relevant for the TUI + const trimmedChunk = chunk.toString().trim(); + if (trimmedChunk.length) { + // Remove ANSI escape codes, the TUI will control the formatting + appLifeCycle?.__setCloudMessage( + stripVTControlCharacters(trimmedChunk) + ); + } + } + // Preserve original behavior around callback and return value, just in case + if (callback) { + callback(); + } + return true; + }; + }; + + const createPatchedConsoleMethod = ( + originalMethod: typeof console.log | typeof console.error + ): typeof console.log | typeof console.error => { + return (...args: any[]) => { + // Check if the log came from the Nx Cloud client, otherwise invoke the original write method + const stackTrace = new Error().stack; + const isNxCloudLog = stackTrace.includes( + join(workspaceRoot, '.nx', 'cache', 'cloud') + ); + if (!isNxCloudLog) { + return originalMethod(...args); + } + // No-op the Nx Cloud client logs + }; + }; + + process.stdout.write = createPatchedLogWrite(originalStdoutWrite); + process.stderr.write = createPatchedLogWrite(originalStderrWrite); + + // The cloud client calls console.log when NX_VERBOSE_LOGGING is set to true + console.log = createPatchedConsoleMethod(originalConsoleLog); + console.error = createPatchedConsoleMethod(originalConsoleError); + + renderIsDone = new Promise((resolve) => { + appLifeCycle.__init(() => { + resolve(); + }); + }) + .then(() => { + restoreTerminal(); + }) + .finally(() => { + // Revert the patched methods + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + console.log = originalConsoleLog; + console.error = originalConsoleError; + printSummary(); + // Print the intercepted Nx Cloud logs + for (const log of interceptedNxCloudLogs) { + const logString = log.toString().trimStart(); + process.stdout.write(logString); + if (logString) { + process.stdout.write('\n'); + } + } + }); + } + + return { + lifeCycle: new CompositeLifeCycle(lifeCycles), + renderIsDone, + }; + } + const { runnerOptions } = getRunner(nxArgs, nxJson); const isRunOne = initiatingProject != null; const useDynamicOutput = shouldUseDynamicLifeCycle( @@ -81,9 +290,6 @@ async function getTerminalOutputLifeCycle( nxArgs.outputStyle ); - const overridesWithoutHidden = { ...overrides }; - delete overridesWithoutHidden['__overrides_unparsed__']; - if (isRunOne) { if (useDynamicOutput) { return await createRunOneDynamicOutputRenderer({ @@ -255,6 +461,7 @@ export async function runCommandForTasks( initiatingProject, projectNames, tasks, + taskGraph, nxArgs, nxJson, overrides diff --git a/packages/nx/src/tasks-runner/task-env.ts b/packages/nx/src/tasks-runner/task-env.ts index 462de5cb73..22d66ae976 100644 --- a/packages/nx/src/tasks-runner/task-env.ts +++ b/packages/nx/src/tasks-runner/task-env.ts @@ -37,7 +37,7 @@ export function getEnvVariablesForTask( captureStderr: boolean, outputPath: string, streamOutput: boolean -) { +): NodeJS.ProcessEnv { const res = { // Start With Dotenv Variables ...taskSpecificEnv, @@ -95,7 +95,7 @@ function getNxEnvVariablesForTask( captureStderr: boolean, outputPath: string, streamOutput: boolean -) { +): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { NX_TASK_TARGET_PROJECT: task.target.project, NX_TASK_TARGET_TARGET: task.target.target, @@ -119,6 +119,8 @@ function getNxEnvVariablesForTask( streamOutput ), ...env, + // Ensure the TUI does not get spawned within the TUI if ever tasks invoke Nx again + NX_TUI: 'false', }; } diff --git a/packages/nx/src/tasks-runner/task-orchestrator.ts b/packages/nx/src/tasks-runner/task-orchestrator.ts index dbbe79e648..53f04d18bf 100644 --- a/packages/nx/src/tasks-runner/task-orchestrator.ts +++ b/packages/nx/src/tasks-runner/task-orchestrator.ts @@ -1,13 +1,35 @@ import { defaultMaxListeners } from 'events'; -import { performance } from 'perf_hooks'; -import { relative } from 'path'; import { writeFileSync } from 'fs'; -import { TaskHasher } from '../hasher/task-hasher'; +import { relative } from 'path'; +import { performance } from 'perf_hooks'; +import { NxJsonConfiguration } from '../config/nx-json'; +import { ProjectGraph } from '../config/project-graph'; +import { Task, TaskGraph } from '../config/task-graph'; +import { DaemonClient } from '../daemon/client/client'; import { runCommands } from '../executors/run-commands/run-commands.impl'; -import { ForkedProcessTaskRunner } from './forked-process-task-runner'; +import { getTaskDetails, hashTask } from '../hasher/hash-task'; +import { TaskHasher } from '../hasher/task-hasher'; +import { RunningTasksService, TaskDetails } from '../native'; +import { NxArgs } from '../utils/command-line-utils'; +import { getDbConnection } from '../utils/db-connection'; +import { output } from '../utils/output'; +import { combineOptionsForExecutor } from '../utils/params'; +import { workspaceRoot } from '../utils/workspace-root'; import { Cache, DbCache, dbCacheEnabled, getCache } from './cache'; import { DefaultTasksRunnerOptions } from './default-tasks-runner'; +import { ForkedProcessTaskRunner } from './forked-process-task-runner'; +import { isTuiEnabled } from './is-tui-enabled'; +import { TaskMetadata } from './life-cycle'; +import { PseudoTtyProcess } from './pseudo-terminal'; +import { NoopChildProcess } from './running-tasks/noop-child-process'; +import { RunningTask } from './running-tasks/running-task'; +import { + getEnvVariablesForBatchProcess, + getEnvVariablesForTask, + getTaskSpecificEnv, +} from './task-env'; import { TaskStatus } from './tasks-runner'; +import { Batch, TasksSchedule } from './tasks-schedule'; import { calculateReverseDeps, getExecutorForTask, @@ -17,31 +39,15 @@ import { removeTasksFromTaskGraph, shouldStreamOutput, } from './utils'; -import { Batch, TasksSchedule } from './tasks-schedule'; -import { TaskMetadata } from './life-cycle'; -import { ProjectGraph } from '../config/project-graph'; -import { Task, TaskGraph } from '../config/task-graph'; -import { DaemonClient } from '../daemon/client/client'; -import { getTaskDetails, hashTask } from '../hasher/hash-task'; -import { - getEnvVariablesForBatchProcess, - getEnvVariablesForTask, - getTaskSpecificEnv, -} from './task-env'; -import { workspaceRoot } from '../utils/workspace-root'; -import { output } from '../utils/output'; -import { combineOptionsForExecutor } from '../utils/params'; -import { NxJsonConfiguration } from '../config/nx-json'; -import { RunningTasksService, type TaskDetails } from '../native'; -import { NoopChildProcess } from './running-tasks/noop-child-process'; -import { RunningTask } from './running-tasks/running-task'; -import { NxArgs } from '../utils/command-line-utils'; -import { getDbConnection } from '../utils/db-connection'; export class TaskOrchestrator { private taskDetails: TaskDetails | null = getTaskDetails(); private cache: DbCache | Cache = getCache(this.options); - private forkedProcessTaskRunner = new ForkedProcessTaskRunner(this.options); + private readonly tuiEnabled = isTuiEnabled(this.nxJson); + private forkedProcessTaskRunner = new ForkedProcessTaskRunner( + this.options, + this.tuiEnabled + ); private runningTasksService = new RunningTasksService(getDbConnection()); private tasksSchedule = new TasksSchedule( @@ -72,6 +78,7 @@ export class TaskOrchestrator { private runningContinuousTasks = new Map(); private cleaningUp = false; + // endregion internal state constructor( @@ -81,6 +88,7 @@ export class TaskOrchestrator { private readonly taskGraph: TaskGraph, private readonly nxJson: NxJsonConfiguration, private readonly options: NxArgs & DefaultTasksRunnerOptions, + private readonly threadCount: number, private readonly bail: boolean, private readonly daemon: DaemonClient, private readonly outputStyle: string @@ -99,17 +107,13 @@ export class TaskOrchestrator { performance.mark('task-execution:start'); - const threadCount = - this.options.parallel + - Object.values(this.taskGraph.tasks).filter((t) => t.continuous).length; - const threads = []; - process.stdout.setMaxListeners(threadCount + defaultMaxListeners); - process.stderr.setMaxListeners(threadCount + defaultMaxListeners); + process.stdout.setMaxListeners(this.threadCount + defaultMaxListeners); + process.stderr.setMaxListeners(this.threadCount + defaultMaxListeners); // initial seeding of the queue - for (let i = 0; i < threadCount; ++i) { + for (let i = 0; i < this.threadCount; ++i) { threads.push(this.executeNextBatchOfTasksUsingTaskSchedule()); } await Promise.all(threads); @@ -460,7 +464,6 @@ export class TaskOrchestrator { ) { try { const { schema } = getExecutorForTask(task, this.projectGraph); - const isRunOne = this.initiatingProject != null; const combinedOptions = combineOptionsForExecutor( task.overrides, task.target.configuration ?? targetConfiguration.defaultConfiguration, @@ -480,31 +483,54 @@ export class TaskOrchestrator { const args = getPrintableCommandArgsForTask(task); output.logCommand(args.join(' ')); } - const runningTask = await runCommands( - { - ...combinedOptions, - env, - usePty: - isRunOne && - !this.tasksSchedule.hasTasks() && - this.runningContinuousTasks.size === 0, - streamOutput, - }, - { - root: workspaceRoot, // only root is needed in runCommands - } as any - ); + const runCommandsOptions = { + ...combinedOptions, + env, + usePty: + this.tuiEnabled || + (!this.tasksSchedule.hasTasks() && + this.runningContinuousTasks.size === 0), + streamOutput, + }; - runningTask.onExit((code, terminalOutput) => { - if (!streamOutput) { - this.options.lifeCycle.printTaskTerminalOutput( - task, - code === 0 ? 'success' : 'failure', - terminalOutput - ); - writeFileSync(temporaryOutputPath, terminalOutput); + const runningTask = await runCommands(runCommandsOptions, { + root: workspaceRoot, // only root is needed in runCommands + } as any); + + if (this.tuiEnabled && runningTask instanceof PseudoTtyProcess) { + // This is an external of a the pseudo terminal where a task is running and can be passed to the TUI + this.options.lifeCycle.registerRunningTask( + task.id, + runningTask.getParserAndWriter() + ); + } + + if (!streamOutput) { + if (runningTask instanceof PseudoTtyProcess) { + // TODO: shouldn't this be checking if the task is continuous before writing anything to disk or calling printTaskTerminalOutput? + let terminalOutput = ''; + runningTask.onOutput((data) => { + terminalOutput += data; + }); + runningTask.onExit((code) => { + this.options.lifeCycle.printTaskTerminalOutput( + task, + code === 0 ? 'success' : 'failure', + terminalOutput + ); + writeFileSync(temporaryOutputPath, terminalOutput); + }); + } else { + runningTask.onExit((code, terminalOutput) => { + this.options.lifeCycle.printTaskTerminalOutput( + task, + code === 0 ? 'success' : 'failure', + terminalOutput + ); + writeFileSync(temporaryOutputPath, terminalOutput); + }); } - }); + } return runningTask; } catch (e) { @@ -515,6 +541,10 @@ export class TaskOrchestrator { } const terminalOutput = e.stack ?? e.message ?? ''; writeFileSync(temporaryOutputPath, terminalOutput); + return new NoopChildProcess({ + code: 1, + terminalOutput, + }); } } else if (targetConfiguration.executor === 'nx:noop') { writeFileSync(temporaryOutputPath, ''); @@ -524,13 +554,23 @@ export class TaskOrchestrator { }); } else { // cache prep - return await this.runTaskInForkedProcess( + const runningTask = await this.runTaskInForkedProcess( task, env, pipeOutput, temporaryOutputPath, streamOutput ); + + if (this.tuiEnabled && runningTask instanceof PseudoTtyProcess) { + // This is an external of a the pseudo terminal where a task is running and can be passed to the TUI + this.options.lifeCycle.registerRunningTask( + task.id, + runningTask.getParserAndWriter() + ); + } + + return runningTask; } } @@ -545,7 +585,8 @@ export class TaskOrchestrator { const usePtyFork = process.env.NX_NATIVE_COMMAND_RUNNER !== 'false'; // Disable the pseudo terminal if this is a run-many or when running a continuous task as part of a run-one - const disablePseudoTerminal = !this.initiatingProject || task.continuous; + const disablePseudoTerminal = + !this.tuiEnabled && (!this.initiatingProject || task.continuous); // execution const childProcess = usePtyFork ? await this.forkedProcessTaskRunner.forkProcess(task, { diff --git a/packages/nx/src/tasks-runner/utils.ts b/packages/nx/src/tasks-runner/utils.ts index fd74a06e05..b99c4d9e3f 100644 --- a/packages/nx/src/tasks-runner/utils.ts +++ b/packages/nx/src/tasks-runner/utils.ts @@ -1,27 +1,27 @@ -import { output } from '../utils/output'; -import { relative } from 'path'; -import { join } from 'path/posix'; -import { Task, TaskGraph } from '../config/task-graph'; +import { minimatch } from 'minimatch'; +import { relative } from 'node:path'; +import { join } from 'node:path/posix'; +import { getExecutorInformation } from '../command-line/run/executor-utils'; +import { CustomHasher, ExecutorConfig } from '../config/misc-interfaces'; import { ProjectGraph, ProjectGraphProjectNode } from '../config/project-graph'; +import { Task, TaskGraph } from '../config/task-graph'; import { TargetConfiguration, TargetDependencyConfig, } from '../config/workspace-json-project-json'; -import { workspaceRoot } from '../utils/workspace-root'; -import { joinPathFragments } from '../utils/path'; -import { isRelativePath } from '../utils/fileutils'; -import { serializeOverridesIntoCommandLine } from '../utils/serialize-overrides-into-command-line'; -import { splitByColons } from '../utils/split-target'; -import { getExecutorInformation } from '../command-line/run/executor-utils'; -import { CustomHasher, ExecutorConfig } from '../config/misc-interfaces'; -import { readProjectsConfigurationFromProjectGraph } from '../project-graph/project-graph'; -import { findMatchingProjects } from '../utils/find-matching-projects'; -import { minimatch } from 'minimatch'; -import { isGlobPattern } from '../utils/globs'; import { getTransformableOutputs, validateOutputs as nativeValidateOutputs, } from '../native'; +import { readProjectsConfigurationFromProjectGraph } from '../project-graph/project-graph'; +import { isRelativePath } from '../utils/fileutils'; +import { findMatchingProjects } from '../utils/find-matching-projects'; +import { isGlobPattern } from '../utils/globs'; +import { joinPathFragments } from '../utils/path'; +import { serializeOverridesIntoCommandLine } from '../utils/serialize-overrides-into-command-line'; +import { splitByColons } from '../utils/split-target'; +import { workspaceRoot } from '../utils/workspace-root'; +import { isTuiEnabled } from './is-tui-enabled'; export type NormalizedTargetDependencyConfig = TargetDependencyConfig & { projects: string[]; @@ -555,6 +555,8 @@ export function shouldStreamOutput( task: Task, initiatingProject: string | null ): boolean { + // For now, disable streaming output on the JS side when running the TUI + if (isTuiEnabled()) return false; if (process.env.NX_STREAM_OUTPUT === 'true') return true; if (longRunningTask(task)) return true; if (task.target.project === initiatingProject) return true;