feat(core): add the experimental Terminal UI for tasks (#30565)

This commit is contained in:
James Henry 2025-04-09 17:56:55 +01:00 committed by Jason Jean
parent 3794c2f256
commit 6541751aab
66 changed files with 8216 additions and 633 deletions

803
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -106,6 +106,7 @@ Print the task graph to the console:
| `--skipRemoteCache`, `--disableRemoteCache` | boolean | Disables the remote 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`) | | `--skipSync` | boolean | Skips running the sync generators associated with the tasks. (Default: `false`) |
| `--targets`, `--target`, `--t` | string | Tasks to run for affected projects. | | `--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. | | `--uncommitted` | boolean | Uncommitted changes. |
| `--untracked` | boolean | Untracked changes. | | `--untracked` | boolean | Untracked changes. |
| `--verbose` | boolean | Prints additional information about the commands (e.g., stack traces). | | `--verbose` | boolean | Prints additional information about the commands (e.g., stack traces). |

View File

@ -110,5 +110,6 @@ Print the task graph to the console:
| `--skipRemoteCache`, `--disableRemoteCache` | boolean | Disables the remote 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`) | | `--skipSync` | boolean | Skips running the sync generators associated with the tasks. (Default: `false`) |
| `--targets`, `--target`, `--t` | string | Tasks to run for affected projects. | | `--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). | | `--verbose` | boolean | Prints additional information about the commands (e.g., stack traces). |
| `--version` | boolean | Show version number. | | `--version` | boolean | Show version number. |

View File

@ -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`) | | `--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`) | | `--skipRemoteCache`, `--disableRemoteCache` | boolean | Disables the remote cache. (Default: `false`) |
| `--skipSync` | boolean | Skips running the sync generators associated with the tasks. (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). | | `--verbose` | boolean | Prints additional information about the commands (e.g., stack traces). |
| `--version` | boolean | Show version number. | | `--version` | boolean | Show version number. |

View File

@ -42,6 +42,7 @@ Nx.json configuration
- [sync](../../devkit/documents/NxJsonConfiguration#sync): NxSyncConfiguration - [sync](../../devkit/documents/NxJsonConfiguration#sync): NxSyncConfiguration
- [targetDefaults](../../devkit/documents/NxJsonConfiguration#targetdefaults): TargetDefaults - [targetDefaults](../../devkit/documents/NxJsonConfiguration#targetdefaults): TargetDefaults
- [tasksRunnerOptions](../../devkit/documents/NxJsonConfiguration#tasksrunneroptions): Object - [tasksRunnerOptions](../../devkit/documents/NxJsonConfiguration#tasksrunneroptions): Object
- [tui](../../devkit/documents/NxJsonConfiguration#tui): Object
- [useDaemonProcess](../../devkit/documents/NxJsonConfiguration#usedaemonprocess): boolean - [useDaemonProcess](../../devkit/documents/NxJsonConfiguration#usedaemonprocess): boolean
- [useInferencePlugins](../../devkit/documents/NxJsonConfiguration#useinferenceplugins): boolean - [useInferencePlugins](../../devkit/documents/NxJsonConfiguration#useinferenceplugins): boolean
- [useLegacyCache](../../devkit/documents/NxJsonConfiguration#uselegacycache): 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 ### useDaemonProcess
`Optional` **useDaemonProcess**: `boolean` `Optional` **useDaemonProcess**: `boolean`

View File

@ -41,6 +41,7 @@ use ProjectsConfigurations or NxJsonConfiguration
- [sync](../../devkit/documents/Workspace#sync): NxSyncConfiguration - [sync](../../devkit/documents/Workspace#sync): NxSyncConfiguration
- [targetDefaults](../../devkit/documents/Workspace#targetdefaults): TargetDefaults - [targetDefaults](../../devkit/documents/Workspace#targetdefaults): TargetDefaults
- [tasksRunnerOptions](../../devkit/documents/Workspace#tasksrunneroptions): Object - [tasksRunnerOptions](../../devkit/documents/Workspace#tasksrunneroptions): Object
- [tui](../../devkit/documents/Workspace#tui): Object
- [useDaemonProcess](../../devkit/documents/Workspace#usedaemonprocess): boolean - [useDaemonProcess](../../devkit/documents/Workspace#usedaemonprocess): boolean
- [useInferencePlugins](../../devkit/documents/Workspace#useinferenceplugins): boolean - [useInferencePlugins](../../devkit/documents/Workspace#useinferenceplugins): boolean
- [useLegacyCache](../../devkit/documents/Workspace#uselegacycache): 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 ### useDaemonProcess
`Optional` **useDaemonProcess**: `boolean` `Optional` **useDaemonProcess**: `boolean`

View File

@ -106,6 +106,7 @@ Print the task graph to the console:
| `--skipRemoteCache`, `--disableRemoteCache` | boolean | Disables the remote 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`) | | `--skipSync` | boolean | Skips running the sync generators associated with the tasks. (Default: `false`) |
| `--targets`, `--target`, `--t` | string | Tasks to run for affected projects. | | `--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. | | `--uncommitted` | boolean | Uncommitted changes. |
| `--untracked` | boolean | Untracked changes. | | `--untracked` | boolean | Untracked changes. |
| `--verbose` | boolean | Prints additional information about the commands (e.g., stack traces). | | `--verbose` | boolean | Prints additional information about the commands (e.g., stack traces). |

View File

@ -110,5 +110,6 @@ Print the task graph to the console:
| `--skipRemoteCache`, `--disableRemoteCache` | boolean | Disables the remote 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`) | | `--skipSync` | boolean | Skips running the sync generators associated with the tasks. (Default: `false`) |
| `--targets`, `--target`, `--t` | string | Tasks to run for affected projects. | | `--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). | | `--verbose` | boolean | Prints additional information about the commands (e.g., stack traces). |
| `--version` | boolean | Show version number. | | `--version` | boolean | Show version number. |

View File

@ -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`) | | `--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`) | | `--skipRemoteCache`, `--disableRemoteCache` | boolean | Disables the remote cache. (Default: `false`) |
| `--skipSync` | boolean | Skips running the sync generators associated with the tasks. (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). | | `--verbose` | boolean | Prints additional information about the commands (e.g., stack traces). |
| `--version` | boolean | Show version number. | | `--version` | boolean | Show version number. |

View File

@ -13,50 +13,62 @@ strip = "none"
[dependencies] [dependencies]
anyhow = "1.0.71" anyhow = "1.0.71"
arboard = "3.4.1"
better-panic = "0.3.0"
colored = "2" colored = "2"
color-eyre = "0.6.3"
crossbeam-channel = '0.5' crossbeam-channel = '0.5'
dashmap = { version = "5.5.3", features = ["rayon"] } dashmap = { version = "5.5.3", features = ["rayon"] }
dunce = "1" dunce = "1"
flate2 = "1.1.1" flate2 = "1.1.1"
fs_extra = "1.3.0" fs_extra = "1.3.0"
futures = "0.3.28"
globset = "0.4.10" globset = "0.4.10"
hashbrown = { version = "0.14.5", features = ["rayon", "rkyv"] } hashbrown = { version = "0.14.5", features = ["rayon", "rkyv"] }
ignore = '0.4' ignore = '0.4'
itertools = "0.10.5" itertools = "0.10.5"
once_cell = "1.18.0" once_cell = "1.18.0"
parking_lot = { version = "0.12.1", features = ["send_guard"] } parking_lot = { version = "0.12.1", features = ["send_guard"] }
napi = { version = '2.16.0', default-features = false, features = [ napi = { version = "2.16.0", default-features = false, features = [
'anyhow', "anyhow",
'napi4', "napi4",
'tokio_rt', "tokio_rt",
"async",
"chrono_date",
] } ] }
napi-derive = '2.16.0' napi-derive = '2.16.0'
nom = '7.1.3' nom = '7.1.3'
regex = "1.9.1" regex = "1.9.1"
ratatui = { version = "0.29", features = ["scrolling-regions"] }
rayon = "1.7.0" rayon = "1.7.0"
rkyv = { version = "0.7", features = ["validation"] } 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_common = "0.31.16"
swc_ecma_parser = { version = "0.137.1", features = ["typescript"] } swc_ecma_parser = { version = "0.137.1", features = ["typescript"] }
swc_ecma_visit = "0.93.0" swc_ecma_visit = "0.93.0"
swc_ecma_ast = "0.107.0" swc_ecma_ast = "0.107.0"
sysinfo = "0.33.1" sysinfo = "0.33.1"
rand = "0.9.0" 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] [target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["fileapi"] } winapi = { version = "0.3", features = ["fileapi"] }
[target.'cfg(all(not(windows), not(target_family = "wasm")))'.dependencies] [target.'cfg(all(not(windows), not(target_family = "wasm")))'.dependencies]
mio = "0.8" mio = "0.8"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [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" } portable-pty = { git = "https://github.com/cammisuli/wezterm", rev = "b538ee29e1e89eeb4832fb35ae095564dce34c29" }
crossterm = "0.27.0"
ignore-files = "2.1.0" ignore-files = "2.1.0"
fs4 = "0.12.0" fs4 = "0.12.0"
reqwest = { version = "0.12.15", default-features = false, features = ["rustls-tls"] } 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 # This is only used for unit tests
swc_ecma_dep_graph = "0.109.1" swc_ecma_dep_graph = "0.109.1"
tempfile = "3.13.0" tempfile = "3.13.0"
# We only explicitly use tokio for async tests
tokio = "1.38.0"

View File

@ -77,6 +77,23 @@
"$ref": "#/definitions/plugins" "$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": { "defaultProject": {
"type": "string", "type": "string",
"description": "Default project. When project isn't provided, the default project will be used." "description": "Default project. When project isn't provided, the default project will be used."

View File

@ -83,6 +83,7 @@ export const allowedWorkspaceExtensions = [
'sync', 'sync',
'useLegacyCache', 'useLegacyCache',
'maxCacheSize', 'maxCacheSize',
'tui',
] as const; ] as const;
if (!patched) { if (!patched) {

View File

@ -1,4 +1,5 @@
import { CommandModule } from 'yargs'; import { CommandModule } from 'yargs';
import { handleErrors } from '../../utils/handle-errors';
import { linkToNxDevAndExamples } from '../yargs-utils/documentation'; import { linkToNxDevAndExamples } from '../yargs-utils/documentation';
import { import {
withAffectedOptions, withAffectedOptions,
@ -8,8 +9,8 @@ import {
withOverrides, withOverrides,
withRunOptions, withRunOptions,
withTargetAndConfigurationOption, withTargetAndConfigurationOption,
withTuiOptions,
} from '../yargs-utils/shared-options'; } from '../yargs-utils/shared-options';
import { handleErrors } from '../../utils/handle-errors';
export const yargsAffectedCommand: CommandModule = { export const yargsAffectedCommand: CommandModule = {
command: 'affected', command: 'affected',
@ -17,9 +18,11 @@ export const yargsAffectedCommand: CommandModule = {
builder: (yargs) => builder: (yargs) =>
linkToNxDevAndExamples( linkToNxDevAndExamples(
withAffectedOptions( withAffectedOptions(
withRunOptions( withTuiOptions(
withOutputStyleOption( withRunOptions(
withTargetAndConfigurationOption(withBatch(yargs)) withOutputStyleOption(
withTargetAndConfigurationOption(withBatch(yargs))
)
) )
) )
) )
@ -56,7 +59,9 @@ export const yargsAffectedTestCommand: CommandModule = {
builder: (yargs) => builder: (yargs) =>
linkToNxDevAndExamples( linkToNxDevAndExamples(
withAffectedOptions( withAffectedOptions(
withRunOptions(withOutputStyleOption(withConfiguration(yargs))) withTuiOptions(
withRunOptions(withOutputStyleOption(withConfiguration(yargs)))
)
), ),
'affected' 'affected'
), ),
@ -80,7 +85,9 @@ export const yargsAffectedBuildCommand: CommandModule = {
builder: (yargs) => builder: (yargs) =>
linkToNxDevAndExamples( linkToNxDevAndExamples(
withAffectedOptions( withAffectedOptions(
withRunOptions(withOutputStyleOption(withConfiguration(yargs))) withTuiOptions(
withRunOptions(withOutputStyleOption(withConfiguration(yargs)))
)
), ),
'affected' 'affected'
), ),
@ -104,7 +111,9 @@ export const yargsAffectedLintCommand: CommandModule = {
builder: (yargs) => builder: (yargs) =>
linkToNxDevAndExamples( linkToNxDevAndExamples(
withAffectedOptions( withAffectedOptions(
withRunOptions(withOutputStyleOption(withConfiguration(yargs))) withTuiOptions(
withRunOptions(withOutputStyleOption(withConfiguration(yargs)))
)
), ),
'affected' 'affected'
), ),
@ -128,7 +137,9 @@ export const yargsAffectedE2ECommand: CommandModule = {
builder: (yargs) => builder: (yargs) =>
linkToNxDevAndExamples( linkToNxDevAndExamples(
withAffectedOptions( withAffectedOptions(
withRunOptions(withOutputStyleOption(withConfiguration(yargs))) withTuiOptions(
withRunOptions(withOutputStyleOption(withConfiguration(yargs)))
)
), ),
'affected' 'affected'
), ),

View File

@ -2,12 +2,13 @@ import { CommandModule } from 'yargs';
import { import {
withOverrides, withOverrides,
withRunManyOptions, withRunManyOptions,
withTuiOptions,
} from '../yargs-utils/shared-options'; } from '../yargs-utils/shared-options';
export const yargsExecCommand: CommandModule = { export const yargsExecCommand: CommandModule = {
command: 'exec', command: 'exec',
describe: 'Executes any command as if it was a target on the project.', 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) => { handler: async (args) => {
try { try {
await (await import('./exec')).nxExecCommand(withOverrides(args) as any); await (await import('./exec')).nxExecCommand(withOverrides(args) as any);

View File

@ -268,7 +268,9 @@ async function runPublishOnProjects(
/** /**
* Run the relevant nx-release-publish executor on each of the selected projects. * 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( const commandResults = await runCommandForTasks(
projectsWithTarget, projectsWithTarget,
projectGraph, projectGraph,

View File

@ -1,22 +1,25 @@
import { CommandModule } from 'yargs'; import { CommandModule } from 'yargs';
import { handleErrors } from '../../utils/handle-errors';
import { linkToNxDevAndExamples } from '../yargs-utils/documentation'; import { linkToNxDevAndExamples } from '../yargs-utils/documentation';
import { import {
withRunManyOptions,
withOutputStyleOption,
withTargetAndConfigurationOption,
withOverrides,
withBatch, withBatch,
withOutputStyleOption,
withOverrides,
withRunManyOptions,
withTargetAndConfigurationOption,
withTuiOptions,
} from '../yargs-utils/shared-options'; } from '../yargs-utils/shared-options';
import { handleErrors } from '../../utils/handle-errors';
export const yargsRunManyCommand: CommandModule = { export const yargsRunManyCommand: CommandModule = {
command: 'run-many', command: 'run-many',
describe: 'Run target for multiple listed projects.', describe: 'Run target for multiple listed projects.',
builder: (yargs) => builder: (yargs) =>
linkToNxDevAndExamples( linkToNxDevAndExamples(
withRunManyOptions( withTuiOptions(
withOutputStyleOption( withRunManyOptions(
withTargetAndConfigurationOption(withBatch(yargs)) withOutputStyleOption(
withTargetAndConfigurationOption(withBatch(yargs))
)
) )
), ),
'run-many' 'run-many'

View File

@ -1,10 +1,11 @@
import { CommandModule, showHelp } from 'yargs'; import { CommandModule, showHelp } from 'yargs';
import { handleErrors } from '../../utils/handle-errors';
import { import {
withBatch, withBatch,
withOverrides, withOverrides,
withRunOneOptions, withRunOneOptions,
withTuiOptions,
} from '../yargs-utils/shared-options'; } from '../yargs-utils/shared-options';
import { handleErrors } from '../../utils/handle-errors';
export const yargsRunCommand: CommandModule = { export const yargsRunCommand: CommandModule = {
command: 'run [project][:target][:configuration] [_..]', command: 'run [project][:target][:configuration] [_..]',
@ -15,7 +16,7 @@ export const yargsRunCommand: CommandModule = {
(e.g., nx serve myapp --configuration=production) (e.g., nx serve myapp --configuration=production)
You can skip the use of Nx cache by using the --skip-nx-cache option.`, 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) => { handler: async (args) => {
const exitCode = await handleErrors( const exitCode = await handleErrors(
(args.verbose as boolean) ?? process.env.NX_VERBOSE_LOGGING === 'true', (args.verbose as boolean) ?? process.env.NX_VERBOSE_LOGGING === 'true',

View File

@ -20,7 +20,7 @@ import {
} from '../../utils/async-iterator'; } from '../../utils/async-iterator';
import { getExecutorInformation } from './executor-utils'; import { getExecutorInformation } from './executor-utils';
import { import {
getPseudoTerminal, createPseudoTerminal,
PseudoTerminal, PseudoTerminal,
} from '../../tasks-runner/pseudo-terminal'; } from '../../tasks-runner/pseudo-terminal';
import { exec } from 'child_process'; import { exec } from 'child_process';
@ -124,7 +124,7 @@ async function printTargetRunHelpInternal(
...localEnv, ...localEnv,
}; };
if (PseudoTerminal.isSupported()) { if (PseudoTerminal.isSupported()) {
const terminal = getPseudoTerminal(); const terminal = createPseudoTerminal();
await new Promise(() => { await new Promise(() => {
const cp = terminal.runCommand(helpCommand, { jsEnv: env }); const cp = terminal.runCommand(helpCommand, { jsEnv: env });
cp.onExit((code) => { cp.onExit((code) => {

View File

@ -41,6 +41,31 @@ export interface RunOptions {
skipSync: boolean; skipSync: boolean;
} }
export interface TuiOptions {
tuiAutoExit: boolean | number;
}
export function withTuiOptions<T>(yargs: Argv<T>): Argv<T & TuiOptions> {
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<T & TuiOptions>;
}
export function withRunOptions<T>(yargs: Argv<T>): Argv<T & RunOptions> { export function withRunOptions<T>(yargs: Argv<T>): Argv<T & RunOptions> {
return withVerbose(withExcludeOption(yargs)) return withVerbose(withExcludeOption(yargs))
.option('parallel', { .option('parallel', {
@ -112,7 +137,6 @@ export function withRunOptions<T>(yargs: Argv<T>): Argv<T & RunOptions> {
type: 'boolean', type: 'boolean',
hidden: true, hidden: true,
}) })
.options('dte', { .options('dte', {
type: 'boolean', type: 'boolean',
hidden: true, hidden: true,

View File

@ -654,6 +654,24 @@ export interface NxJsonConfiguration<T = '*' | string[]> {
* 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. * 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; 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; export type PluginConfiguration = string | ExpandedPluginConfiguration;

View File

@ -1,12 +1,18 @@
import { Serializable } from 'child_process'; import { Serializable } from 'child_process';
import * as yargsParser from 'yargs-parser'; import * as yargsParser from 'yargs-parser';
import { ExecutorContext } from '../../config/misc-interfaces'; import { ExecutorContext } from '../../config/misc-interfaces';
import { isTuiEnabled } from '../../tasks-runner/is-tui-enabled';
import { import {
getPseudoTerminal, createPseudoTerminal,
PseudoTerminal, PseudoTerminal,
PseudoTtyProcess,
} from '../../tasks-runner/pseudo-terminal'; } from '../../tasks-runner/pseudo-terminal';
import { signalToCode } from '../../utils/exit-codes'; 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 const LARGE_BUFFER = 1024 * 1000000;
export type Json = { export type Json = {
@ -65,10 +71,7 @@ const propKeys = [
]; ];
export interface NormalizedRunCommandsOptions extends RunCommandsOptions { export interface NormalizedRunCommandsOptions extends RunCommandsOptions {
commands: { commands: Array<RunCommandsCommandOptions>;
command: string;
forwardAllArgs?: boolean;
}[];
unknownOptions?: { unknownOptions?: {
[k: string]: any; [k: string]: any;
}; };
@ -120,17 +123,26 @@ export async function runCommands(
); );
} }
const pseudoTerminal = const isSingleCommand = normalized.commands.length === 1;
!options.parallel && PseudoTerminal.isSupported()
? getPseudoTerminal() const usePseudoTerminal =
: null; (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 { try {
const runningTask = options.parallel const runningTask = isSingleCommandAndCanUsePseudoTerminal
? new ParallelRunningTasks(normalized, context) ? await runSingleCommandWithPseudoTerminal(normalized, context)
: new SeriallyRunningTasks(normalized, context, pseudoTerminal); : options.parallel
? new ParallelRunningTasks(normalized, context, tuiEnabled)
registerProcessListener(runningTask, pseudoTerminal); : new SeriallyRunningTasks(normalized, context, tuiEnabled);
return runningTask; return runningTask;
} catch (e) { } catch (e) {
if (process.env.NX_VERBOSE_LOGGING === 'true') { if (process.env.NX_VERBOSE_LOGGING === 'true') {
@ -322,48 +334,6 @@ function filterPropKeysFromUnParsedOptions(
return parsedOptions; 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 { function wrapArgIntoQuotesIfNeeded(arg: string): string {
if (arg.includes('=')) { if (arg.includes('=')) {
const [key, value] = arg.split('='); const [key, value] = arg.split('=');

View File

@ -1,24 +1,25 @@
import * as chalk from 'chalk';
import { ChildProcess, exec, Serializable } from 'child_process'; 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 { ExecutorContext } from '../../config/misc-interfaces';
import { import {
LARGE_BUFFER, createPseudoTerminal,
NormalizedRunCommandsOptions,
RunCommandsCommandOptions,
RunCommandsOptions,
} from './run-commands.impl';
import {
PseudoTerminal, PseudoTerminal,
PseudoTtyProcess, PseudoTtyProcess,
} from '../../tasks-runner/pseudo-terminal'; } from '../../tasks-runner/pseudo-terminal';
import { isAbsolute, join } from 'path'; import { RunningTask } from '../../tasks-runner/running-tasks/running-task';
import * as chalk from 'chalk';
import { env as appendLocalEnv } from 'npm-run-path';
import { import {
loadAndExpandDotEnvFile, loadAndExpandDotEnvFile,
unloadDotEnvFile, unloadDotEnvFile,
} from '../../tasks-runner/task-env'; } 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 { export class ParallelRunningTasks implements RunningTask {
private readonly childProcesses: RunningNodeProcess[]; private readonly childProcesses: RunningNodeProcess[];
@ -28,7 +29,11 @@ export class ParallelRunningTasks implements RunningTask {
private exitCallbacks: Array<(code: number, terminalOutput: string) => void> = 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( this.childProcesses = options.commands.map(
(commandConfig) => (commandConfig) =>
new RunningNodeProcess( new RunningNodeProcess(
@ -160,7 +165,7 @@ export class SeriallyRunningTasks implements RunningTask {
constructor( constructor(
options: NormalizedRunCommandsOptions, options: NormalizedRunCommandsOptions,
context: ExecutorContext, context: ExecutorContext,
private pseudoTerminal?: PseudoTerminal private readonly tuiEnabled: boolean
) { ) {
this.run(options, context) this.run(options, context)
.catch((e) => { .catch((e) => {
@ -204,11 +209,9 @@ export class SeriallyRunningTasks implements RunningTask {
for (const c of options.commands) { for (const c of options.commands) {
const childProcess = await this.createProcess( const childProcess = await this.createProcess(
c, c,
[],
options.color, options.color,
calculateCwd(options.cwd, context), calculateCwd(options.cwd, context),
options.processEnv ?? options.env ?? {}, options.processEnv ?? options.env ?? {},
false,
options.usePty, options.usePty,
options.streamOutput, options.streamOutput,
options.tty, options.tty,
@ -235,11 +238,9 @@ export class SeriallyRunningTasks implements RunningTask {
private async createProcess( private async createProcess(
commandConfig: RunCommandsCommandOptions, commandConfig: RunCommandsCommandOptions,
readyWhenStatus: { stringToMatch: string; found: boolean }[] = [],
color: boolean, color: boolean,
cwd: string, cwd: string,
env: Record<string, string>, env: Record<string, string>,
isParallel: boolean,
usePty: boolean = true, usePty: boolean = true,
streamOutput: boolean = true, streamOutput: boolean = true,
tty: boolean, 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 // 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 // currently does not work properly in windows
if ( if (
this.pseudoTerminal &&
process.env.NX_NATIVE_COMMAND_RUNNER !== 'false' && process.env.NX_NATIVE_COMMAND_RUNNER !== 'false' &&
!commandConfig.prefix && !commandConfig.prefix &&
readyWhenStatus.length === 0 && usePty &&
!isParallel && PseudoTerminal.isSupported()
usePty
) { ) {
const pseudoTerminal = createPseudoTerminal();
registerProcessListener(this, pseudoTerminal);
return createProcessWithPseudoTty( return createProcessWithPseudoTty(
this.pseudoTerminal, pseudoTerminal,
commandConfig, commandConfig,
color, color,
cwd, cwd,
@ -272,7 +274,7 @@ export class SeriallyRunningTasks implements RunningTask {
color, color,
cwd, cwd,
env, env,
readyWhenStatus, [],
streamOutput, streamOutput,
envFile envFile
); );
@ -393,14 +395,28 @@ class RunningNodeProcess implements RunningTask {
} }
} }
export async function runSingleCommandWithPseudoTerminal(
normalized: NormalizedRunCommandsOptions,
context: ExecutorContext
): Promise<PseudoTtyProcess> {
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( async function createProcessWithPseudoTty(
pseudoTerminal: PseudoTerminal, pseudoTerminal: PseudoTerminal,
commandConfig: { commandConfig: RunCommandsCommandOptions,
command: string;
color?: string;
bgColor?: string;
prefix?: string;
},
color: boolean, color: boolean,
cwd: string, cwd: string,
env: Record<string, string>, env: Record<string, string>,
@ -408,23 +424,12 @@ async function createProcessWithPseudoTty(
tty: boolean, tty: boolean,
envFile?: string envFile?: string
) { ) {
let terminalOutput = chalk.dim('> ') + commandConfig.command + '\r\n\r\n'; return pseudoTerminal.runCommand(commandConfig.command, {
if (streamOutput) {
process.stdout.write(terminalOutput);
}
env = processEnv(color, cwd, env, envFile);
const childProcess = pseudoTerminal.runCommand(commandConfig.command, {
cwd, cwd,
jsEnv: env, jsEnv: processEnv(color, cwd, env, envFile),
quiet: !streamOutput, quiet: !streamOutput,
tty, tty,
}); });
childProcess.onOutput((output) => {
terminalOutput += output;
});
return childProcess;
} }
function addColorAndPrefix(out: string, config: RunCommandsCommandOptions) { function addColorAndPrefix(out: string, config: RunCommandsCommandOptions) {
@ -517,3 +522,47 @@ function loadEnvVarsFile(path: string, env: Record<string, string> = {}) {
throw result.error; 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
});
}

View File

@ -1,11 +1,11 @@
import { execSync } from 'child_process';
import * as path from 'path'; import * as path from 'path';
import type { ExecutorContext } from '../../config/misc-interfaces'; import type { ExecutorContext } from '../../config/misc-interfaces';
import { getPackageManagerCommand } from '../../utils/package-manager';
import { execSync } from 'child_process';
import { import {
getPseudoTerminal, createPseudoTerminal,
PseudoTerminal, PseudoTerminal,
} from '../../tasks-runner/pseudo-terminal'; } from '../../tasks-runner/pseudo-terminal';
import { getPackageManagerCommand } from '../../utils/package-manager';
export interface RunScriptOptions { export interface RunScriptOptions {
script: string; script: string;
@ -63,7 +63,7 @@ async function ptyProcess(
cwd: string, cwd: string,
env: Record<string, string> env: Record<string, string>
) { ) {
const terminal = getPseudoTerminal(); const terminal = createPseudoTerminal();
return new Promise<void>((res, rej) => { return new Promise<void>((res, rej) => {
const cp = terminal.runCommand(command, { cwd, jsEnv: env }); const cp = terminal.runCommand(command, { cwd, jsEnv: env });

View File

@ -7,7 +7,21 @@ export declare class ExternalObject<T> {
[K: symbol]: T [K: symbol]: T
} }
} }
export declare class AppLifeCycle {
constructor(tasks: Array<Task>, pinnedTasks: Array<string>, tuiCliArgs: TuiCliArgs, tuiConfig: TuiConfig, titleText: string)
startCommand(threadCount?: number | undefined | null): void
scheduleTask(task: Task): void
startTasks(tasks: Array<Task>, metadata: object): void
printTaskTerminalOutput(task: Task, status: string, output: string): void
endTasks(taskResults: Array<TaskResult>, metadata: object): void
endCommand(): void
__init(doneCallback: () => any): void
registerRunningTask(taskId: string, parserAndWriter: ExternalObject<[ParserArc, WriterArc]>): void
__setCloudMessage(message: string): Promise<void>
}
export declare class ChildProcess { export declare class ChildProcess {
getParserAndWriter(): ExternalObject<[ParserArc, WriterArc]>
kill(): void kill(): void
onExit(callback: (message: string) => void): void onExit(callback: (message: string) => void): void
onOutput(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 remove(src: string): void
export declare export declare function restoreTerminal(): void
export interface RuntimeInput { export interface RuntimeInput {
runtime: string runtime: string
} }
@ -269,6 +285,9 @@ export interface Task {
target: TaskTarget target: TaskTarget
outputs: Array<string> outputs: Array<string>
projectRoot?: string projectRoot?: string
startTime?: number
endTime?: number
continuous?: boolean
} }
export interface TaskGraph { export interface TaskGraph {
@ -277,6 +296,13 @@ export interface TaskGraph {
dependencies: Record<string, Array<string>> dependencies: Record<string, Array<string>>
} }
export interface TaskResult {
task: Task
status: string
code: number
terminalOutput?: string
}
export interface TaskRun { export interface TaskRun {
hash: string hash: string
status: string status: string
@ -299,6 +325,15 @@ export declare export declare function testOnlyTransferFileMap(projectFiles: Rec
*/ */
export declare export declare function transferProjectGraph(projectGraph: ProjectGraph): ExternalObject<ProjectGraph> export declare export declare function transferProjectGraph(projectGraph: ProjectGraph): ExternalObject<ProjectGraph>
export interface TuiCliArgs {
targets?: string[] | undefined
tuiAutoExit?: boolean | number | undefined
}
export interface TuiConfig {
autoExit?: boolean | number | undefined
}
export interface UpdatedWorkspaceFiles { export interface UpdatedWorkspaceFiles {
fileMap: FileMap fileMap: FileMap
externalReferences: NxWorkspaceFilesExternals externalReferences: NxWorkspaceFilesExternals

View File

@ -1,9 +1,11 @@
use colored::Colorize; use colored::Colorize;
use std::io::IsTerminal; use std::io::IsTerminal;
use tracing::{Event, Level, Subscriber}; use tracing::{Event, Level, Subscriber};
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::fmt::{format, FmtContext, FormatEvent, FormatFields, FormattedFields}; use tracing_subscriber::fmt::{format, FmtContext, FormatEvent, FormatFields, FormattedFields};
use tracing_subscriber::prelude::*;
use tracing_subscriber::registry::LookupSpan; use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::EnvFilter; use tracing_subscriber::{EnvFilter, Layer};
struct NxLogFormatter; struct NxLogFormatter;
impl<S, N> FormatEvent<S, N> for NxLogFormatter impl<S, N> FormatEvent<S, N> for NxLogFormatter
@ -88,13 +90,30 @@ where
/// - `NX_NATIVE_LOGGING=nx=trace` - enable all logs for the `nx` (this) crate /// - `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=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_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() { pub(crate) fn enable_logger() {
let env_filter = let stdout_layer = tracing_subscriber::fmt::layer()
EnvFilter::try_from_env("NX_NATIVE_LOGGING").unwrap_or_else(|_| EnvFilter::new("ERROR"));
_ = tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_ansi(std::io::stdout().is_terminal()) .with_ansi(std::io::stdout().is_terminal())
.with_writer(std::io::stdout)
.event_format(NxLogFormatter) .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() .try_init()
.ok(); .ok();
} }

View File

@ -17,4 +17,6 @@ pub mod db;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub mod pseudo_terminal; pub mod pseudo_terminal;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub mod tui;
#[cfg(not(target_arch = "wasm32"))]
pub mod watch; pub mod watch;

View File

@ -361,6 +361,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`) throw new Error(`Failed to load native binding`)
} }
module.exports.AppLifeCycle = nativeBinding.AppLifeCycle
module.exports.ChildProcess = nativeBinding.ChildProcess module.exports.ChildProcess = nativeBinding.ChildProcess
module.exports.FileLock = nativeBinding.FileLock module.exports.FileLock = nativeBinding.FileLock
module.exports.HashPlanner = nativeBinding.HashPlanner module.exports.HashPlanner = nativeBinding.HashPlanner
@ -388,6 +389,7 @@ module.exports.hashArray = nativeBinding.hashArray
module.exports.hashFile = nativeBinding.hashFile module.exports.hashFile = nativeBinding.hashFile
module.exports.IS_WASM = nativeBinding.IS_WASM module.exports.IS_WASM = nativeBinding.IS_WASM
module.exports.remove = nativeBinding.remove module.exports.remove = nativeBinding.remove
module.exports.restoreTerminal = nativeBinding.restoreTerminal
module.exports.testOnlyTransferFileMap = nativeBinding.testOnlyTransferFileMap module.exports.testOnlyTransferFileMap = nativeBinding.testOnlyTransferFileMap
module.exports.transferProjectGraph = nativeBinding.transferProjectGraph module.exports.transferProjectGraph = nativeBinding.transferProjectGraph
module.exports.validateOutputs = nativeBinding.validateOutputs module.exports.validateOutputs = nativeBinding.validateOutputs

View File

@ -1,13 +1,18 @@
use crate::native::pseudo_terminal::pseudo_terminal::{ParserArc, WriterArc};
use crossbeam_channel::Sender; use crossbeam_channel::Sender;
use crossbeam_channel::{bounded, Receiver}; use crossbeam_channel::{bounded, Receiver};
use napi::bindgen_prelude::External;
use napi::{ use napi::{
threadsafe_function::{ threadsafe_function::{
ErrorStrategy::Fatal, ThreadsafeFunction, ThreadsafeFunctionCallMode::NonBlocking, ErrorStrategy::Fatal, ThreadsafeFunction, ThreadsafeFunctionCallMode::NonBlocking,
}, },
Env, JsFunction, Env, JsFunction,
}; };
use portable_pty::ChildKiller; use portable_pty::ChildKiller;
use std::io::Write;
use std::sync::{Arc, Mutex, RwLock};
use tracing::warn; use tracing::warn;
use vt100_ctt::Parser;
pub enum ChildProcessMessage { pub enum ChildProcessMessage {
Kill, Kill,
@ -15,19 +20,25 @@ pub enum ChildProcessMessage {
#[napi] #[napi]
pub struct ChildProcess { pub struct ChildProcess {
parser: Arc<RwLock<Parser>>,
process_killer: Box<dyn ChildKiller + Sync + Send>, process_killer: Box<dyn ChildKiller + Sync + Send>,
message_receiver: Receiver<String>, message_receiver: Receiver<String>,
pub(crate) wait_receiver: Receiver<String>, pub(crate) wait_receiver: Receiver<String>,
thread_handles: Vec<Sender<()>>, thread_handles: Vec<Sender<()>>,
writer_arc: Arc<Mutex<Box<dyn Write + Send>>>,
} }
#[napi] #[napi]
impl ChildProcess { impl ChildProcess {
pub fn new( pub fn new(
parser: Arc<RwLock<Parser>>,
writer_arc: Arc<Mutex<Box<dyn Write + Send>>>,
process_killer: Box<dyn ChildKiller + Sync + Send>, process_killer: Box<dyn ChildKiller + Sync + Send>,
message_receiver: Receiver<String>, message_receiver: Receiver<String>,
exit_receiver: Receiver<String>, exit_receiver: Receiver<String>,
) -> Self { ) -> Self {
Self { Self {
parser,
writer_arc,
process_killer, process_killer,
message_receiver, message_receiver,
wait_receiver: exit_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] #[napi]
pub fn kill(&mut self) -> anyhow::Result<()> { pub fn kill(&mut self) -> anyhow::Result<()> {
self.process_killer.kill().map_err(anyhow::Error::from) self.process_killer.kill().map_err(anyhow::Error::from)

View File

@ -1,11 +1,12 @@
use mio::{unix::SourceFd, Events};
use std::{ use std::{
io::{Read, Stdin, Write}, io::{Read, Stdin, Write},
os::fd::AsRawFd, os::fd::AsRawFd,
}; };
use mio::{unix::SourceFd, Events};
use tracing::trace; use tracing::trace;
use super::pseudo_terminal::WriterArc;
pub fn handle_path_space(path: String) -> String { pub fn handle_path_space(path: String) -> String {
if path.contains(' ') { if path.contains(' ') {
format!("'{}'", path) 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 buffer = [0; 1024];
let mut poll = mio::Poll::new()?; 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) => { mio::Token(0) => {
// Read data from stdin // Read data from stdin
loop { loop {
let mut writer = writer.lock().expect("Failed to lock writer");
match stdin.read(&mut buffer) { match stdin.read(&mut buffer) {
Ok(n) => { Ok(n) => {
writer.write_all(&buffer[..n])?; writer.write_all(&buffer[..n])?;

View File

@ -1,9 +1,10 @@
use std::io::{Stdin, Write}; use std::io::{Stdin, Write};
use std::os::windows::ffi::OsStrExt; use std::os::windows::ffi::OsStrExt;
use std::{ffi::OsString, os::windows::ffi::OsStringExt}; use std::{ffi::OsString, os::windows::ffi::OsStringExt};
use winapi::um::fileapi::GetShortPathNameW; use winapi::um::fileapi::GetShortPathNameW;
use super::pseudo_terminal::WriterArc;
pub fn handle_path_space(path: String) -> String { pub fn handle_path_space(path: String) -> String {
let wide: Vec<u16> = std::path::PathBuf::from(&path) let wide: Vec<u16> = std::path::PathBuf::from(&path)
.as_os_str() .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<()> { pub fn write_to_pty(stdin: &mut Stdin, writer: WriterArc) -> anyhow::Result<()> {
std::io::copy(stdin, writer) let mut writer = writer.lock().expect("Failed to lock writer");
std::io::copy(stdin, writer.as_mut())
.map_err(|e| anyhow::anyhow!(e)) .map_err(|e| anyhow::anyhow!(e))
.map(|_| ()) .map(|_| ())
} }

View File

@ -1,26 +1,30 @@
use std::collections::HashMap; use std::collections::HashMap;
use tracing::trace; use tracing::trace;
use super::child_process::ChildProcess; use super::child_process::ChildProcess;
use super::os; use super::os;
use super::pseudo_terminal::{create_pseudo_terminal, run_command}; use super::pseudo_terminal::PseudoTerminal;
use crate::native::logger::enable_logger; use crate::native::logger::enable_logger;
#[napi] #[napi]
pub struct RustPseudoTerminal {} pub struct RustPseudoTerminal {
pseudo_terminal: PseudoTerminal,
}
#[napi] #[napi]
impl RustPseudoTerminal { impl RustPseudoTerminal {
#[napi(constructor)] #[napi(constructor)]
pub fn new() -> napi::Result<Self> { pub fn new() -> napi::Result<Self> {
enable_logger(); enable_logger();
Ok(Self {})
let pseudo_terminal = PseudoTerminal::default()?;
Ok(Self { pseudo_terminal })
} }
#[napi] #[napi]
pub fn run_command( pub fn run_command(
&self, &mut self,
command: String, command: String,
command_dir: Option<String>, command_dir: Option<String>,
js_env: Option<HashMap<String, String>>, js_env: Option<HashMap<String, String>>,
@ -28,9 +32,7 @@ impl RustPseudoTerminal {
quiet: Option<bool>, quiet: Option<bool>,
tty: Option<bool>, tty: Option<bool>,
) -> napi::Result<ChildProcess> { ) -> napi::Result<ChildProcess> {
let pseudo_terminal = create_pseudo_terminal()?; self.pseudo_terminal.run_command(
run_command(
&pseudo_terminal,
command, command,
command_dir, command_dir,
js_env, js_env,
@ -43,9 +45,8 @@ impl RustPseudoTerminal {
/// This allows us to run a pseudoterminal with a fake node ipc channel /// 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 /// this makes it possible to be backwards compatible with the old implementation
#[napi] #[napi]
#[allow(clippy::too_many_arguments)]
pub fn fork( pub fn fork(
&self, &mut self,
id: String, id: String,
fork_script: String, fork_script: String,
pseudo_ipc_path: String, pseudo_ipc_path: String,

View File

@ -3,7 +3,7 @@
mod os; mod os;
#[allow(clippy::module_inception)] #[allow(clippy::module_inception)]
mod pseudo_terminal; pub mod pseudo_terminal;
pub mod child_process; pub mod child_process;

View File

@ -4,7 +4,7 @@ use tracing::trace;
use super::child_process::ChildProcess; use super::child_process::ChildProcess;
use super::os; use super::os;
use super::pseudo_terminal::{create_pseudo_terminal, run_command, PseudoTerminal}; use super::pseudo_terminal::PseudoTerminal;
use crate::native::logger::enable_logger; use crate::native::logger::enable_logger;
#[napi] #[napi]
@ -18,14 +18,14 @@ impl RustPseudoTerminal {
pub fn new() -> napi::Result<Self> { pub fn new() -> napi::Result<Self> {
enable_logger(); enable_logger();
let pseudo_terminal = create_pseudo_terminal()?; let pseudo_terminal = PseudoTerminal::default()?;
Ok(Self { pseudo_terminal }) Ok(Self { pseudo_terminal })
} }
#[napi] #[napi]
pub fn run_command( pub fn run_command(
&self, &mut self,
command: String, command: String,
command_dir: Option<String>, command_dir: Option<String>,
js_env: Option<HashMap<String, String>>, js_env: Option<HashMap<String, String>>,
@ -33,8 +33,7 @@ impl RustPseudoTerminal {
quiet: Option<bool>, quiet: Option<bool>,
tty: Option<bool>, tty: Option<bool>,
) -> napi::Result<ChildProcess> { ) -> napi::Result<ChildProcess> {
run_command( self.pseudo_terminal.run_command(
&self.pseudo_terminal,
command, command,
command_dir, command_dir,
js_env, js_env,
@ -48,7 +47,7 @@ impl RustPseudoTerminal {
/// this makes it possible to be backwards compatible with the old implementation /// this makes it possible to be backwards compatible with the old implementation
#[napi] #[napi]
pub fn fork( pub fn fork(
&self, &mut self,
id: String, id: String,
fork_script: String, fork_script: String,
pseudo_ipc_path: String, pseudo_ipc_path: String,
@ -65,6 +64,13 @@ impl RustPseudoTerminal {
); );
trace!("nx_fork command: {}", &command); 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),
)
} }
} }

View File

@ -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::{ use std::{
collections::HashMap, collections::HashMap,
io::{Read, Write}, io::{Read, Write},
@ -7,16 +18,9 @@ use std::{
}, },
time::Instant, time::Instant,
}; };
use tracing::debug;
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::log::trace; use tracing::log::trace;
use vt100_ctt::Parser;
use super::os; use super::os;
use crate::native::pseudo_terminal::child_process::ChildProcess; use crate::native::pseudo_terminal::child_process::ChildProcess;
@ -27,192 +31,249 @@ pub struct PseudoTerminal {
pub printing_rx: Receiver<()>, pub printing_rx: Receiver<()>,
pub quiet: Arc<AtomicBool>, pub quiet: Arc<AtomicBool>,
pub running: Arc<AtomicBool>, pub running: Arc<AtomicBool>,
pub writer: WriterArc,
pub parser: ParserArc,
is_within_nx_tui: bool,
} }
pub fn create_pseudo_terminal() -> napi::Result<PseudoTerminal> { pub struct PseudoTerminalOptions {
let quiet = Arc::new(AtomicBool::new(true)); pub size: (u16, u16),
let running = Arc::new(AtomicBool::new(false)); }
let pty_system = NativePtySystem::default(); impl Default for PseudoTerminalOptions {
fn default() -> Self {
let (w, h) = terminal::size().unwrap_or((80, 24)); let (w, h) = terminal::size().unwrap_or((80, 24));
trace!("Opening Pseudo Terminal"); Self { size: (w, h) }
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");
} }
}
let mut reader = pty_pair.master.try_clone_reader()?; pub type ParserArc = Arc<RwLock<Parser>>;
let (message_tx, message_rx) = unbounded(); pub type WriterArc = Arc<Mutex<Box<dyn Write + Send>>>;
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];
'read_loop: loop { impl PseudoTerminal {
if let Ok(len) = reader.read(&mut buf) { pub fn new(options: PseudoTerminalOptions) -> Result<Self> {
if len == 0 { let quiet = Arc::new(AtomicBool::new(true));
break; 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); let mut reader = pty_pair.master.try_clone_reader()?;
trace!("Quiet: {}", quiet); let (message_tx, message_rx) = unbounded();
if !quiet { let (printing_tx, printing_rx) = unbounded();
let mut content = String::from_utf8_lossy(&buf[0..len]).to_string(); // Output -> stdout handling
if content.contains("\x1B[6n") { let quiet_clone = quiet.clone();
trace!("Prevented terminal escape sequence ESC[6n from being printed."); let running_clone = running.clone();
content = content.replace("\x1B[6n", "");
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; message_tx
while let Err(e) = stdout.write_all(content.as_bytes()) { .send(String::from_utf8_lossy(&buf[0..len]).to_string())
match e.kind() { .ok();
std::io::ErrorKind::Interrupted => { let quiet = quiet_clone.load(Ordering::Relaxed);
if !logged_interrupted_error { trace!("Quiet: {}", quiet);
trace!("Interrupted error writing to stdout: {:?}", e); let contains_clear = buf[..len]
logged_interrupted_error = true; .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<PseudoTerminal> {
Self::new(PseudoTerminalOptions::default())
}
pub fn run_command(
&mut self,
command: String,
command_dir: Option<String>,
js_env: Option<HashMap<String, String>>,
exec_argv: Option<Vec<String>>,
quiet: Option<bool>,
tty: Option<bool>,
) -> napi::Result<ChildProcess> {
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 should_control_raw_mode {
if !running_clone.load(Ordering::SeqCst) { trace!("Disabling raw mode");
printing_tx.send(()).ok(); disable_raw_mode().expect("Failed to restore non-raw terminal");
}
}
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<String>,
js_env: Option<HashMap<String, String>>,
exec_argv: Option<Vec<String>>,
quiet: Option<bool>,
tty: Option<bool>,
) -> napi::Result<ChildProcess> {
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;
}
} }
trace!("Printing finished"); exit_to_process_tx.send(exit.to_string()).ok();
} } else {
if is_tty { trace!("Error waiting for {}", command);
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);
};
});
trace!("Returning ChildProcess"); trace!("Returning ChildProcess");
Ok(ChildProcess::new( Ok(ChildProcess::new(
process_killer, self.parser.clone(),
pseudo_terminal.message_rx.clone(), self.writer.clone(),
exit_to_process_rx, process_killer,
)) self.message_rx.clone(),
exit_to_process_rx,
))
}
} }
fn get_directory(command_dir: Option<String>) -> anyhow::Result<String> { fn get_directory(command_dir: Option<String>) -> anyhow::Result<String> {
@ -252,11 +313,12 @@ mod tests {
#[test] #[test]
fn can_run_commands() { fn can_run_commands() {
let mut i = 0; let mut i = 0;
let pseudo_terminal = create_pseudo_terminal().unwrap(); let mut pseudo_terminal = PseudoTerminal::default().unwrap();
while i < 10 { while i < 10 {
println!("Running {}", i); println!("Running {}", i);
let cp1 = let cp1 = pseudo_terminal
run_command(&pseudo_terminal, String::from("whoami"), None, None, None).unwrap(); .run_command(String::from("whoami"), None, None, None)
.unwrap();
cp1.wait_receiver.recv().unwrap(); cp1.wait_receiver.recv().unwrap();
i += 1; i += 1;
} }

View File

@ -13,6 +13,9 @@ pub struct Task {
pub target: TaskTarget, pub target: TaskTarget,
pub outputs: Vec<String>, pub outputs: Vec<String>,
pub project_root: Option<String>, pub project_root: Option<String>,
pub start_time: Option<f64>,
pub end_time: Option<f64>,
pub continuous: Option<bool>,
} }
#[napi(object)] #[napi(object)]
@ -23,6 +26,15 @@ pub struct TaskTarget {
pub configuration: Option<String>, pub configuration: Option<String>,
} }
#[napi(object)]
#[derive(Default, Clone)]
pub struct TaskResult {
pub task: Task,
pub status: String,
pub code: i32,
pub terminal_output: Option<String>,
}
#[napi(object)] #[napi(object)]
pub struct TaskGraph { pub struct TaskGraph {
pub roots: Vec<String>, pub roots: Vec<String>,

View File

@ -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),
}

View File

@ -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<Box<dyn Component>>,
pub quit_at: Option<std::time::Instant>,
focus: Focus,
previous_focus: Focus,
done_callback: Option<ThreadsafeFunction<(), ErrorStrategy::Fatal>>,
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<Task>,
pinned_tasks: Vec<String>,
tui_config: TuiConfig,
title_text: String,
) -> Result<Self> {
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<Box<dyn Component>> = 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<u32>) {
if let Some(tasks_list) = self
.components
.iter_mut()
.find_map(|c| c.as_any_mut().downcast_mut::<TasksList>())
{
tasks_list.set_max_parallel(thread_count);
}
}
pub fn start_tasks(&mut self, tasks: Vec<Task>) {
if let Some(tasks_list) = self
.components
.iter_mut()
.find_map(|c| c.as_any_mut().downcast_mut::<TasksList>())
{
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::<TasksList>())
{
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<TaskResult>) {
if let Some(tasks_list) = self
.components
.iter_mut()
.find_map(|c| c.as_any_mut().downcast_mut::<TasksList>())
{
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::<CountdownPopup>())
{
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::<TasksList>())
{
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<Action>,
) -> Result<bool> {
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::<TasksList>())
{
// 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::<HelpPopup>())
{
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::<CountdownPopup>())
{
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::<HelpPopup>())
{
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::<HelpPopup>())
{
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::<HelpPopup>())
{
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::<TasksList>())
{
// 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::<HelpPopup>()
})
{
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::<TasksList>())
{
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<Action>,
) {
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::<HelpPopup>())
{
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::<TasksList>())
{
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::<TasksList>()
{
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<String>) {
if let Some(tasks_list) = self
.components
.iter_mut()
.find_map(|c| c.as_any_mut().downcast_mut::<TasksList>())
{
tasks_list.set_cloud_message(message);
}
}
}

View File

@ -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<Action>) -> Result<()> {
Ok(())
}
fn init(&mut self) -> Result<()> {
Ok(())
}
fn handle_events(&mut self, event: Option<Event>) -> Result<Option<Action>> {
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<Option<Action>> {
Ok(None)
}
#[allow(unused_variables)]
fn handle_mouse_events(&mut self, mouse: MouseEvent) -> Result<Option<Action>> {
Ok(None)
}
#[allow(unused_variables)]
fn update(&mut self, action: Action) -> Result<Option<Action>> {
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;
}

View File

@ -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<Instant>,
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
}
}

View File

@ -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"),
("<ctrl>+c", "Quit the TUI"),
("", ""),
// Navigation
("↑ or k", "Navigate/scroll task output up"),
("↓ or j", "Navigate/scroll task output down"),
("<ctrl>+u", "Scroll task output up"),
("<ctrl>+d", "Scroll task output down"),
("← or h", "Navigate left"),
("→ or l", "Navigate right"),
("", ""),
// Task List Controls
("/", "Filter tasks based on search term"),
("<esc>", "Clear filter"),
("", ""),
// Output Controls
("<space>", "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"),
(
"<tab>",
"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"),
("<ctrl>+z", "Stop interacting with a continuous task"),
];
let mut content: Vec<Line> = 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::<usize>() + 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::<Vec<Line>>(),
);
// 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
}
}

View File

@ -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("<ctrl>+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("<ctrl>+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("<tab>", base_style.fg(Color::Cyan)),
];
f.render_widget(
Paragraph::new(Line::from(shortcuts)).alignment(Alignment::Center),
area,
);
}
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,464 @@
pub struct TaskSelectionManager {
// The list of task names in their current visual order, None represents empty rows
entries: Vec<Option<String>>,
// The currently selected task name
selected_task_name: Option<String>,
// 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<Option<String>>) {
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<Option<String>>) {
// 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<Option<String>>) {
// 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<String>) {
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<Option<String>> {
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<usize> {
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<usize> {
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<Option<String>> = vec![];
manager.update_entries(entries);
// No entries, so no selection
assert_eq!(manager.get_selected_task_name(), None);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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<Arc<PtyInstance>>,
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("<ctrl>+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::<usize>();
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::<usize>();
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);
}
}
}
}
}
}

View File

@ -0,0 +1,62 @@
#[derive(Clone)]
pub struct TuiCliArgs {
pub targets: Vec<String>,
pub tui_auto_exit: Option<AutoExit>,
}
#[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<u32> {
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<AutoExit>, 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,
}
}
}

View File

@ -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<Vec<String>>,
#[napi(ts_type = "boolean | number | undefined")]
pub tui_auto_exit: Option<Either<bool, u32>>,
}
impl From<TuiCliArgs> 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<Either<bool, u32>>,
}
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<Mutex<App>>,
}
#[napi]
impl AppLifeCycle {
#[napi(constructor)]
pub fn new(
tasks: Vec<Task>,
pinned_tasks: Vec<String>,
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<u32>) -> 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<Task>, _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<TaskResult>,
_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(())
}

View File

@ -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;

View File

@ -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<RwLock<Parser>>,
pub writer: Arc<Mutex<Box<dyn Write + Send>>>,
rows: u16,
cols: u16,
}
impl PtyInstance {
pub fn new(
task_id: String,
parser: Arc<RwLock<Parser>>,
writer: Arc<Mutex<Box<dyn Write + Send>>>,
) -> io::Result<Self> {
// 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<vt100_ctt::Screen> {
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<Parser>, output: &[u8]) -> io::Result<()> {
if let Ok(mut parser_guard) = parser.write() {
let normalized = normalize_newlines(output);
parser_guard.process(&normalized);
}
Ok(())
}
}

View File

@ -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<Backend<std::io::Stderr>>,
pub task: JoinHandle<()>,
pub cancellation_token: CancellationToken,
pub event_rx: UnboundedReceiver<Event>,
pub event_tx: UnboundedSender<Event>,
pub frame_rate: f64,
pub tick_rate: f64,
}
impl Tui {
pub fn new() -> Result<Self> {
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<Event> {
self.event_rx.recv().await
}
}
impl Deref for Tui {
type Target = ratatui::Terminal<Backend<std::io::Stderr>>;
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();
}
}

View File

@ -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<u8> {
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<u128>) -> 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<TaskItem> = 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::<Vec<_>>();
// Sort should maintain original order for equal elements
sort_task_items(&mut tasks);
let sorted_names = tasks.iter().map(|t| t.name.clone()).collect::<Vec<_>>();
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<TaskItem> = (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");
}
}

View File

@ -1,16 +1,16 @@
import { findAncestorNodeModules } from './resolution-helpers'; import { Task } from '../config/task-graph';
import {
NxCloudClientUnavailableError,
NxCloudEnterpriseOutdatedError,
verifyOrUpdateNxCloudClient,
} from './update-manager';
import { import {
defaultTasksRunner, defaultTasksRunner,
DefaultTasksRunnerOptions, DefaultTasksRunnerOptions,
} from '../tasks-runner/default-tasks-runner'; } from '../tasks-runner/default-tasks-runner';
import { TasksRunner } from '../tasks-runner/tasks-runner'; import { TasksRunner } from '../tasks-runner/tasks-runner';
import { output } from '../utils/output'; 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 { export interface CloudTaskRunnerOptions extends DefaultTasksRunnerOptions {
accessToken?: string; accessToken?: string;
@ -56,7 +56,7 @@ export const nxCloudTasksRunnerShell: TasksRunner<
if (e instanceof NxCloudEnterpriseOutdatedError) { if (e instanceof NxCloudEnterpriseOutdatedError) {
output.warn({ output.warn({
title: e.message, 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); const results = await defaultTasksRunner(tasks, options, context);

View File

@ -49,7 +49,7 @@ export class ProcessTasks {
target, target,
configuration configuration
); );
const id = this.getId(projectName, target, resolvedConfiguration); const id = createTaskId(projectName, target, resolvedConfiguration);
const task = this.createTask( const task = this.createTask(
id, id,
project, project,
@ -221,7 +221,7 @@ export class ProcessTasks {
dependencyConfig.target, dependencyConfig.target,
configuration configuration
); );
const selfTaskId = this.getId( const selfTaskId = createTaskId(
selfProject.name, selfProject.name,
dependencyConfig.target, dependencyConfig.target,
resolvedConfiguration resolvedConfiguration
@ -286,7 +286,7 @@ export class ProcessTasks {
dependencyConfig.target, dependencyConfig.target,
configuration configuration
); );
const depTargetId = this.getId( const depTargetId = createTaskId(
depProject.name, depProject.name,
dependencyConfig.target, dependencyConfig.target,
resolvedConfiguration resolvedConfiguration
@ -325,7 +325,7 @@ export class ProcessTasks {
} }
} else { } else {
// Create a dummy task for task.target.project... which simulates if depProject had dependencyConfig.target // Create a dummy task for task.target.project... which simulates if depProject had dependencyConfig.target
const dummyId = this.getId( const dummyId = createTaskId(
depProject.name, depProject.name,
task.target.project + task.target.project +
'__' + '__' +
@ -408,18 +408,6 @@ export class ProcessTasks {
? configuration ? configuration
: defaultConfiguration; : defaultConfiguration;
} }
getId(
project: string,
target: string,
configuration: string | undefined
): string {
let id = `${project}:${target}`;
if (configuration) {
id += `:${configuration}`;
}
return id;
}
} }
export function createTaskGraph( export function createTaskGraph(
@ -532,3 +520,15 @@ export function getNonDummyDeps(
return [currentTask]; return [currentTask];
} }
} }
export function createTaskId(
project: string,
target: string,
configuration: string | undefined
): string {
let id = `${project}:${target}`;
if (configuration) {
id += `:${configuration}`;
}
return id;
}

View File

@ -135,16 +135,24 @@ export const defaultTasksRunner: TasksRunner<
(options as any)['parallel'] = Number((options as any)['maxParallel'] || 3); (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 { try {
return await runAllTasks(tasks, options, context); return await runAllTasks(options, {
...context,
threadCount,
});
} finally { } finally {
await options.lifeCycle.endCommand(); await options.lifeCycle.endCommand();
} }
}; };
async function runAllTasks( async function runAllTasks(
tasks: Task[],
options: DefaultTasksRunnerOptions, options: DefaultTasksRunnerOptions,
context: { context: {
initiatingProject?: string; initiatingProject?: string;
@ -154,6 +162,7 @@ async function runAllTasks(
taskGraph: TaskGraph; taskGraph: TaskGraph;
hasher: TaskHasher; hasher: TaskHasher;
daemon: DaemonClient; daemon: DaemonClient;
threadCount: number;
} }
): Promise<{ [id: string]: TaskStatus }> { ): Promise<{ [id: string]: TaskStatus }> {
const orchestrator = new TaskOrchestrator( const orchestrator = new TaskOrchestrator(
@ -163,6 +172,7 @@ async function runAllTasks(
context.taskGraph, context.taskGraph,
context.nxJson, context.nxJson,
options, options,
context.threadCount,
context.nxArgs?.nxBail, context.nxArgs?.nxBail,
context.daemon, context.daemon,
context.nxArgs?.outputStyle context.nxArgs?.outputStyle

View File

@ -8,11 +8,7 @@ import { join } from 'path';
import { BatchMessageType } from './batch/batch-messages'; import { BatchMessageType } from './batch/batch-messages';
import { stripIndents } from '../utils/strip-indents'; import { stripIndents } from '../utils/strip-indents';
import { Task, TaskGraph } from '../config/task-graph'; import { Task, TaskGraph } from '../config/task-graph';
import { import { PseudoTerminal, PseudoTtyProcess } from './pseudo-terminal';
getPseudoTerminal,
PseudoTerminal,
PseudoTtyProcess,
} from './pseudo-terminal';
import { signalToCode } from '../utils/exit-codes'; import { signalToCode } from '../utils/exit-codes';
import { ProjectGraph } from '../config/project-graph'; import { ProjectGraph } from '../config/project-graph';
import { import {
@ -21,6 +17,7 @@ import {
} from './running-tasks/node-child-process'; } from './running-tasks/node-child-process';
import { BatchProcess } from './running-tasks/batch-process'; import { BatchProcess } from './running-tasks/batch-process';
import { RunningTask } from './running-tasks/running-task'; import { RunningTask } from './running-tasks/running-task';
import { RustPseudoTerminal } from '../native';
const forkScript = join(__dirname, './fork.js'); const forkScript = join(__dirname, './fork.js');
@ -32,17 +29,14 @@ export class ForkedProcessTaskRunner {
private readonly verbose = process.env.NX_VERBOSE_LOGGING === 'true'; private readonly verbose = process.env.NX_VERBOSE_LOGGING === 'true';
private processes = new Set<RunningTask | BatchProcess>(); private processes = new Set<RunningTask | BatchProcess>();
private finishedProcesses = new Set<BatchProcess>(); private finishedProcesses = new Set<BatchProcess>();
private pseudoTerminals = new Set<PseudoTerminal>();
private pseudoTerminal: PseudoTerminal | null = PseudoTerminal.isSupported() constructor(
? getPseudoTerminal() private readonly options: DefaultTasksRunnerOptions,
: null; private readonly tuiEnabled: boolean
) {}
constructor(private readonly options: DefaultTasksRunnerOptions) {}
async init() { async init() {
if (this.pseudoTerminal) {
await this.pseudoTerminal.init();
}
this.setupProcessEventListeners(); this.setupProcessEventListeners();
} }
@ -148,32 +142,45 @@ export class ForkedProcessTaskRunner {
} }
): Promise<RunningTask | PseudoTtyProcess> { ): Promise<RunningTask | PseudoTtyProcess> {
const shouldPrefix = 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 // 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 // there's no point in running the commands in a pty if we are not streaming the output
if ( if (
!this.pseudoTerminal || PseudoTerminal.isSupported() &&
disablePseudoTerminal || !disablePseudoTerminal &&
!streamOutput || (this.tuiEnabled || (streamOutput && !shouldPrefix))
shouldPrefix
) { ) {
return this.forkProcessWithPrefixAndNotTTY(task, {
temporaryOutputPath,
streamOutput,
taskGraph,
env,
});
} else {
return this.forkProcessWithPseudoTerminal(task, { return this.forkProcessWithPseudoTerminal(task, {
temporaryOutputPath, temporaryOutputPath,
streamOutput, streamOutput,
taskGraph, taskGraph,
env, 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( private async forkProcessWithPseudoTerminal(
task: Task, task: Task,
{ {
@ -188,13 +195,10 @@ export class ForkedProcessTaskRunner {
env: NodeJS.ProcessEnv; env: NodeJS.ProcessEnv;
} }
): Promise<PseudoTtyProcess> { ): Promise<PseudoTtyProcess> {
const args = getPrintableCommandArgsForTask(task);
if (streamOutput) {
output.logCommand(args.join(' '));
}
const childId = task.id; 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(), cwd: process.cwd(),
execArgv: process.execArgv, execArgv: process.execArgv,
jsEnv: env, jsEnv: env,
@ -218,6 +222,7 @@ export class ForkedProcessTaskRunner {
if (code > 128) { if (code > 128) {
process.exit(code); process.exit(code);
} }
this.pseudoTerminals.delete(pseudoTerminal);
this.processes.delete(p); this.processes.delete(p);
this.writeTerminalOutput(temporaryOutputPath, terminalOutput); this.writeTerminalOutput(temporaryOutputPath, terminalOutput);
}); });
@ -355,16 +360,10 @@ export class ForkedProcessTaskRunner {
} }
private setupProcessEventListeners() { private setupProcessEventListeners() {
if (this.pseudoTerminal) {
this.pseudoTerminal.onMessageFromChildren((message: Serializable) => {
process.send(message);
});
}
const messageHandler = (message: Serializable) => { const messageHandler = (message: Serializable) => {
if (this.pseudoTerminal) { this.pseudoTerminals.forEach((p) => {
this.pseudoTerminal.sendMessageToChildren(message); p.sendMessageToChildren(message);
} });
this.processes.forEach((p) => { this.processes.forEach((p) => {
if ('connected' in p && p.connected && 'send' in p) { if ('connected' in p && p.connected && 'send' in p) {

View File

@ -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'
);
}

View File

@ -1,5 +1,7 @@
import { TaskStatus } from './tasks-runner';
import { Task } from '../config/task-graph'; 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} * The result of a completed {@link Task}
@ -20,8 +22,16 @@ export interface TaskMetadata {
groupId: number; 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> | void;
}
export interface LifeCycle { export interface LifeCycle {
startCommand?(): void | Promise<void>; startCommand?(parallel?: number): void | Promise<void>;
endCommand?(): void | Promise<void>; endCommand?(): void | Promise<void>;
@ -53,15 +63,20 @@ export interface LifeCycle {
status: TaskStatus, status: TaskStatus,
output: string output: string
): void; ): void;
registerRunningTask?(
taskId: string,
parserAndWriter: ExternalObject<[any, any]>
);
} }
export class CompositeLifeCycle implements LifeCycle { export class CompositeLifeCycle implements LifeCycle {
constructor(private readonly lifeCycles: LifeCycle[]) {} constructor(private readonly lifeCycles: LifeCycle[]) {}
async startCommand(): Promise<void> { async startCommand(parallel?: number): Promise<void> {
for (let l of this.lifeCycles) { for (let l of this.lifeCycles) {
if (l.startCommand) { 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<any>
): Promise<void> {
for (let l of this.lifeCycles) {
if (l.registerRunningTask) {
await l.registerRunningTask(taskId, parserAndWriter);
}
}
}
} }

View File

@ -1,11 +1,12 @@
import { serializeTarget } from '../../utils/serialize-target';
import { Task } from '../../config/task-graph'; import { Task } from '../../config/task-graph';
import { output } from '../../utils/output';
import { import {
getHistoryForHashes, getHistoryForHashes,
TaskRun, TaskRun,
writeTaskRunsToHistory as writeTaskRunsToHistory, writeTaskRunsToHistory,
} from '../../utils/legacy-task-history'; } 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'; import { LifeCycle, TaskResult } from '../life-cycle';
export class LegacyTaskHistoryLifeCycle implements LifeCycle { 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) { if (flakyTasks.length > 0) {
output.warn({ output.warn({
title: `Nx detected ${ title: `Nx detected ${

View File

@ -1,9 +1,10 @@
import { serializeTarget } from '../../utils/serialize-target';
import { Task } from '../../config/task-graph'; 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 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 { getTaskHistory, TaskHistory } from '../../utils/task-history';
import { isTuiEnabled } from '../is-tui-enabled';
import { LifeCycle, TaskResult } from '../life-cycle';
interface TaskRun extends NativeTaskRun { interface TaskRun extends NativeTaskRun {
target: Task['target']; target: Task['target'];
@ -45,6 +46,10 @@ export class TaskHistoryLifeCycle implements LifeCycle {
const flakyTasks = await this.taskHistory.getFlakyTasks( const flakyTasks = await this.taskHistory.getFlakyTasks(
entries.map(([hash]) => hash) entries.map(([hash]) => hash)
); );
// Do not directly print output when using the TUI
if (isTuiEnabled()) {
return;
}
if (flakyTasks.length > 0) { if (flakyTasks.length > 0) {
output.warn({ output.warn({
title: `Nx detected ${ title: `Nx detected ${

View File

@ -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<string, unknown>;
initiatingProject: string;
resolveRenderIsDonePromise: (value: void) => void;
}) {
const lifeCycle = {} as Partial<LifeCycle>;
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<string>();
const inProgressTasks = new Set<string>();
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 };
}

View File

@ -1,9 +1,9 @@
import { getPseudoTerminal, PseudoTerminal } from './pseudo-terminal'; import { createPseudoTerminal, PseudoTerminal } from './pseudo-terminal';
describe('PseudoTerminal', () => { describe('PseudoTerminal', () => {
let terminal: PseudoTerminal; let terminal: PseudoTerminal;
beforeAll(() => { beforeEach(() => {
terminal = getPseudoTerminal(true); terminal = createPseudoTerminal(true);
}); });
afterAll(() => { 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') { if (process.env.CI !== 'true') {
it('should be tty', (done) => { it('should be tty', (done) => {
const childProcess = terminal.runCommand( 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++;
}
});
}); });

View File

@ -4,19 +4,37 @@ import { getForkedProcessOsSocketPath } from '../daemon/socket-utils';
import { Serializable } from 'child_process'; import { Serializable } from 'child_process';
import * as os from 'os'; 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()) { if (!skipSupportCheck && !PseudoTerminal.isSupported()) {
throw new Error('Pseudo terminal is not supported on this platform.'); 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; return pseudoTerminal;
} }
let id = 0;
export class PseudoTerminal { export class PseudoTerminal {
private pseudoIPCPath = getForkedProcessOsSocketPath(process.pid.toString()); private pseudoIPCPath = getForkedProcessOsSocketPath(
process.pid.toString() + '-' + id++
);
private pseudoIPC = new PseudoIPCServer(this.pseudoIPCPath); private pseudoIPC = new PseudoIPCServer(this.pseudoIPCPath);
private initialized: boolean = false; private initialized: boolean = false;
@ -25,9 +43,7 @@ export class PseudoTerminal {
return process.stdout.isTTY && supportedPtyPlatform(); return process.stdout.isTTY && supportedPtyPlatform();
} }
constructor(private rustPseudoTerminal: RustPseudoTerminal) { constructor(private rustPseudoTerminal: RustPseudoTerminal) {}
this.setupProcessListeners();
}
async init() { async init() {
if (this.initialized) { if (this.initialized) {
@ -37,6 +53,12 @@ export class PseudoTerminal {
this.initialized = true; this.initialized = true;
} }
shutdown() {
if (this.initialized) {
this.pseudoIPC.close();
}
}
runCommand( runCommand(
command: string, command: string,
{ {
@ -54,6 +76,7 @@ export class PseudoTerminal {
} = {} } = {}
) { ) {
return new PseudoTtyProcess( return new PseudoTtyProcess(
this.rustPseudoTerminal,
this.rustPseudoTerminal.runCommand( this.rustPseudoTerminal.runCommand(
command, command,
cwd, cwd,
@ -84,6 +107,7 @@ export class PseudoTerminal {
throw new Error('Call init() before forking processes'); throw new Error('Call init() before forking processes');
} }
const cp = new PseudoTtyProcessWithSend( const cp = new PseudoTtyProcessWithSend(
this.rustPseudoTerminal,
this.rustPseudoTerminal.fork( this.rustPseudoTerminal.fork(
id, id,
script, script,
@ -109,30 +133,6 @@ export class PseudoTerminal {
onMessageFromChildren(callback: (message: Serializable) => void) { onMessageFromChildren(callback: (message: Serializable) => void) {
this.pseudoIPC.onMessageFromChildren(callback); 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 { export class PseudoTtyProcess {
@ -143,7 +143,10 @@ export class PseudoTtyProcess {
private terminalOutput = ''; private terminalOutput = '';
constructor(private childProcess: ChildProcess) { constructor(
public rustPseudoTerminal: RustPseudoTerminal,
private childProcess: ChildProcess
) {
childProcess.onOutput((output) => { childProcess.onOutput((output) => {
this.terminalOutput += output; this.terminalOutput += output;
this.outputCallbacks.forEach((cb) => cb(output)); this.outputCallbacks.forEach((cb) => cb(output));
@ -187,15 +190,20 @@ export class PseudoTtyProcess {
} }
} }
} }
getParserAndWriter() {
return this.childProcess.getParserAndWriter();
}
} }
export class PseudoTtyProcessWithSend extends PseudoTtyProcess { export class PseudoTtyProcessWithSend extends PseudoTtyProcess {
constructor( constructor(
public rustPseudoTerminal: RustPseudoTerminal,
_childProcess: ChildProcess, _childProcess: ChildProcess,
private id: string, private id: string,
private pseudoIpc: PseudoIPCServer private pseudoIpc: PseudoIPCServer
) { ) {
super(_childProcess); super(rustPseudoTerminal, _childProcess);
} }
send(message: Serializable) { send(message: Serializable) {

View File

@ -1,6 +1,8 @@
import { prompt } from 'enquirer'; import { prompt } from 'enquirer';
import { join } from 'node:path';
import { stripVTControlCharacters } from 'node:util';
import * as ora from 'ora'; import * as ora from 'ora';
import { join } from 'path'; import type { Observable } from 'rxjs';
import { import {
NxJsonConfiguration, NxJsonConfiguration,
readNxJson, readNxJson,
@ -16,13 +18,18 @@ import {
hashTasksThatDoNotDependOnOutputsOfOtherTasks, hashTasksThatDoNotDependOnOutputsOfOtherTasks,
} from '../hasher/hash-task'; } from '../hasher/hash-task';
import { IS_WASM } from '../native'; import { IS_WASM } from '../native';
import {
runPostTasksExecution,
runPreTasksExecution,
} from '../project-graph/plugins/tasks-execution-hooks';
import { createProjectGraphAsync } from '../project-graph/project-graph'; import { createProjectGraphAsync } from '../project-graph/project-graph';
import { NxArgs } from '../utils/command-line-utils'; import { NxArgs } from '../utils/command-line-utils';
import { isRelativePath } from '../utils/fileutils'; import { isRelativePath } from '../utils/fileutils';
import { handleErrors } from '../utils/handle-errors';
import { isCI } from '../utils/is-ci'; import { isCI } from '../utils/is-ci';
import { isNxCloudUsed } from '../utils/nx-cloud-utils'; import { isNxCloudUsed } from '../utils/nx-cloud-utils';
import { printNxKey } from '../utils/nx-key';
import { output } from '../utils/output'; import { output } from '../utils/output';
import { handleErrors } from '../utils/handle-errors';
import { import {
collectEnabledTaskSyncGeneratorsFromTaskGraph, collectEnabledTaskSyncGeneratorsFromTaskGraph,
flushSyncGeneratorChanges, flushSyncGeneratorChanges,
@ -33,7 +40,8 @@ import {
processSyncGeneratorResultErrors, processSyncGeneratorResultErrors,
} from '../utils/sync-generators'; } from '../utils/sync-generators';
import { workspaceRoot } from '../utils/workspace-root'; 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 { import {
CompositeLifeCycle, CompositeLifeCycle,
LifeCycle, LifeCycle,
@ -48,8 +56,9 @@ import { StoreRunInformationLifeCycle } from './life-cycles/store-run-informatio
import { TaskHistoryLifeCycle } from './life-cycles/task-history-life-cycle'; import { TaskHistoryLifeCycle } from './life-cycles/task-history-life-cycle';
import { LegacyTaskHistoryLifeCycle } from './life-cycles/task-history-life-cycle-old'; import { LegacyTaskHistoryLifeCycle } from './life-cycles/task-history-life-cycle-old';
import { TaskProfilingLifeCycle } from './life-cycles/task-profiling-life-cycle'; 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 { 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 { import {
findCycle, findCycle,
makeAcyclic, makeAcyclic,
@ -58,21 +67,221 @@ import {
import { TasksRunner, TaskStatus } from './tasks-runner'; import { TasksRunner, TaskStatus } from './tasks-runner';
import { shouldStreamOutput } from './utils'; import { shouldStreamOutput } from './utils';
import chalk = require('chalk'); import chalk = require('chalk');
import type { Observable } from 'rxjs';
import { printNxKey } from '../utils/nx-key'; const originalStdoutWrite = process.stdout.write.bind(process.stdout);
import { const originalStderrWrite = process.stderr.write.bind(process.stderr);
runPostTasksExecution, const originalConsoleLog = console.log.bind(console);
runPreTasksExecution, const originalConsoleError = console.error.bind(console);
} from '../project-graph/plugins/tasks-execution-hooks';
async function getTerminalOutputLifeCycle( async function getTerminalOutputLifeCycle(
initiatingProject: string, initiatingProject: string,
projectNames: string[], projectNames: string[],
tasks: Task[], tasks: Task[],
taskGraph: TaskGraph,
nxArgs: NxArgs, nxArgs: NxArgs,
nxJson: NxJsonConfiguration, nxJson: NxJsonConfiguration,
overrides: Record<string, unknown> overrides: Record<string, unknown>
): Promise<{ lifeCycle: LifeCycle; renderIsDone: Promise<void> }> { ): Promise<{ lifeCycle: LifeCycle; renderIsDone: Promise<void> }> {
const overridesWithoutHidden = { ...overrides };
delete overridesWithoutHidden['__overrides_unparsed__'];
if (isTuiEnabled(nxJson)) {
const interceptedNxCloudLogs: (string | Uint8Array<ArrayBufferLike>)[] = [];
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<void>(
(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<void>((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 { runnerOptions } = getRunner(nxArgs, nxJson);
const isRunOne = initiatingProject != null; const isRunOne = initiatingProject != null;
const useDynamicOutput = shouldUseDynamicLifeCycle( const useDynamicOutput = shouldUseDynamicLifeCycle(
@ -81,9 +290,6 @@ async function getTerminalOutputLifeCycle(
nxArgs.outputStyle nxArgs.outputStyle
); );
const overridesWithoutHidden = { ...overrides };
delete overridesWithoutHidden['__overrides_unparsed__'];
if (isRunOne) { if (isRunOne) {
if (useDynamicOutput) { if (useDynamicOutput) {
return await createRunOneDynamicOutputRenderer({ return await createRunOneDynamicOutputRenderer({
@ -255,6 +461,7 @@ export async function runCommandForTasks(
initiatingProject, initiatingProject,
projectNames, projectNames,
tasks, tasks,
taskGraph,
nxArgs, nxArgs,
nxJson, nxJson,
overrides overrides

View File

@ -37,7 +37,7 @@ export function getEnvVariablesForTask(
captureStderr: boolean, captureStderr: boolean,
outputPath: string, outputPath: string,
streamOutput: boolean streamOutput: boolean
) { ): NodeJS.ProcessEnv {
const res = { const res = {
// Start With Dotenv Variables // Start With Dotenv Variables
...taskSpecificEnv, ...taskSpecificEnv,
@ -95,7 +95,7 @@ function getNxEnvVariablesForTask(
captureStderr: boolean, captureStderr: boolean,
outputPath: string, outputPath: string,
streamOutput: boolean streamOutput: boolean
) { ): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { const env: NodeJS.ProcessEnv = {
NX_TASK_TARGET_PROJECT: task.target.project, NX_TASK_TARGET_PROJECT: task.target.project,
NX_TASK_TARGET_TARGET: task.target.target, NX_TASK_TARGET_TARGET: task.target.target,
@ -119,6 +119,8 @@ function getNxEnvVariablesForTask(
streamOutput streamOutput
), ),
...env, ...env,
// Ensure the TUI does not get spawned within the TUI if ever tasks invoke Nx again
NX_TUI: 'false',
}; };
} }

View File

@ -1,13 +1,35 @@
import { defaultMaxListeners } from 'events'; import { defaultMaxListeners } from 'events';
import { performance } from 'perf_hooks';
import { relative } from 'path';
import { writeFileSync } from 'fs'; 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 { 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 { Cache, DbCache, dbCacheEnabled, getCache } from './cache';
import { DefaultTasksRunnerOptions } from './default-tasks-runner'; 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 { TaskStatus } from './tasks-runner';
import { Batch, TasksSchedule } from './tasks-schedule';
import { import {
calculateReverseDeps, calculateReverseDeps,
getExecutorForTask, getExecutorForTask,
@ -17,31 +39,15 @@ import {
removeTasksFromTaskGraph, removeTasksFromTaskGraph,
shouldStreamOutput, shouldStreamOutput,
} from './utils'; } 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 { export class TaskOrchestrator {
private taskDetails: TaskDetails | null = getTaskDetails(); private taskDetails: TaskDetails | null = getTaskDetails();
private cache: DbCache | Cache = getCache(this.options); 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 runningTasksService = new RunningTasksService(getDbConnection());
private tasksSchedule = new TasksSchedule( private tasksSchedule = new TasksSchedule(
@ -72,6 +78,7 @@ export class TaskOrchestrator {
private runningContinuousTasks = new Map<string, RunningTask>(); private runningContinuousTasks = new Map<string, RunningTask>();
private cleaningUp = false; private cleaningUp = false;
// endregion internal state // endregion internal state
constructor( constructor(
@ -81,6 +88,7 @@ export class TaskOrchestrator {
private readonly taskGraph: TaskGraph, private readonly taskGraph: TaskGraph,
private readonly nxJson: NxJsonConfiguration, private readonly nxJson: NxJsonConfiguration,
private readonly options: NxArgs & DefaultTasksRunnerOptions, private readonly options: NxArgs & DefaultTasksRunnerOptions,
private readonly threadCount: number,
private readonly bail: boolean, private readonly bail: boolean,
private readonly daemon: DaemonClient, private readonly daemon: DaemonClient,
private readonly outputStyle: string private readonly outputStyle: string
@ -99,17 +107,13 @@ export class TaskOrchestrator {
performance.mark('task-execution:start'); performance.mark('task-execution:start');
const threadCount =
this.options.parallel +
Object.values(this.taskGraph.tasks).filter((t) => t.continuous).length;
const threads = []; const threads = [];
process.stdout.setMaxListeners(threadCount + defaultMaxListeners); process.stdout.setMaxListeners(this.threadCount + defaultMaxListeners);
process.stderr.setMaxListeners(threadCount + defaultMaxListeners); process.stderr.setMaxListeners(this.threadCount + defaultMaxListeners);
// initial seeding of the queue // initial seeding of the queue
for (let i = 0; i < threadCount; ++i) { for (let i = 0; i < this.threadCount; ++i) {
threads.push(this.executeNextBatchOfTasksUsingTaskSchedule()); threads.push(this.executeNextBatchOfTasksUsingTaskSchedule());
} }
await Promise.all(threads); await Promise.all(threads);
@ -460,7 +464,6 @@ export class TaskOrchestrator {
) { ) {
try { try {
const { schema } = getExecutorForTask(task, this.projectGraph); const { schema } = getExecutorForTask(task, this.projectGraph);
const isRunOne = this.initiatingProject != null;
const combinedOptions = combineOptionsForExecutor( const combinedOptions = combineOptionsForExecutor(
task.overrides, task.overrides,
task.target.configuration ?? targetConfiguration.defaultConfiguration, task.target.configuration ?? targetConfiguration.defaultConfiguration,
@ -480,31 +483,54 @@ export class TaskOrchestrator {
const args = getPrintableCommandArgsForTask(task); const args = getPrintableCommandArgsForTask(task);
output.logCommand(args.join(' ')); output.logCommand(args.join(' '));
} }
const runningTask = await runCommands( const runCommandsOptions = {
{ ...combinedOptions,
...combinedOptions, env,
env, usePty:
usePty: this.tuiEnabled ||
isRunOne && (!this.tasksSchedule.hasTasks() &&
!this.tasksSchedule.hasTasks() && this.runningContinuousTasks.size === 0),
this.runningContinuousTasks.size === 0, streamOutput,
streamOutput, };
},
{
root: workspaceRoot, // only root is needed in runCommands
} as any
);
runningTask.onExit((code, terminalOutput) => { const runningTask = await runCommands(runCommandsOptions, {
if (!streamOutput) { root: workspaceRoot, // only root is needed in runCommands
this.options.lifeCycle.printTaskTerminalOutput( } as any);
task,
code === 0 ? 'success' : 'failure', if (this.tuiEnabled && runningTask instanceof PseudoTtyProcess) {
terminalOutput // 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(
writeFileSync(temporaryOutputPath, terminalOutput); 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; return runningTask;
} catch (e) { } catch (e) {
@ -515,6 +541,10 @@ export class TaskOrchestrator {
} }
const terminalOutput = e.stack ?? e.message ?? ''; const terminalOutput = e.stack ?? e.message ?? '';
writeFileSync(temporaryOutputPath, terminalOutput); writeFileSync(temporaryOutputPath, terminalOutput);
return new NoopChildProcess({
code: 1,
terminalOutput,
});
} }
} else if (targetConfiguration.executor === 'nx:noop') { } else if (targetConfiguration.executor === 'nx:noop') {
writeFileSync(temporaryOutputPath, ''); writeFileSync(temporaryOutputPath, '');
@ -524,13 +554,23 @@ export class TaskOrchestrator {
}); });
} else { } else {
// cache prep // cache prep
return await this.runTaskInForkedProcess( const runningTask = await this.runTaskInForkedProcess(
task, task,
env, env,
pipeOutput, pipeOutput,
temporaryOutputPath, temporaryOutputPath,
streamOutput 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'; 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 // 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 // execution
const childProcess = usePtyFork const childProcess = usePtyFork
? await this.forkedProcessTaskRunner.forkProcess(task, { ? await this.forkedProcessTaskRunner.forkProcess(task, {

View File

@ -1,27 +1,27 @@
import { output } from '../utils/output'; import { minimatch } from 'minimatch';
import { relative } from 'path'; import { relative } from 'node:path';
import { join } from 'path/posix'; import { join } from 'node:path/posix';
import { Task, TaskGraph } from '../config/task-graph'; import { getExecutorInformation } from '../command-line/run/executor-utils';
import { CustomHasher, ExecutorConfig } from '../config/misc-interfaces';
import { ProjectGraph, ProjectGraphProjectNode } from '../config/project-graph'; import { ProjectGraph, ProjectGraphProjectNode } from '../config/project-graph';
import { Task, TaskGraph } from '../config/task-graph';
import { import {
TargetConfiguration, TargetConfiguration,
TargetDependencyConfig, TargetDependencyConfig,
} from '../config/workspace-json-project-json'; } 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 { import {
getTransformableOutputs, getTransformableOutputs,
validateOutputs as nativeValidateOutputs, validateOutputs as nativeValidateOutputs,
} from '../native'; } 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 & { export type NormalizedTargetDependencyConfig = TargetDependencyConfig & {
projects: string[]; projects: string[];
@ -555,6 +555,8 @@ export function shouldStreamOutput(
task: Task, task: Task,
initiatingProject: string | null initiatingProject: string | null
): boolean { ): 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 (process.env.NX_STREAM_OUTPUT === 'true') return true;
if (longRunningTask(task)) return true; if (longRunningTask(task)) return true;
if (task.target.project === initiatingProject) return true; if (task.target.project === initiatingProject) return true;