feat(core): add the experimental Terminal UI for tasks (#30565)
This commit is contained in:
parent
3794c2f256
commit
6541751aab
803
Cargo.lock
generated
803
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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). |
|
||||||
|
|||||||
@ -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. |
|
||||||
|
|||||||
@ -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. |
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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). |
|
||||||
|
|||||||
@ -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. |
|
||||||
|
|||||||
@ -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. |
|
||||||
|
|||||||
@ -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"
|
|
||||||
|
|||||||
@ -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."
|
||||||
|
|||||||
@ -83,6 +83,7 @@ export const allowedWorkspaceExtensions = [
|
|||||||
'sync',
|
'sync',
|
||||||
'useLegacyCache',
|
'useLegacyCache',
|
||||||
'maxCacheSize',
|
'maxCacheSize',
|
||||||
|
'tui',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
if (!patched) {
|
if (!patched) {
|
||||||
|
|||||||
@ -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,12 +18,14 @@ export const yargsAffectedCommand: CommandModule = {
|
|||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
linkToNxDevAndExamples(
|
linkToNxDevAndExamples(
|
||||||
withAffectedOptions(
|
withAffectedOptions(
|
||||||
|
withTuiOptions(
|
||||||
withRunOptions(
|
withRunOptions(
|
||||||
withOutputStyleOption(
|
withOutputStyleOption(
|
||||||
withTargetAndConfigurationOption(withBatch(yargs))
|
withTargetAndConfigurationOption(withBatch(yargs))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
.option('all', {
|
.option('all', {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
deprecated: 'Use `nx run-many` instead',
|
deprecated: 'Use `nx run-many` instead',
|
||||||
@ -56,7 +59,9 @@ export const yargsAffectedTestCommand: CommandModule = {
|
|||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
linkToNxDevAndExamples(
|
linkToNxDevAndExamples(
|
||||||
withAffectedOptions(
|
withAffectedOptions(
|
||||||
|
withTuiOptions(
|
||||||
withRunOptions(withOutputStyleOption(withConfiguration(yargs)))
|
withRunOptions(withOutputStyleOption(withConfiguration(yargs)))
|
||||||
|
)
|
||||||
),
|
),
|
||||||
'affected'
|
'affected'
|
||||||
),
|
),
|
||||||
@ -80,7 +85,9 @@ export const yargsAffectedBuildCommand: CommandModule = {
|
|||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
linkToNxDevAndExamples(
|
linkToNxDevAndExamples(
|
||||||
withAffectedOptions(
|
withAffectedOptions(
|
||||||
|
withTuiOptions(
|
||||||
withRunOptions(withOutputStyleOption(withConfiguration(yargs)))
|
withRunOptions(withOutputStyleOption(withConfiguration(yargs)))
|
||||||
|
)
|
||||||
),
|
),
|
||||||
'affected'
|
'affected'
|
||||||
),
|
),
|
||||||
@ -104,7 +111,9 @@ export const yargsAffectedLintCommand: CommandModule = {
|
|||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
linkToNxDevAndExamples(
|
linkToNxDevAndExamples(
|
||||||
withAffectedOptions(
|
withAffectedOptions(
|
||||||
|
withTuiOptions(
|
||||||
withRunOptions(withOutputStyleOption(withConfiguration(yargs)))
|
withRunOptions(withOutputStyleOption(withConfiguration(yargs)))
|
||||||
|
)
|
||||||
),
|
),
|
||||||
'affected'
|
'affected'
|
||||||
),
|
),
|
||||||
@ -128,7 +137,9 @@ export const yargsAffectedE2ECommand: CommandModule = {
|
|||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
linkToNxDevAndExamples(
|
linkToNxDevAndExamples(
|
||||||
withAffectedOptions(
|
withAffectedOptions(
|
||||||
|
withTuiOptions(
|
||||||
withRunOptions(withOutputStyleOption(withConfiguration(yargs)))
|
withRunOptions(withOutputStyleOption(withConfiguration(yargs)))
|
||||||
|
)
|
||||||
),
|
),
|
||||||
'affected'
|
'affected'
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -1,23 +1,26 @@
|
|||||||
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(
|
||||||
|
withTuiOptions(
|
||||||
withRunManyOptions(
|
withRunManyOptions(
|
||||||
withOutputStyleOption(
|
withOutputStyleOption(
|
||||||
withTargetAndConfigurationOption(withBatch(yargs))
|
withTargetAndConfigurationOption(withBatch(yargs))
|
||||||
)
|
)
|
||||||
|
)
|
||||||
),
|
),
|
||||||
'run-many'
|
'run-many'
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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('=');
|
||||||
|
|||||||
@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
35
packages/nx/src/native/index.d.ts
vendored
35
packages/nx/src/native/index.d.ts
vendored
@ -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
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
|
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,
|
||||||
@ -7,7 +9,10 @@ use napi::{
|
|||||||
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)
|
||||||
|
|||||||
@ -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])?;
|
||||||
|
|||||||
@ -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(|_| ())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,16 +31,34 @@ 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 {
|
||||||
|
pub size: (u16, u16),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PseudoTerminalOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
let (w, h) = terminal::size().unwrap_or((80, 24));
|
||||||
|
Self { size: (w, h) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ParserArc = Arc<RwLock<Parser>>;
|
||||||
|
pub type WriterArc = Arc<Mutex<Box<dyn Write + Send>>>;
|
||||||
|
|
||||||
|
impl PseudoTerminal {
|
||||||
|
pub fn new(options: PseudoTerminalOptions) -> Result<Self> {
|
||||||
let quiet = Arc::new(AtomicBool::new(true));
|
let quiet = Arc::new(AtomicBool::new(true));
|
||||||
let running = Arc::new(AtomicBool::new(false));
|
let running = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
let pty_system = NativePtySystem::default();
|
let pty_system = NativePtySystem::default();
|
||||||
|
|
||||||
let (w, h) = terminal::size().unwrap_or((80, 24));
|
|
||||||
trace!("Opening Pseudo Terminal");
|
trace!("Opening Pseudo Terminal");
|
||||||
|
let (w, h) = options.size;
|
||||||
let pty_pair = pty_system.openpty(PtySize {
|
let pty_pair = pty_system.openpty(PtySize {
|
||||||
rows: h,
|
rows: h,
|
||||||
cols: w,
|
cols: w,
|
||||||
@ -44,22 +66,22 @@ pub fn create_pseudo_terminal() -> napi::Result<PseudoTerminal> {
|
|||||||
pixel_height: 0,
|
pixel_height: 0,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut writer = pty_pair.master.take_writer()?;
|
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
|
// Stdin -> pty stdin
|
||||||
if std::io::stdout().is_tty() {
|
|
||||||
trace!("Passing through stdin");
|
trace!("Passing through stdin");
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let mut stdin = std::io::stdin();
|
let mut stdin = std::io::stdin();
|
||||||
if let Err(e) = os::write_to_pty(&mut stdin, &mut writer) {
|
if let Err(e) = os::write_to_pty(&mut stdin, writer_clone) {
|
||||||
trace!("Error writing to pty: {:?}", e);
|
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()?;
|
let mut reader = pty_pair.master.try_clone_reader()?;
|
||||||
let (message_tx, message_rx) = unbounded();
|
let (message_tx, message_rx) = unbounded();
|
||||||
@ -67,9 +89,13 @@ pub fn create_pseudo_terminal() -> napi::Result<PseudoTerminal> {
|
|||||||
// Output -> stdout handling
|
// Output -> stdout handling
|
||||||
let quiet_clone = quiet.clone();
|
let quiet_clone = quiet.clone();
|
||||||
let running_clone = running.clone();
|
let running_clone = running.clone();
|
||||||
|
|
||||||
|
let parser = Arc::new(RwLock::new(Parser::new(h, w, 10000)));
|
||||||
|
let parser_clone = parser.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let mut stdout = std::io::stdout();
|
let mut stdout = std::io::stdout();
|
||||||
let mut buf = [0; 8 * 1024];
|
let mut buf = [0; 8 * 1024];
|
||||||
|
let mut first: bool = true;
|
||||||
|
|
||||||
'read_loop: loop {
|
'read_loop: loop {
|
||||||
if let Ok(len) = reader.read(&mut buf) {
|
if let Ok(len) = reader.read(&mut buf) {
|
||||||
@ -81,14 +107,26 @@ pub fn create_pseudo_terminal() -> napi::Result<PseudoTerminal> {
|
|||||||
.ok();
|
.ok();
|
||||||
let quiet = quiet_clone.load(Ordering::Relaxed);
|
let quiet = quiet_clone.load(Ordering::Relaxed);
|
||||||
trace!("Quiet: {}", quiet);
|
trace!("Quiet: {}", quiet);
|
||||||
|
let contains_clear = buf[..len]
|
||||||
|
.windows(4)
|
||||||
|
.any(|window| window == [0x1B, 0x5B, 0x32, 0x4A]);
|
||||||
|
debug!("Contains clear: {}", contains_clear);
|
||||||
|
debug!("Read {} bytes", len);
|
||||||
|
if let Ok(mut parser) = parser_clone.write() {
|
||||||
|
let prev = parser.screen().clone();
|
||||||
|
|
||||||
|
parser.process(&buf[..len]);
|
||||||
|
debug!("{}", parser.get_raw_output().len());
|
||||||
|
|
||||||
|
let write_buf = if first {
|
||||||
|
parser.screen().contents_formatted()
|
||||||
|
} else {
|
||||||
|
parser.screen().contents_diff(&prev)
|
||||||
|
};
|
||||||
|
first = false;
|
||||||
if !quiet {
|
if !quiet {
|
||||||
let mut content = String::from_utf8_lossy(&buf[0..len]).to_string();
|
|
||||||
if content.contains("\x1B[6n") {
|
|
||||||
trace!("Prevented terminal escape sequence ESC[6n from being printed.");
|
|
||||||
content = content.replace("\x1B[6n", "");
|
|
||||||
}
|
|
||||||
let mut logged_interrupted_error = false;
|
let mut logged_interrupted_error = false;
|
||||||
while let Err(e) = stdout.write_all(content.as_bytes()) {
|
while let Err(e) = stdout.write_all(&write_buf) {
|
||||||
match e.kind() {
|
match e.kind() {
|
||||||
std::io::ErrorKind::Interrupted => {
|
std::io::ErrorKind::Interrupted => {
|
||||||
if !logged_interrupted_error {
|
if !logged_interrupted_error {
|
||||||
@ -107,6 +145,9 @@ pub fn create_pseudo_terminal() -> napi::Result<PseudoTerminal> {
|
|||||||
}
|
}
|
||||||
let _ = stdout.flush();
|
let _ = stdout.flush();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
debug!("Failed to lock parser");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !running_clone.load(Ordering::SeqCst) {
|
if !running_clone.load(Ordering::SeqCst) {
|
||||||
printing_tx.send(()).ok();
|
printing_tx.send(()).ok();
|
||||||
@ -115,20 +156,24 @@ pub fn create_pseudo_terminal() -> napi::Result<PseudoTerminal> {
|
|||||||
|
|
||||||
printing_tx.send(()).ok();
|
printing_tx.send(()).ok();
|
||||||
});
|
});
|
||||||
if std::io::stdout().is_tty() {
|
|
||||||
trace!("Disabling raw mode");
|
|
||||||
disable_raw_mode().expect("Failed to exit raw terminal mode");
|
|
||||||
}
|
|
||||||
Ok(PseudoTerminal {
|
Ok(PseudoTerminal {
|
||||||
quiet,
|
quiet,
|
||||||
|
writer: writer_arc,
|
||||||
running,
|
running,
|
||||||
|
parser,
|
||||||
pty_pair,
|
pty_pair,
|
||||||
message_rx,
|
message_rx,
|
||||||
printing_rx,
|
printing_rx,
|
||||||
|
is_within_nx_tui,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn default() -> Result<PseudoTerminal> {
|
||||||
|
Self::new(PseudoTerminalOptions::default())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run_command(
|
pub fn run_command(
|
||||||
pseudo_terminal: &PseudoTerminal,
|
&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>>,
|
||||||
@ -138,11 +183,11 @@ pub fn run_command(
|
|||||||
) -> napi::Result<ChildProcess> {
|
) -> napi::Result<ChildProcess> {
|
||||||
let command_dir = get_directory(command_dir)?;
|
let command_dir = get_directory(command_dir)?;
|
||||||
|
|
||||||
let pair = &pseudo_terminal.pty_pair;
|
let pair = &self.pty_pair;
|
||||||
|
|
||||||
let quiet = quiet.unwrap_or(false);
|
let quiet = quiet.unwrap_or(false);
|
||||||
|
|
||||||
pseudo_terminal.quiet.store(quiet, Ordering::Relaxed);
|
self.quiet.store(quiet, Ordering::Relaxed);
|
||||||
|
|
||||||
let mut cmd = command_builder();
|
let mut cmd = command_builder();
|
||||||
cmd.arg(command.as_str());
|
cmd.arg(command.as_str());
|
||||||
@ -160,19 +205,32 @@ pub fn run_command(
|
|||||||
|
|
||||||
let (exit_to_process_tx, exit_to_process_rx) = bounded(1);
|
let (exit_to_process_tx, exit_to_process_rx) = bounded(1);
|
||||||
trace!("Running {}", command);
|
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)?;
|
let mut child = pair.slave.spawn_command(cmd)?;
|
||||||
pseudo_terminal.running.store(true, Ordering::SeqCst);
|
self.running.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
let is_tty = tty.unwrap_or_else(|| std::io::stdout().is_tty());
|
let is_tty = tty.unwrap_or_else(|| std::io::stdout().is_tty());
|
||||||
if 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");
|
trace!("Enabling raw mode");
|
||||||
enable_raw_mode().expect("Failed to enter raw terminal mode");
|
enable_raw_mode().expect("Failed to enter raw terminal mode");
|
||||||
}
|
}
|
||||||
let process_killer = child.clone_killer();
|
let process_killer = child.clone_killer();
|
||||||
|
|
||||||
trace!("Getting running clone");
|
trace!("Getting running clone");
|
||||||
let running_clone = pseudo_terminal.running.clone();
|
let running_clone = self.running.clone();
|
||||||
trace!("Getting printing_rx clone");
|
trace!("Getting printing_rx clone");
|
||||||
let printing_rx = pseudo_terminal.printing_rx.clone();
|
let printing_rx = self.printing_rx.clone();
|
||||||
|
|
||||||
trace!("spawning thread to wait for command");
|
trace!("spawning thread to wait for command");
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
@ -197,7 +255,7 @@ pub fn run_command(
|
|||||||
}
|
}
|
||||||
trace!("Printing finished");
|
trace!("Printing finished");
|
||||||
}
|
}
|
||||||
if is_tty {
|
if should_control_raw_mode {
|
||||||
trace!("Disabling raw mode");
|
trace!("Disabling raw mode");
|
||||||
disable_raw_mode().expect("Failed to restore non-raw terminal");
|
disable_raw_mode().expect("Failed to restore non-raw terminal");
|
||||||
}
|
}
|
||||||
@ -209,11 +267,14 @@ pub fn run_command(
|
|||||||
|
|
||||||
trace!("Returning ChildProcess");
|
trace!("Returning ChildProcess");
|
||||||
Ok(ChildProcess::new(
|
Ok(ChildProcess::new(
|
||||||
|
self.parser.clone(),
|
||||||
|
self.writer.clone(),
|
||||||
process_killer,
|
process_killer,
|
||||||
pseudo_terminal.message_rx.clone(),
|
self.message_rx.clone(),
|
||||||
exit_to_process_rx,
|
exit_to_process_rx,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn get_directory(command_dir: Option<String>) -> anyhow::Result<String> {
|
fn get_directory(command_dir: Option<String>) -> anyhow::Result<String> {
|
||||||
if let Some(command_dir) = command_dir {
|
if let Some(command_dir) = command_dir {
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>,
|
||||||
|
|||||||
25
packages/nx/src/native/tui/action.rs
Normal file
25
packages/nx/src/native/tui/action.rs
Normal 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),
|
||||||
|
}
|
||||||
687
packages/nx/src/native/tui/app.rs
Normal file
687
packages/nx/src/native/tui/app.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
packages/nx/src/native/tui/components.rs
Normal file
52
packages/nx/src/native/tui/components.rs
Normal 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;
|
||||||
|
}
|
||||||
299
packages/nx/src/native/tui/components/countdown_popup.rs
Normal file
299
packages/nx/src/native/tui/components/countdown_popup.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
347
packages/nx/src/native/tui/components/help_popup.rs
Normal file
347
packages/nx/src/native/tui/components/help_popup.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
83
packages/nx/src/native/tui/components/help_text.rs
Normal file
83
packages/nx/src/native/tui/components/help_text.rs
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
packages/nx/src/native/tui/components/pagination.rs
Normal file
64
packages/nx/src/native/tui/components/pagination.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
464
packages/nx/src/native/tui/components/task_selection_manager.rs
Normal file
464
packages/nx/src/native/tui/components/task_selection_manager.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
2262
packages/nx/src/native/tui/components/tasks_list.rs
Normal file
2262
packages/nx/src/native/tui/components/tasks_list.rs
Normal file
File diff suppressed because it is too large
Load Diff
493
packages/nx/src/native/tui/components/terminal_pane.rs
Normal file
493
packages/nx/src/native/tui/components/terminal_pane.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
packages/nx/src/native/tui/config.rs
Normal file
62
packages/nx/src/native/tui/config.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
262
packages/nx/src/native/tui/lifecycle.rs
Normal file
262
packages/nx/src/native/tui/lifecycle.rs
Normal 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(())
|
||||||
|
}
|
||||||
8
packages/nx/src/native/tui/mod.rs
Normal file
8
packages/nx/src/native/tui/mod.rs
Normal 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;
|
||||||
120
packages/nx/src/native/tui/pty.rs
Normal file
120
packages/nx/src/native/tui/pty.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
212
packages/nx/src/native/tui/tui.rs
Normal file
212
packages/nx/src/native/tui/tui.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
392
packages/nx/src/native/tui/utils.rs
Normal file
392
packages/nx/src/native/tui/utils.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
66
packages/nx/src/tasks-runner/is-tui-enabled.ts
Normal file
66
packages/nx/src/tasks-runner/is-tui-enabled.ts
Normal 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 ${
|
||||||
|
|||||||
@ -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 ${
|
||||||
|
|||||||
@ -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 };
|
||||||
|
}
|
||||||
@ -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++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
isRunOne &&
|
this.tuiEnabled ||
|
||||||
!this.tasksSchedule.hasTasks() &&
|
(!this.tasksSchedule.hasTasks() &&
|
||||||
this.runningContinuousTasks.size === 0,
|
this.runningContinuousTasks.size === 0),
|
||||||
streamOutput,
|
streamOutput,
|
||||||
},
|
};
|
||||||
{
|
|
||||||
root: workspaceRoot, // only root is needed in runCommands
|
const runningTask = await runCommands(runCommandsOptions, {
|
||||||
} as any
|
root: workspaceRoot, // only root is needed in runCommands
|
||||||
);
|
} as any);
|
||||||
|
|
||||||
|
if (this.tuiEnabled && runningTask instanceof PseudoTtyProcess) {
|
||||||
|
// This is an external of a the pseudo terminal where a task is running and can be passed to the TUI
|
||||||
|
this.options.lifeCycle.registerRunningTask(
|
||||||
|
task.id,
|
||||||
|
runningTask.getParserAndWriter()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
runningTask.onExit((code, terminalOutput) => {
|
|
||||||
if (!streamOutput) {
|
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(
|
this.options.lifeCycle.printTaskTerminalOutput(
|
||||||
task,
|
task,
|
||||||
code === 0 ? 'success' : 'failure',
|
code === 0 ? 'success' : 'failure',
|
||||||
terminalOutput
|
terminalOutput
|
||||||
);
|
);
|
||||||
writeFileSync(temporaryOutputPath, 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, {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user