chore(core): add tui layout manager (#30947)

This commit is contained in:
James Henry 2025-05-01 01:43:11 +04:00 committed by GitHub
parent d6ea3ab45f
commit 0f4c085297
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 3819 additions and 2113 deletions

50
Cargo.lock generated
View File

@ -1608,6 +1608,19 @@ dependencies = [
"libc",
]
[[package]]
name = "insta"
version = "1.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50259abbaa67d11d2bcafc7ba1d094ed7a0c70e3ce893f0d0997f73558cb3084"
dependencies = [
"console",
"linked-hash-map",
"once_cell",
"pin-project",
"similar",
]
[[package]]
name = "instability"
version = "0.3.7"
@ -1747,6 +1760,12 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linked-hash-map"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]]
name = "linux-raw-sys"
version = "0.4.14"
@ -2144,6 +2163,7 @@ dependencies = [
"hashbrown 0.14.5",
"ignore",
"ignore-files 2.1.0",
"insta",
"itertools 0.10.5",
"machine-uid",
"mio 0.8.11",
@ -2268,9 +2288,9 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.19.0"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "overload"
@ -2338,6 +2358,26 @@ dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.100",
]
[[package]]
name = "pin-project-lite"
version = "0.2.13"
@ -3130,6 +3170,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a"
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "siphasher"
version = "0.3.11"

View File

@ -87,6 +87,7 @@ napi-build = '2.1.3'
[dev-dependencies]
assert_fs = "1.0.10"
insta = "1.42.2"
# This is only used for unit tests
swc_ecma_dep_graph = "0.109.1"
tempfile = "3.13.0"

View File

@ -7,4 +7,6 @@ export default {
globals: {},
displayName: 'nx',
preset: '../../jest.preset.js',
// Ensure cargo insta snapshots do not get picked up by jest
testPathIgnorePatterns: ['<rootDir>/src/native/tui'],
};

View File

@ -8,7 +8,7 @@ export declare class ExternalObject<T> {
}
}
export declare class AppLifeCycle {
constructor(tasks: Array<Task>, pinnedTasks: Array<string>, tuiCliArgs: TuiCliArgs, tuiConfig: TuiConfig, titleText: string)
constructor(tasks: Array<Task>, initiatingTasks: Array<string>, runMode: RunMode, 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
@ -273,6 +273,11 @@ export declare export declare function remove(src: string): void
export declare export declare function restoreTerminal(): void
export declare const enum RunMode {
RunOne = 0,
RunMany = 1
}
export interface RuntimeInput {
runtime: string
}

View File

@ -391,6 +391,7 @@ module.exports.IS_WASM = nativeBinding.IS_WASM
module.exports.parseTaskStatus = nativeBinding.parseTaskStatus
module.exports.remove = nativeBinding.remove
module.exports.restoreTerminal = nativeBinding.restoreTerminal
module.exports.RunMode = nativeBinding.RunMode
module.exports.TaskStatus = nativeBinding.TaskStatus
module.exports.testOnlyTransferFileMap = nativeBinding.testOnlyTransferFileMap
module.exports.transferProjectGraph = nativeBinding.transferProjectGraph

View File

@ -7,19 +7,19 @@ use napi::{
};
#[napi(object)]
#[derive(Default, Clone)]
#[derive(Default, Clone, Debug, PartialEq, Eq)]
pub struct Task {
pub id: String,
pub target: TaskTarget,
pub outputs: Vec<String>,
pub project_root: Option<String>,
pub start_time: Option<f64>,
pub end_time: Option<f64>,
pub start_time: Option<i64>,
pub end_time: Option<i64>,
pub continuous: Option<bool>,
}
#[napi(object)]
#[derive(Default, Clone)]
#[derive(Default, Clone, Debug, PartialEq, Eq)]
pub struct TaskTarget {
pub project: String,
pub target: String,
@ -27,7 +27,7 @@ pub struct TaskTarget {
}
#[napi(object)]
#[derive(Default, Clone)]
#[derive(Default, Clone, Debug, PartialEq, Eq)]
pub struct TaskResult {
pub task: Task,
pub status: String,

View File

@ -1,3 +1,7 @@
use crate::native::tasks::types::{Task, TaskResult};
use super::{app::Focus, components::tasks_list::TaskStatus};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Tick,
@ -13,13 +17,21 @@ pub enum Action {
RemoveFilterChar,
ScrollUp,
ScrollDown,
NextTask,
PreviousTask,
PinTask(String, usize),
UnpinTask(String, usize),
UnpinAllTasks,
SortTasks,
NextPage,
PreviousPage,
ToggleOutput,
FocusNext,
FocusPrevious,
NextTask,
PreviousTask,
SetSpacebarMode(bool),
ScrollPaneUp(usize),
ScrollPaneDown(usize),
UpdateTaskStatus(String, TaskStatus),
UpdateCloudMessage(String),
UpdateFocus(Focus),
StartCommand(Option<u32>),
StartTasks(Vec<Task>),
EndTasks(Vec<TaskResult>),
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
use color_eyre::eyre::Result;
use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::layout::Rect;
use ratatui::layout::{Rect, Size};
use std::any::Any;
use tokio::sync::mpsc::UnboundedSender;
@ -12,40 +12,110 @@ use super::{
pub mod countdown_popup;
pub mod help_popup;
pub mod help_text;
pub mod layout_manager;
pub mod pagination;
pub mod task_selection_manager;
pub mod tasks_list;
pub mod terminal_pane;
/// `Component` is a trait that represents a visual and interactive element of the user interface.
///
/// Implementors of this trait can be registered with the main application loop and will be able to
/// receive events, update state, and be rendered on the screen.
pub trait Component: Any + Send {
#[allow(unused_variables)]
/// Register an action handler that can send actions for processing if necessary.
///
/// # Arguments
///
/// * `tx` - An unbounded sender that can send actions.
///
/// # Returns
///
/// * `Result<()>` - An Ok result or an error.
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
let _ = tx; // to appease clippy
Ok(())
}
fn init(&mut self) -> Result<()> {
/// Initialize the component with a specified area if necessary.
///
/// # Arguments
///
/// * `area` - Rectangular area to initialize the component within.
///
/// # Returns
///
/// * `Result<()>` - An Ok result or an error.
fn init(&mut self, area: Size) -> Result<()> {
let _ = area; // to appease clippy
Ok(())
}
/// Handle incoming events and produce actions if necessary.
///
/// # Arguments
///
/// * `event` - An optional event to be processed.
///
/// # Returns
///
/// * `Result<Option<Action>>` - An action to be processed or none.
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)?,
let action = match event {
Some(Event::Key(key_event)) => self.handle_key_event(key_event)?,
Some(Event::Mouse(mouse_event)) => self.handle_mouse_event(mouse_event)?,
_ => None,
};
Ok(r)
Ok(action)
}
#[allow(unused_variables)]
fn handle_key_events(&mut self, key: KeyEvent) -> Result<Option<Action>> {
/// Handle key events and produce actions if necessary.
///
/// # Arguments
///
/// * `key` - A key event to be processed.
///
/// # Returns
///
/// * `Result<Option<Action>>` - An action to be processed or none.
fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
let _ = key; // to appease clippy
Ok(None)
}
#[allow(unused_variables)]
fn handle_mouse_events(&mut self, mouse: MouseEvent) -> Result<Option<Action>> {
/// Handle mouse events and produce actions if necessary.
///
/// # Arguments
///
/// * `mouse` - A mouse event to be processed.
///
/// # Returns
///
/// * `Result<Option<Action>>` - An action to be processed or none.
fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result<Option<Action>> {
let _ = mouse; // to appease clippy
Ok(None)
}
#[allow(unused_variables)]
/// Update the state of the component based on a received action. (REQUIRED)
///
/// # Arguments
///
/// * `action` - An action that may modify the state of the component.
///
/// # Returns
///
/// * `Result<Option<Action>>` - An action to be processed or none.
fn update(&mut self, action: Action) -> Result<Option<Action>> {
let _ = action; // to appease clippy
Ok(None)
}
fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()>;
/// Render the component on the screen. (REQUIRED)
///
/// # Arguments
///
/// * `f` - A frame used for rendering.
/// * `area` - The area in which the component should be drawn.
///
/// # Returns
///
/// * `Result<()>` - An Ok result or an error.
fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()>;
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;

View File

@ -98,16 +98,31 @@ impl CountdownPopup {
}
pub fn render(&mut self, f: &mut Frame<'_>, area: Rect) {
// Add a safety check to prevent rendering outside buffer bounds (this can happen if the user resizes the window a lot before it stabilizes it seems)
if area.height == 0 || area.width == 0 ||
area.x >= f.area().width ||
area.y >= f.area().height {
return; // Area is out of bounds, don't try to render
}
// Ensure area is entirely within frame bounds
let safe_area = Rect {
x: area.x,
y: area.y,
width: area.width.min(f.area().width.saturating_sub(area.x)),
height: area.height.min(f.area().height.saturating_sub(area.y)),
};
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));
let popup_height = popup_height.min(safe_area.height.saturating_sub(4));
let popup_width = popup_width.min(safe_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;
let popup_x = safe_area.x + (safe_area.width.saturating_sub(popup_width)) / 2;
let popup_y = safe_area.y + (safe_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);

View File

@ -9,6 +9,9 @@ use ratatui::{
},
};
use std::any::Any;
use tokio::sync::mpsc::UnboundedSender;
use crate::native::tui::action::Action;
use super::{Component, Frame};
@ -18,6 +21,7 @@ pub struct HelpPopup {
content_height: usize,
viewport_height: usize,
visible: bool,
action_tx: Option<UnboundedSender<Action>>,
}
impl HelpPopup {
@ -28,6 +32,7 @@ impl HelpPopup {
content_height: 0,
viewport_height: 0,
visible: false,
action_tx: None,
}
}
@ -67,6 +72,23 @@ impl HelpPopup {
}
pub fn render(&mut self, f: &mut Frame<'_>, area: Rect) {
// Add a safety check to prevent rendering outside buffer bounds (this can happen if the user resizes the window a lot before it stabilizes it seems)
if area.height == 0
|| area.width == 0
|| area.x >= f.area().width
|| area.y >= f.area().height
{
return; // Area is out of bounds, don't try to render
}
// Ensure area is entirely within frame bounds
let safe_area = Rect {
x: area.x,
y: area.y,
width: area.width.min(f.area().width.saturating_sub(area.x)),
height: area.height.min(f.area().height.saturating_sub(area.y)),
};
let percent_y = 85;
let percent_x = 70;
@ -77,7 +99,7 @@ impl HelpPopup {
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
.split(safe_area);
let popup_area = Layout::default()
.direction(Direction::Horizontal)
@ -110,6 +132,10 @@ impl HelpPopup {
("<esc>", "Set focus back to task list"),
("<space>", "Quick toggle a single output pane"),
("b", "Toggle task list visibility"),
(
"m",
"Cycle through layout modes: auto, vertical, horizontal",
),
("1", "Pin task to be shown in output pane 1"),
("2", "Pin task to be shown in output pane 2"),
(
@ -322,11 +348,17 @@ impl Clone for HelpPopup {
content_height: self.content_height,
viewport_height: self.viewport_height,
visible: self.visible,
action_tx: self.action_tx.clone(),
}
}
}
impl Component for HelpPopup {
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
self.action_tx = Some(tx);
Ok(())
}
fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> {
if self.visible {
self.render(f, rect);
@ -334,6 +366,16 @@ impl Component for HelpPopup {
Ok(())
}
fn update(&mut self, action: Action) -> Result<Option<Action>> {
match action {
Action::Resize(w, h) => {
self.handle_resize(w, h);
}
_ => {}
}
Ok(None)
}
fn as_any(&self) -> &dyn Any {
self
}

View File

@ -26,6 +26,21 @@ impl HelpText {
}
pub fn render(&self, f: &mut Frame<'_>, area: Rect) {
// Add a safety check to prevent rendering outside buffer bounds (this can happen if the user resizes the window a lot before it stabilizes it seems)
if area.height == 0 || area.width == 0 ||
area.x >= f.area().width ||
area.y >= f.area().height {
return; // Area is out of bounds, don't try to render
}
// Ensure area is entirely within frame bounds
let safe_area = Rect {
x: area.x,
y: area.y,
width: area.width.min(f.area().width.saturating_sub(area.x)),
height: area.height.min(f.area().height.saturating_sub(area.y)),
};
let base_style = if self.is_dimmed {
Style::default().add_modifier(Modifier::DIM)
} else {
@ -47,7 +62,7 @@ impl HelpText {
} else {
Alignment::Right
}),
area,
safe_area,
);
} else {
// Show full shortcuts
@ -76,7 +91,7 @@ impl HelpText {
f.render_widget(
Paragraph::new(Line::from(shortcuts)).alignment(Alignment::Center),
area,
safe_area,
);
}
}

View File

@ -0,0 +1,964 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
/// Represents the available layout modes for the TUI application.
///
/// - `Auto`: Layout is determined based on available terminal space
/// - `Vertical`: Forces vertical layout (task list above terminal panes)
/// - `Horizontal`: Forces horizontal layout (task list beside terminal panes)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LayoutMode {
Auto,
Vertical,
Horizontal,
}
impl Default for LayoutMode {
fn default() -> Self {
Self::Auto
}
}
/// Represents the possible arrangements of terminal panes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaneArrangement {
/// No terminal panes are visible.
None,
/// Only one terminal pane is visible.
Single,
/// Two terminal panes are visible, side by side or stacked.
Double,
}
/// Represents the visibility state of the task list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TaskListVisibility {
Visible,
Hidden,
}
impl Default for TaskListVisibility {
fn default() -> Self {
Self::Visible
}
}
/// Configuration for the layout calculations.
#[derive(Debug, Clone)]
pub struct LayoutConfig {
/// The mode that determines how components are arranged.
pub mode: LayoutMode,
/// The visibility state of the task list.
pub task_list_visibility: TaskListVisibility,
/// The arrangement of terminal panes.
pub pane_arrangement: PaneArrangement,
/// The total number of tasks.
pub task_count: usize,
}
impl Default for LayoutConfig {
fn default() -> Self {
Self {
mode: LayoutMode::Auto,
task_list_visibility: TaskListVisibility::Visible,
pane_arrangement: PaneArrangement::None,
task_count: 5,
}
}
}
/// Output of layout calculations containing areas for each component.
#[derive(Debug, Clone)]
pub struct LayoutAreas {
/// Area for the task list, if visible.
pub task_list: Option<Rect>,
/// Areas for terminal panes.
pub terminal_panes: Vec<Rect>,
}
/// Manages the layout of components in the TUI application.
///
/// The LayoutManager is responsible for calculating the optimal layout
/// for the task list and terminal panes based on the available terminal
/// space and the configured layout mode.
pub struct LayoutManager {
/// The current layout mode.
mode: LayoutMode,
/// The minimum width required for a horizontal layout to be viable.
min_horizontal_width: u16,
/// The minimum height required for a vertical layout to be viable.
min_vertical_height: u16,
/// The arrangement of terminal panes.
pane_arrangement: PaneArrangement,
/// The visibility state of the task list.
task_list_visibility: TaskListVisibility,
/// The total number of tasks.
task_count: usize,
/// Padding between task list and terminal panes in horizontal layout (left-right).
horizontal_padding: u16,
/// Padding between task list and terminal panes in vertical layout (top-bottom).
vertical_padding: u16,
}
impl LayoutManager {
/// Creates a new LayoutManager with default settings.
pub fn new(task_count: usize) -> Self {
Self {
mode: LayoutMode::Auto,
// TODO: figure out these values
min_horizontal_width: 120, // Minimum width for horizontal layout to be viable
min_vertical_height: 30, // Minimum height for vertical layout to be viable
pane_arrangement: PaneArrangement::None,
task_list_visibility: TaskListVisibility::Visible,
task_count,
horizontal_padding: 2, // Default horizontal padding of 2 characters
vertical_padding: 1, // Default vertical padding of 1 character
}
}
/// Sets the layout mode.
pub fn set_mode(&mut self, mode: LayoutMode) {
self.mode = mode;
}
/// Gets the current layout mode.
pub fn get_mode(&self) -> LayoutMode {
self.mode
}
/// Cycles the layout mode.
pub fn cycle_layout_mode(&mut self) {
self.mode = match self.mode {
LayoutMode::Auto => LayoutMode::Vertical,
LayoutMode::Vertical => LayoutMode::Horizontal,
LayoutMode::Horizontal => LayoutMode::Auto,
};
}
/// Sets the pane arrangement.
pub fn set_pane_arrangement(&mut self, pane_arrangement: PaneArrangement) {
self.pane_arrangement = pane_arrangement;
}
/// Gets the current pane arrangement.
pub fn get_pane_arrangement(&self) -> PaneArrangement {
self.pane_arrangement
}
/// Sets the task list visibility.
pub fn set_task_list_visibility(&mut self, visibility: TaskListVisibility) {
self.task_list_visibility = visibility;
}
/// Gets the current task list visibility.
pub fn get_task_list_visibility(&self) -> TaskListVisibility {
self.task_list_visibility
}
/// Sets the task count.
pub fn set_task_count(&mut self, count: usize) {
self.task_count = count;
}
/// Gets the current task count.
pub fn get_task_count(&self) -> usize {
self.task_count
}
/// Sets the horizontal padding between task list and terminal panes.
pub fn set_horizontal_padding(&mut self, padding: u16) {
self.horizontal_padding = padding;
}
/// Gets the current horizontal padding between task list and terminal panes.
pub fn get_horizontal_padding(&self) -> u16 {
self.horizontal_padding
}
/// Sets the vertical padding between task list and terminal panes.
pub fn set_vertical_padding(&mut self, padding: u16) {
self.vertical_padding = padding;
}
/// Gets the current vertical padding between task list and terminal panes.
pub fn get_vertical_padding(&self) -> u16 {
self.vertical_padding
}
/// Calculates the layout based on the given terminal area.
///
/// Returns a LayoutAreas struct containing the calculated areas for each component.
pub fn calculate_layout(&self, area: Rect) -> LayoutAreas {
// Basic bounds checking to prevent crashes
if area.width == 0 || area.height == 0 {
return LayoutAreas {
task_list: None,
terminal_panes: Vec::new(),
};
}
match self.task_list_visibility {
TaskListVisibility::Hidden => self.calculate_layout_hidden_task_list(area),
TaskListVisibility::Visible => self.calculate_layout_visible_task_list(area),
}
}
/// Calculates the layout when the task list is hidden.
fn calculate_layout_hidden_task_list(&self, area: Rect) -> LayoutAreas {
let terminal_panes = match self.pane_arrangement {
PaneArrangement::None => Vec::new(),
PaneArrangement::Single => vec![area],
PaneArrangement::Double => {
// Split the area into two equal parts
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
vec![chunks[0], chunks[1]]
}
};
LayoutAreas {
task_list: None,
terminal_panes,
}
}
/// Calculates the layout when the task list is visible.
fn calculate_layout_visible_task_list(&self, area: Rect) -> LayoutAreas {
// Determine whether to use vertical or horizontal layout
let use_vertical = match self.mode {
LayoutMode::Auto => {
self.is_vertical_layout_preferred(area.width, area.height, self.task_count)
}
LayoutMode::Vertical => true,
LayoutMode::Horizontal => false,
};
if use_vertical {
self.calculate_vertical_layout(area)
} else {
self.calculate_horizontal_layout(area)
}
}
/// Calculates a vertical layout (task list above terminal panes).
fn calculate_vertical_layout(&self, area: Rect) -> LayoutAreas {
// If no panes, task list gets the full area
if self.pane_arrangement == PaneArrangement::None {
return LayoutAreas {
task_list: Some(area),
terminal_panes: Vec::new(),
};
}
// Prevent divide-by-zero
let task_list_height = if area.height < 3 {
1
} else {
area.height / 3
};
// Apply padding only if there's enough space
let padding_height = if area.height > task_list_height + self.vertical_padding {
self.vertical_padding
} else {
0
};
// Ensure terminal pane has at least 1 row
let available_height = area.height.saturating_sub(task_list_height);
let terminal_pane_height = available_height.saturating_sub(padding_height);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(task_list_height),
Constraint::Length(padding_height),
Constraint::Length(terminal_pane_height),
])
.split(area);
let task_list_area = chunks[0];
let terminal_pane_area = chunks[2]; // Skip the padding area (chunks[1])
let terminal_panes = match self.pane_arrangement {
PaneArrangement::None => Vec::new(),
PaneArrangement::Single => vec![terminal_pane_area],
PaneArrangement::Double => {
// Split the terminal pane area horizontally for two panes
let pane_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(terminal_pane_area);
vec![pane_chunks[0], pane_chunks[1]]
}
};
LayoutAreas {
task_list: Some(task_list_area),
terminal_panes,
}
}
/// Calculates a horizontal layout (task list beside terminal panes).
fn calculate_horizontal_layout(&self, area: Rect) -> LayoutAreas {
// If no panes, task list gets the full area
if self.pane_arrangement == PaneArrangement::None {
return LayoutAreas {
task_list: Some(area),
terminal_panes: Vec::new(),
};
}
// Prevent divide-by-zero
let task_list_width = if area.width < 3 {
1
} else {
area.width / 3
};
// Apply padding only if there's enough space
let padding_width = if area.width > task_list_width + self.horizontal_padding {
self.horizontal_padding
} else {
0
};
// Ensure terminal pane has at least 1 column
let available_width = area.width.saturating_sub(task_list_width);
let terminal_pane_width = available_width.saturating_sub(padding_width);
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(task_list_width),
Constraint::Length(padding_width),
Constraint::Length(terminal_pane_width),
])
.split(area);
let task_list_area = chunks[0];
let terminal_pane_area = chunks[2]; // Skip the padding area (chunks[1])
let terminal_panes = match self.pane_arrangement {
PaneArrangement::None => Vec::new(),
PaneArrangement::Single => vec![terminal_pane_area],
PaneArrangement::Double => {
// For two panes, split the terminal pane area vertically
let pane_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(terminal_pane_area);
vec![pane_chunks[0], pane_chunks[1]]
}
};
LayoutAreas {
task_list: Some(task_list_area),
terminal_panes,
}
}
/// Creates a LayoutConfig from the current internal state.
pub fn create_config(&self) -> LayoutConfig {
LayoutConfig {
mode: self.mode,
task_list_visibility: self.task_list_visibility,
pane_arrangement: self.pane_arrangement,
task_count: self.task_count,
}
}
/// Sets the minimum width required for a horizontal layout.
pub fn set_min_horizontal_width(&mut self, width: u16) {
self.min_horizontal_width = width;
}
/// Sets the minimum height required for a vertical layout.
pub fn set_min_vertical_height(&mut self, height: u16) {
self.min_vertical_height = height;
}
/// Determines if a vertical layout is preferred based on terminal dimensions and tasks.
///
/// This is used in Auto mode to decide between vertical and horizontal layouts.
///
/// Factors that influence the decision:
/// - Terminal aspect ratio
/// - Number of tasks (single task prefers vertical layout)
/// - Minimum dimensions requirements
fn is_vertical_layout_preferred(
&self,
terminal_width: u16,
terminal_height: u16,
task_count: usize,
) -> bool {
// If there's only a single task, prefer vertical layout
if task_count <= 1 {
return true;
}
// Calculate aspect ratio (width/height)
let aspect_ratio = terminal_width as f32 / terminal_height as f32;
// If very wide and not very tall, prefer horizontal
if aspect_ratio > 2.0 && terminal_height < self.min_vertical_height {
return false;
}
// If very tall and not very wide, prefer vertical
if aspect_ratio < 1.0 && terminal_width < self.min_horizontal_width {
return true;
}
// Otherwise, prefer horizontal for wider terminals, vertical for taller ones
aspect_ratio < 1.5
}
}
impl Default for LayoutManager {
fn default() -> Self {
Self::new(5)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_area(width: u16, height: u16) -> Rect {
Rect::new(0, 0, width, height)
}
#[test]
fn test_default_mode_is_auto() {
let layout_manager = LayoutManager::new(5);
assert_eq!(layout_manager.get_mode(), LayoutMode::Auto);
}
#[test]
fn test_default_properties() {
let layout_manager = LayoutManager::new(5);
assert_eq!(layout_manager.get_pane_arrangement(), PaneArrangement::None);
assert_eq!(layout_manager.get_task_list_visibility(), TaskListVisibility::Visible);
assert_eq!(layout_manager.get_task_count(), 5);
}
#[test]
fn test_set_properties() {
let mut layout_manager = LayoutManager::new(5);
// Test setting pane arrangement
layout_manager.set_pane_arrangement(PaneArrangement::Double);
assert_eq!(
layout_manager.get_pane_arrangement(),
PaneArrangement::Double
);
// Test setting task list visibility
layout_manager.set_task_list_visibility(TaskListVisibility::Hidden);
assert_eq!(
layout_manager.get_task_list_visibility(),
TaskListVisibility::Hidden
);
// Test setting task count
layout_manager.set_task_count(10);
assert_eq!(layout_manager.get_task_count(), 10);
}
#[test]
fn test_hidden_task_list_layout() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(100, 50);
layout_manager.set_task_list_visibility(TaskListVisibility::Hidden);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
assert!(layout.task_list.is_none());
assert_eq!(layout.terminal_panes.len(), 1);
assert_eq!(layout.terminal_panes[0], area);
}
#[test]
fn test_hidden_task_list_with_double_panes() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(100, 50);
layout_manager.set_task_list_visibility(TaskListVisibility::Hidden);
layout_manager.set_pane_arrangement(PaneArrangement::Double);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
assert!(layout.task_list.is_none());
assert_eq!(layout.terminal_panes.len(), 2);
assert_eq!(layout.terminal_panes[0].width, 50); // Half of total width
assert_eq!(layout.terminal_panes[1].width, 50);
}
#[test]
fn test_auto_mode_selects_horizontal_for_wide_terminal() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(200, 60); // Wide terminal
layout_manager.set_mode(LayoutMode::Auto);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
assert!(layout.task_list.is_some());
// In horizontal layout, task list should be on the left, taking about 1/3 of width
let task_list = layout.task_list.unwrap();
assert_eq!(task_list.x, 0);
assert_eq!(task_list.y, 0);
assert_eq!(task_list.width, 200 / 3);
assert_eq!(task_list.height, 60);
}
#[test]
fn test_auto_mode_selects_vertical_for_tall_terminal() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(80, 100); // Tall terminal
layout_manager.set_mode(LayoutMode::Auto);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
assert!(layout.task_list.is_some());
// In vertical layout, task list should be on top, taking about 1/3 of height
let task_list = layout.task_list.unwrap();
assert_eq!(task_list.x, 0);
assert_eq!(task_list.y, 0);
assert_eq!(task_list.width, 80);
assert_eq!(task_list.height, 100 / 3);
}
#[test]
fn test_auto_mode_prefers_vertical_for_single_task() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(200, 60); // Wide terminal that would normally use horizontal
layout_manager.set_mode(LayoutMode::Auto);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(1); // Single task
let layout = layout_manager.calculate_layout(area);
assert!(layout.task_list.is_some());
// Even though terminal is wide, layout should be vertical for a single task
let task_list = layout.task_list.unwrap();
assert_eq!(task_list.x, 0);
assert_eq!(task_list.y, 0);
assert_eq!(task_list.width, 200);
assert_eq!(task_list.height, 60 / 3);
}
#[test]
fn test_forced_vertical_mode() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(200, 60); // Wide terminal that would normally use horizontal
layout_manager.set_mode(LayoutMode::Vertical);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
assert!(layout.task_list.is_some());
// Even though terminal is wide, layout should be vertical
let task_list = layout.task_list.unwrap();
assert_eq!(task_list.x, 0);
assert_eq!(task_list.y, 0);
assert_eq!(task_list.width, 200);
assert_eq!(task_list.height, 60 / 3);
}
#[test]
fn test_forced_horizontal_mode() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(80, 100); // Tall terminal that would normally use vertical
layout_manager.set_mode(LayoutMode::Horizontal);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
assert!(layout.task_list.is_some());
// Even though terminal is tall, layout should be horizontal
let task_list = layout.task_list.unwrap();
assert_eq!(task_list.x, 0);
assert_eq!(task_list.y, 0);
assert_eq!(task_list.width, 80 / 3);
assert_eq!(task_list.height, 100);
}
#[test]
fn test_double_panes_in_vertical_layout() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(100, 80);
layout_manager.set_mode(LayoutMode::Vertical);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Double);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
assert_eq!(layout.terminal_panes.len(), 2);
// In vertical layout with two panes, they should be side by side
// below the task list
assert_eq!(layout.terminal_panes[0].width, 50); // Half of total width
assert_eq!(layout.terminal_panes[1].width, 50);
assert_eq!(
layout.terminal_panes[0].height,
layout.terminal_panes[1].height
);
assert!(layout.terminal_panes[0].y > layout.task_list.unwrap().y);
}
#[test]
fn test_double_panes_in_horizontal_layout() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(150, 60);
layout_manager.set_mode(LayoutMode::Horizontal);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Double);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
assert_eq!(layout.terminal_panes.len(), 2);
// In horizontal layout with two panes, they should be stacked
// to the right of the task list
assert_eq!(layout.terminal_panes[0].height, 30); // Half of total height
assert_eq!(layout.terminal_panes[1].height, 30);
assert_eq!(
layout.terminal_panes[0].width,
layout.terminal_panes[1].width
);
assert!(layout.terminal_panes[0].x > layout.task_list.unwrap().x);
}
#[test]
fn test_no_panes() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(100, 50);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::None);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
assert!(layout.task_list.is_some());
assert_eq!(layout.terminal_panes.len(), 0);
}
#[test]
fn test_create_config() {
let mut layout_manager = LayoutManager::new(5);
// Set custom values for all properties
layout_manager.set_mode(LayoutMode::Vertical);
layout_manager.set_pane_arrangement(PaneArrangement::Double);
layout_manager.set_task_list_visibility(TaskListVisibility::Hidden);
layout_manager.set_task_count(8);
// Create config from internal state
let config = layout_manager.create_config();
// Verify all properties match
assert_eq!(config.mode, LayoutMode::Vertical);
assert_eq!(config.pane_arrangement, PaneArrangement::Double);
assert_eq!(config.task_list_visibility, TaskListVisibility::Hidden);
assert_eq!(config.task_count, 8);
}
#[test]
fn test_padding_between_components() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(100, 60);
// Test with default horizontal padding (2)
layout_manager.set_mode(LayoutMode::Horizontal);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
let layout = layout_manager.calculate_layout(area);
let task_list = layout.task_list.unwrap();
let terminal_pane = layout.terminal_panes[0];
// The gap between task list and terminal pane should equal the horizontal padding
assert_eq!(terminal_pane.x - (task_list.x + task_list.width), 2);
// Test with increased horizontal padding
layout_manager.set_horizontal_padding(3);
let layout = layout_manager.calculate_layout(area);
let task_list = layout.task_list.unwrap();
let terminal_pane = layout.terminal_panes[0];
// The gap should now be 3
assert_eq!(terminal_pane.x - (task_list.x + task_list.width), 3);
// Test with vertical layout and default vertical padding (1)
layout_manager.set_mode(LayoutMode::Vertical);
let layout = layout_manager.calculate_layout(area);
let task_list = layout.task_list.unwrap();
let terminal_pane = layout.terminal_panes[0];
// The gap should be vertical and equal to the vertical padding
assert_eq!(terminal_pane.y - (task_list.y + task_list.height), 1);
// Test with increased vertical padding
layout_manager.set_vertical_padding(2);
let layout = layout_manager.calculate_layout(area);
let task_list = layout.task_list.unwrap();
let terminal_pane = layout.terminal_panes[0];
// The gap should now be 2
assert_eq!(terminal_pane.y - (task_list.y + task_list.height), 2);
}
#[cfg(test)]
mod visual_tests {
use super::*;
use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Borders};
use ratatui::{backend::TestBackend, Terminal};
/// Render a layout configuration to a TestBackend for visualization
fn render_layout(
width: u16,
height: u16,
layout_manager: &LayoutManager,
) -> Terminal<TestBackend> {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let areas = layout_manager.calculate_layout(frame.area());
// Render task list if visible
if let Some(task_list_area) = areas.task_list {
let task_list_block = Block::default()
.title("Task List")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green));
frame.render_widget(task_list_block, task_list_area);
}
// Render terminal panes
for (i, pane_area) in areas.terminal_panes.iter().enumerate() {
let pane_block = Block::default()
.title(format!("Terminal Pane {}", i + 1))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
frame.render_widget(pane_block, *pane_area);
}
})
.unwrap();
terminal
}
/// Visual test for horizontal layout with two panes
#[test]
fn test_visualize_horizontal_layout_with_two_panes() {
let mut layout_manager = LayoutManager::new(5);
layout_manager.set_mode(LayoutMode::Horizontal);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Double);
layout_manager.set_task_count(5);
let terminal = render_layout(100, 40, &layout_manager);
insta::assert_snapshot!(terminal.backend());
}
/// Visual test for vertical layout with two panes
#[test]
fn test_visualize_vertical_layout_with_two_panes() {
let mut layout_manager = LayoutManager::new(5);
layout_manager.set_mode(LayoutMode::Vertical);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Double);
layout_manager.set_task_count(5);
let terminal = render_layout(100, 40, &layout_manager);
insta::assert_snapshot!(terminal.backend());
}
/// Visual test for hidden task list with two panes
#[test]
fn test_visualize_hidden_task_list_with_two_panes() {
let mut layout_manager = LayoutManager::new(5);
layout_manager.set_mode(LayoutMode::Auto);
layout_manager.set_task_list_visibility(TaskListVisibility::Hidden);
layout_manager.set_pane_arrangement(PaneArrangement::Double);
layout_manager.set_task_count(5);
let terminal = render_layout(100, 40, &layout_manager);
insta::assert_snapshot!(terminal.backend());
}
/// Visual test for auto mode with varied terminal sizes
#[test]
fn test_visualize_auto_mode_wide_terminal() {
let mut layout_manager = LayoutManager::new(5);
layout_manager.set_mode(LayoutMode::Auto);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(5);
let terminal = render_layout(120, 30, &layout_manager);
insta::assert_snapshot!(terminal.backend());
}
/// Visual test for auto mode with single task (should be vertical regardless of terminal size)
#[test]
fn test_visualize_auto_mode_single_task() {
let mut layout_manager = LayoutManager::new(5);
layout_manager.set_mode(LayoutMode::Auto);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(1);
let terminal = render_layout(120, 30, &layout_manager);
insta::assert_snapshot!(terminal.backend());
}
/// Visual test for auto mode with tall terminal
#[test]
fn test_visualize_auto_mode_tall_terminal() {
let mut layout_manager = LayoutManager::new(5);
layout_manager.set_mode(LayoutMode::Auto);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(5);
let terminal = render_layout(80, 60, &layout_manager);
insta::assert_snapshot!(terminal.backend());
}
/// Visual test for single pane layout
#[test]
fn test_visualize_single_pane() {
let mut layout_manager = LayoutManager::new(5);
layout_manager.set_mode(LayoutMode::Horizontal);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Single);
layout_manager.set_task_count(5);
let terminal = render_layout(100, 40, &layout_manager);
insta::assert_snapshot!(terminal.backend());
}
/// Visual test using different layout configurations
#[test]
fn test_visualize_different_configurations() {
// Create a layout manager with one configuration
let mut layout_manager1 = LayoutManager::new(5);
layout_manager1.set_mode(LayoutMode::Horizontal);
layout_manager1.set_pane_arrangement(PaneArrangement::Single);
// Create another layout manager with different settings
let mut layout_manager2 = LayoutManager::new(5);
layout_manager2.set_mode(LayoutMode::Vertical);
layout_manager2.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager2.set_pane_arrangement(PaneArrangement::Double);
layout_manager2.set_task_count(3);
// Render both for visual comparison
let terminal_config1 = render_layout(100, 40, &layout_manager1);
insta::assert_snapshot!("config1", terminal_config1.backend());
let terminal_config2 = render_layout(100, 40, &layout_manager2);
insta::assert_snapshot!("config2", terminal_config2.backend());
}
/// Visual test for default case - no panes and full width task list
#[test]
fn test_visualize_no_panes_full_width_task_list() {
let layout_manager = LayoutManager::new(5);
let terminal = render_layout(100, 40, &layout_manager);
insta::assert_snapshot!(terminal.backend());
}
// These tests will run even without the snapshot feature enabled
// to verify layout calculations are correct
#[test]
fn test_verify_horizontal_layout_areas() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(100, 40);
layout_manager.set_mode(LayoutMode::Horizontal);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Double);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
// Verify task list exists and is on the left
assert!(layout.task_list.is_some());
let task_list = layout.task_list.unwrap();
assert_eq!(task_list.x, 0);
assert_eq!(task_list.width, 100 / 3);
assert_eq!(task_list.height, 40);
// Verify we have two panes to the right of the task list
assert_eq!(layout.terminal_panes.len(), 2);
assert!(layout.terminal_panes[0].x > task_list.x);
assert!(layout.terminal_panes[1].x > task_list.x);
// Verify panes are stacked
assert_eq!(layout.terminal_panes[0].height, 20);
assert_eq!(layout.terminal_panes[1].height, 20);
assert!(layout.terminal_panes[1].y > layout.terminal_panes[0].y);
}
#[test]
fn test_verify_vertical_layout_areas() {
let mut layout_manager = LayoutManager::new(5);
let area = create_test_area(100, 40);
layout_manager.set_mode(LayoutMode::Vertical);
layout_manager.set_task_list_visibility(TaskListVisibility::Visible);
layout_manager.set_pane_arrangement(PaneArrangement::Double);
layout_manager.set_task_count(5);
let layout = layout_manager.calculate_layout(area);
// Verify task list exists and is on top
assert!(layout.task_list.is_some());
let task_list = layout.task_list.unwrap();
assert_eq!(task_list.y, 0);
assert_eq!(task_list.width, 100);
assert_eq!(task_list.height, 40 / 3);
// Verify we have two panes below the task list
assert_eq!(layout.terminal_panes.len(), 2);
assert!(layout.terminal_panes[0].y > task_list.y);
assert!(layout.terminal_panes[1].y > task_list.y);
// Verify panes are side by side
assert_eq!(layout.terminal_panes[0].width, 50);
assert_eq!(layout.terminal_panes[1].width, 50);
assert!(layout.terminal_panes[1].x > layout.terminal_panes[0].x);
}
}
}

View File

@ -19,7 +19,25 @@ impl Pagination {
}
}
/// Renders the pagination at the given location with the specified focus state.
pub fn render(&self, f: &mut Frame<'_>, area: Rect, is_dimmed: bool) {
// Add a safety check to prevent rendering outside buffer bounds (this can happen if the user resizes the window a lot before it stabilizes it seems)
if area.height == 0
|| area.width == 0
|| area.x >= f.area().width
|| area.y >= f.area().height
{
return; // Area is out of bounds, don't try to render
}
// Ensure area is entirely within frame bounds
let safe_area = Rect {
x: area.x,
y: area.y,
width: area.width.min(f.area().width.saturating_sub(area.x)),
height: area.height.min(f.area().height.saturating_sub(area.y)),
};
let base_style = if is_dimmed {
Style::default().add_modifier(Modifier::DIM)
} else {
@ -59,6 +77,6 @@ impl Pagination {
let pagination_line = Line::from(spans);
let pagination = Paragraph::new(pagination_line);
f.render_widget(pagination, area);
f.render_widget(pagination, safe_area);
}
}

View File

@ -0,0 +1,44 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: terminal_config1.backend()
---
"┌Task List──────────────────────┐ ┌Terminal Pane 1────────────────────────────────────────────────┐"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"└───────────────────────────────┘ └───────────────────────────────────────────────────────────────┘"

View File

@ -0,0 +1,44 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: terminal_config2.backend()
---
"┌Task List─────────────────────────────────────────────────────────────────────────────────────────┐"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
" "
"┌Terminal Pane 1─────────────────────────────────┐┌Terminal Pane 2─────────────────────────────────┐"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"└────────────────────────────────────────────────┘└────────────────────────────────────────────────┘"

View File

@ -0,0 +1,34 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: terminal.backend()
---
"┌Task List─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘"
" "
"┌Terminal Pane 1───────────────────────────────────────────────────────────────────────────────────────────────────────┐"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘"

View File

@ -0,0 +1,64 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: terminal.backend()
---
"┌Task List─────────────────────────────────────────────────────────────────────┐"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────────────────┘"
" "
"┌Terminal Pane 1───────────────────────────────────────────────────────────────┐"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────────────────┘"

View File

@ -0,0 +1,34 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: terminal.backend()
---
"┌Task List─────────────────────────────┐ ┌Terminal Pane 1─────────────────────────────────────────────────────────────┐"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"└──────────────────────────────────────┘ └────────────────────────────────────────────────────────────────────────────┘"

View File

@ -0,0 +1,44 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: output
---
"┌Terminal Pane 1─────────────────────────────────┐┌Terminal Pane 2─────────────────────────────────┐"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"└────────────────────────────────────────────────┘└────────────────────────────────────────────────┘"

View File

@ -0,0 +1,44 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: terminal.backend()
---
"┌Task List──────────────────────┐ ┌Terminal Pane 1────────────────────────────────────────────────┐"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ └───────────────────────────────────────────────────────────────┘"
"│ │ ┌Terminal Pane 2────────────────────────────────────────────────┐"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"└───────────────────────────────┘ └───────────────────────────────────────────────────────────────┘"

View File

@ -0,0 +1,44 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: terminal.backend()
---
"┌Task List─────────────────────────────────────────────────────────────────────────────────────────┐"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────────────────────────────────────┘"

View File

@ -0,0 +1,44 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: terminal.backend()
---
"┌Task List──────────────────────┐ ┌Terminal Pane 1────────────────────────────────────────────────┐"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"│ │ │ │"
"└───────────────────────────────┘ └───────────────────────────────────────────────────────────────┘"

View File

@ -0,0 +1,44 @@
---
source: packages/nx/src/native/tui/components/layout_manager.rs
expression: terminal.backend()
---
"┌Task List─────────────────────────────────────────────────────────────────────────────────────────┐"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
" "
"┌Terminal Pane 1─────────────────────────────────┐┌Terminal Pane 2─────────────────────────────────┐"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"└────────────────────────────────────────────────┘└────────────────────────────────────────────────┘"

File diff suppressed because it is too large Load Diff

View File

@ -287,6 +287,36 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
type State = TerminalPaneState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
// Add bounds checking to prevent panic when terminal is too narrow
// Safety check: ensure area is at least 5x5 to render anything properly
if area.width < 5 || area.height < 5 {
// Just render a minimal indicator instead of a full pane
let safe_area = Rect {
x: area.x,
y: area.y,
width: area.width.min(buf.area().width.saturating_sub(area.x)),
height: area.height.min(buf.area().height.saturating_sub(area.y)),
};
if safe_area.width > 0 && safe_area.height > 0 {
// Only attempt to render if we have a valid area
let text = "...";
let paragraph = Paragraph::new(text)
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center);
Widget::render(paragraph, safe_area, buf);
}
return;
}
// Ensure the area doesn't extend beyond buffer boundaries
let safe_area = Rect {
x: area.x,
y: area.y,
width: area.width.min(buf.area().width.saturating_sub(area.x)),
height: area.height.min(buf.area().height.saturating_sub(area.y)),
};
let base_style = self.get_base_style(state.task_status);
let border_style = if state.is_focused {
base_style
@ -319,7 +349,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
.alignment(Alignment::Center)
.style(Style::default());
Widget::render(paragraph, area, buf);
Widget::render(paragraph, safe_area, buf);
return;
}
@ -342,7 +372,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
.alignment(Alignment::Center)
.style(Style::default());
Widget::render(paragraph, area, buf);
Widget::render(paragraph, safe_area, buf);
return;
}
@ -365,7 +395,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
.alignment(Alignment::Center)
.style(Style::default());
Widget::render(paragraph, area, buf);
Widget::render(paragraph, safe_area, buf);
return;
}
@ -386,11 +416,11 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
.alignment(Alignment::Center)
.style(Style::default());
Widget::render(paragraph, area, buf);
Widget::render(paragraph, safe_area, buf);
return;
}
let inner_area = block.inner(area);
let inner_area = block.inner(safe_area);
if let Some(pty_data) = &self.pty_data {
if let Some(pty) = &pty_data.pty {
@ -416,7 +446,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
};
let pseudo_term = PseudoTerminal::new(&*screen).block(block);
Widget::render(pseudo_term, area, buf);
Widget::render(pseudo_term, safe_area, buf);
// Only render scrollbar if needed
if needs_scrollbar {
@ -426,10 +456,10 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
.end_symbol(Some(""))
.style(border_style);
scrollbar.render(area, buf, &mut state.scrollbar_state);
scrollbar.render(safe_area, buf, &mut state.scrollbar_state);
}
// Show interactive/readonly status for focused, non-cache hit, tasks
// Show interactive/readonly status for focused, in progress tasks
if state.task_status == TaskStatus::InProgress && state.is_focused {
// Bottom right status
let bottom_text = if self.is_currently_interactive() {
@ -458,17 +488,20 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
.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,
};
// Ensure status text doesn't extend past safe area
if text_width as u16 + 3 < safe_area.width {
let bottom_right_area = Rect {
x: safe_area.x + safe_area.width - text_width as u16 - 3,
y: safe_area.y + safe_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);
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() {
@ -489,47 +522,53 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
.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,
};
// Ensure status text doesn't extend past safe area
if mode_width as u16 + 3 < safe_area.width {
let top_right_area = Rect {
x: safe_area.x + safe_area.width - mode_width as u16 - 3,
y: safe_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);
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,
};
// Ensure paddings don't extend past safe area
if padding_width + 3 < safe_area.width {
// Top padding
let top_right_area = Rect {
x: safe_area.x + safe_area.width - padding_width - 3,
y: safe_area.y,
width: padding_width + 2,
height: 1,
};
Paragraph::new(padding_text.clone())
.alignment(Alignment::Right)
.style(border_style)
.render(top_right_area, buf);
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,
};
// Bottom padding
let bottom_right_area = Rect {
x: safe_area.x + safe_area.width - padding_width - 3,
y: safe_area.y + safe_area.height - 1,
width: padding_width + 2,
height: 1,
};
Paragraph::new(padding_text)
.alignment(Alignment::Right)
.style(border_style)
.render(bottom_right_area, buf);
Paragraph::new(padding_text)
.alignment(Alignment::Right)
.style(border_style)
.render(bottom_right_area, buf);
}
}
}
}

View File

@ -1,3 +1,4 @@
use hashbrown::HashSet;
use napi::bindgen_prelude::*;
use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction};
use napi::JsObject;
@ -53,6 +54,12 @@ impl From<(TuiConfig, &RustTuiCliArgs)> for RustTuiConfig {
}
}
#[napi]
pub enum RunMode {
RunOne,
RunMany,
}
#[napi]
#[derive(Clone)]
pub struct AppLifeCycle {
@ -64,6 +71,8 @@ impl AppLifeCycle {
#[napi(constructor)]
pub fn new(
tasks: Vec<Task>,
initiating_tasks: Vec<String>,
run_mode: RunMode,
pinned_tasks: Vec<String>,
tui_cli_args: TuiCliArgs,
tui_config: TuiConfig,
@ -75,10 +84,14 @@ impl AppLifeCycle {
// Convert JSON TUI configuration to our Rust TuiConfig
let rust_tui_config = RustTuiConfig::from((tui_config, &rust_tui_cli_args));
let initiating_tasks = initiating_tasks.into_iter().collect();
Self {
app: Arc::new(std::sync::Mutex::new(
App::new(
tasks.into_iter().map(|t| t.into()).collect(),
initiating_tasks,
run_mode,
pinned_tasks,
rust_tui_config,
title_text,
@ -190,9 +203,14 @@ impl AppLifeCycle {
// Store callback for cleanup
app.set_done_callback(done_callback);
app.register_action_handler(action_tx.clone()).ok();
for component in app.components.iter_mut() {
component.register_action_handler(action_tx.clone()).ok();
component.init().ok();
}
app.init(tui.size().unwrap()).ok();
for component in app.components.iter_mut() {
component.init(tui.size().unwrap()).ok();
}
}
debug!("Initialized Components");
@ -252,7 +270,7 @@ impl AppLifeCycle {
#[napi]
pub fn set_task_status(&mut self, task_id: String, status: TaskStatus) {
let mut app = self.app.lock().unwrap();
app.set_task_status(task_id, status)
app.update_task_status(task_id, status)
}
#[napi]

View File

@ -1,6 +1,8 @@
use hashbrown::HashSet;
use crate::native::tui::components::tasks_list::{TaskItem, TaskStatus};
pub fn format_duration(duration_ms: u128) -> String {
pub fn format_duration(duration_ms: i64) -> String {
if duration_ms == 0 {
"<1ms".to_string()
} else if duration_ms < 1000 {
@ -10,7 +12,7 @@ pub fn format_duration(duration_ms: u128) -> String {
}
}
pub fn format_duration_since(start_ms: u128, end_ms: u128) -> String {
pub fn format_duration_since(start_ms: i64, end_ms: i64) -> String {
format_duration(end_ms.saturating_sub(start_ms))
}
@ -36,32 +38,37 @@ pub fn normalize_newlines(input: &[u8]) -> Vec<u8> {
///
/// 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
/// 2. Highlighted tasks second (tasks whose names appear in the highlighted_names list)
/// 3. Failure tasks third
/// 4. Other completed tasks fourth (sorted by end_time if available)
/// 5. 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]) {
pub fn sort_task_items(tasks: &mut [TaskItem], highlighted_names: &HashSet<String>) {
tasks.sort_by(|a, b| {
// Map status to a numeric category for sorting
let status_to_category = |status: &TaskStatus| -> u8 {
let status_to_category = |status: &TaskStatus, name: &str| -> u8 {
if highlighted_names.contains(&name.to_string()) {
return 1; // Highlighted tasks come second
}
match status {
TaskStatus::InProgress | TaskStatus::Shared => 0,
TaskStatus::Failure => 1,
TaskStatus::Failure => 2,
TaskStatus::Success
| TaskStatus::LocalCacheKeptExisting
| TaskStatus::LocalCache
| TaskStatus::RemoteCache
| TaskStatus::Skipped
| TaskStatus::Stopped => 2,
TaskStatus::NotStarted => 3,
| TaskStatus::Stopped => 3,
TaskStatus::NotStarted => 4,
}
};
let a_category = status_to_category(&a.status);
let b_category = status_to_category(&b.status);
let a_category = status_to_category(&a.status, &a.name);
let b_category = status_to_category(&b.status, &b.name);
// First compare by status category
if a_category != b_category {
@ -69,7 +76,7 @@ pub fn sort_task_items(tasks: &mut [TaskItem]) {
}
// For completed tasks, sort by end_time if available
if a_category == 1 || a_category == 2 {
if a_category == 2 || a_category == 3 {
// Failure or Success categories
match (a.end_time, b.end_time) {
(Some(time_a), Some(time_b)) => {
@ -94,7 +101,7 @@ mod tests {
use super::*;
// Helper function to create a TaskItem for testing
fn create_task(name: &str, status: TaskStatus, end_time: Option<u128>) -> TaskItem {
fn create_task(name: &str, status: TaskStatus, end_time: Option<i64>) -> TaskItem {
let mut task = TaskItem::new(name.to_string(), false);
task.status = status;
task.end_time = end_time;
@ -110,7 +117,7 @@ mod tests {
create_task("task4", TaskStatus::Failure, Some(200)),
];
sort_task_items(&mut tasks);
sort_task_items(&mut tasks, &HashSet::new());
// Expected order: InProgress, Failure, Success, NotStarted
assert_eq!(tasks[0].status, TaskStatus::InProgress);
@ -119,6 +126,31 @@ mod tests {
assert_eq!(tasks[3].status, TaskStatus::NotStarted);
}
#[test]
fn test_highlighted_tasks() {
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)),
create_task("highlighted1", TaskStatus::NotStarted, None),
create_task("highlighted2", TaskStatus::Success, Some(300)),
];
// Highlight two tasks, one that is NotStarted and one that is Success
let highlighted = HashSet::from(["highlighted1".to_string(), "highlighted2".to_string()]);
sort_task_items(&mut tasks, &highlighted);
// Expected order: InProgress (task2), Highlighted (highlighted1, highlighted2),
// Failure (task4), Success (task3), NotStarted (task1)
assert_eq!(tasks[0].name, "task2"); // InProgress
assert!(tasks[1].name == "highlighted1" || tasks[1].name == "highlighted2");
assert!(tasks[2].name == "highlighted1" || tasks[2].name == "highlighted2");
assert_eq!(tasks[3].name, "task4"); // Failure
assert_eq!(tasks[4].name, "task3"); // Success
assert_eq!(tasks[5].name, "task1"); // NotStarted
}
#[test]
fn test_sort_completed_tasks_by_end_time() {
let mut tasks = vec![
@ -127,7 +159,8 @@ mod tests {
create_task("task3", TaskStatus::Success, Some(200)),
];
sort_task_items(&mut tasks);
let empty_highlighted: HashSet<String> = HashSet::new();
sort_task_items(&mut tasks, &empty_highlighted);
// Should be sorted by end_time: 100, 200, 300
assert_eq!(tasks[0].name, "task2");
@ -143,7 +176,8 @@ mod tests {
create_task("task3", TaskStatus::Success, None),
];
sort_task_items(&mut tasks);
let empty_highlighted: HashSet<String> = HashSet::new();
sort_task_items(&mut tasks, &empty_highlighted);
// Tasks with end_time come before those without
assert_eq!(tasks[0].name, "task2");
@ -160,7 +194,8 @@ mod tests {
create_task("b", TaskStatus::NotStarted, None),
];
sort_task_items(&mut tasks);
let empty_highlighted: HashSet<String> = HashSet::new();
sort_task_items(&mut tasks, &empty_highlighted);
// Should be sorted alphabetically: a, b, c
assert_eq!(tasks[0].name, "a");
@ -181,13 +216,8 @@ mod tests {
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)
let empty_highlighted: HashSet<String> = HashSet::new();
sort_task_items(&mut tasks, &empty_highlighted);
// Check the order within each status group
let names: Vec<&str> = tasks.iter().map(|t| &t.name[..]).collect();
@ -226,7 +256,8 @@ mod tests {
create_task("b", TaskStatus::Success, Some(100)),
];
sort_task_items(&mut tasks);
let empty_highlighted: HashSet<String> = HashSet::new();
sort_task_items(&mut tasks, &empty_highlighted);
// When end_times are the same, should sort by name
assert_eq!(tasks[0].name, "a");
@ -238,8 +269,9 @@ mod tests {
fn test_sort_empty_list() {
let mut tasks: Vec<TaskItem> = vec![];
let empty_highlighted: HashSet<String> = HashSet::new();
// Should not panic on empty list
sort_task_items(&mut tasks);
sort_task_items(&mut tasks, &empty_highlighted);
assert!(tasks.is_empty());
}
@ -248,8 +280,9 @@ mod tests {
fn test_sort_single_task() {
let mut tasks = vec![create_task("task", TaskStatus::Success, Some(100))];
let empty_highlighted: HashSet<String> = HashSet::new();
// Should not change a single-element list
sort_task_items(&mut tasks);
sort_task_items(&mut tasks, &empty_highlighted);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].name, "task");
@ -266,15 +299,54 @@ mod tests {
// Mark the original positions
let original_names = tasks.iter().map(|t| t.name.clone()).collect::<Vec<_>>();
let empty_highlighted: HashSet<String> = HashSet::new();
// Sort should maintain original order for equal elements
sort_task_items(&mut tasks);
sort_task_items(&mut tasks, &empty_highlighted);
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() {
fn test_sort_edge_cases() {
// Test with extreme end_time values
let mut tasks = vec![
create_task("a", TaskStatus::Success, Some(i64::MAX)),
create_task("b", TaskStatus::Success, Some(0)),
create_task("c", TaskStatus::Success, Some(i64::MAX / 2)),
];
let empty_highlighted: HashSet<String> = HashSet::new();
sort_task_items(&mut tasks, &empty_highlighted);
// 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");
}
#[test]
fn test_highlighted_tasks_empty_list() {
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)),
];
// Empty highlighted list should not affect sorting
let empty_highlighted: HashSet<String> = HashSet::new();
sort_task_items(&mut tasks, &empty_highlighted);
// Expected order: InProgress, Failure, Success, NotStarted
assert_eq!(tasks[0].name, "task2"); // InProgress
assert_eq!(tasks[1].name, "task4"); // Failure
assert_eq!(tasks[2].name, "task3"); // Success
assert_eq!(tasks[3].name, "task1"); // NotStarted
}
#[test]
fn test_large_random_dataset() {
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
@ -303,8 +375,9 @@ mod tests {
})
.collect();
let empty_highlighted: HashSet<String> = HashSet::new();
// Sort should not panic with large random dataset
sort_task_items(&mut tasks);
sort_task_items(&mut tasks, &empty_highlighted);
// Verify the sort maintains the expected ordering rules
for i in 1..tasks.len() {
@ -312,22 +385,23 @@ mod tests {
let b = &tasks[i];
// Map status to category for comparison
let status_to_category = |status: &TaskStatus| -> u8 {
let status_to_category = |status: &TaskStatus, name: &str| -> u8 {
// In this test we're using an empty highlighted list
match status {
TaskStatus::InProgress | TaskStatus::Shared => 0,
TaskStatus::Failure => 1,
TaskStatus::Failure => 2,
TaskStatus::Success
| TaskStatus::LocalCacheKeptExisting
| TaskStatus::LocalCache
| TaskStatus::RemoteCache
| TaskStatus::Stopped
| TaskStatus::Skipped => 2,
TaskStatus::NotStarted => 3,
| TaskStatus::Skipped => 3,
TaskStatus::NotStarted => 4,
}
};
let a_category = status_to_category(&a.status);
let b_category = status_to_category(&b.status);
let a_category = status_to_category(&a.status, &a.name);
let b_category = status_to_category(&b.status, &b.name);
if a_category < b_category {
// If a's category is less than b's, that's correct
@ -341,7 +415,7 @@ mod tests {
}
// Same category, check end_time for completed tasks
if a_category == 1 || a_category == 2 {
if a_category == 2 || a_category == 3 {
match (a.end_time, b.end_time) {
(Some(time_a), Some(time_b)) => {
if time_a > time_b {
@ -367,21 +441,4 @@ mod tests {
}
}
}
#[test]
fn test_sort_edge_cases() {
// Test with extreme end_time values
let mut tasks = vec![
create_task("a", TaskStatus::Success, Some(u128::MAX)),
create_task("b", TaskStatus::Success, Some(0)),
create_task("c", TaskStatus::Success, Some(u128::MAX / 2)),
];
sort_task_items(&mut tasks);
// Should sort by end_time: 0, MAX/2, MAX
assert_eq!(tasks[0].name, "b");
assert_eq!(tasks[1].name, "c");
assert_eq!(tasks[2].name, "a");
}
}

View File

@ -17,6 +17,7 @@ import {
getTaskDetails,
hashTasksThatDoNotDependOnOutputsOfOtherTasks,
} from '../hasher/hash-task';
import { RunMode } from '../native';
import {
runPostTasksExecution,
runPreTasksExecution,
@ -89,6 +90,8 @@ async function getTerminalOutputLifeCycle(
const overridesWithoutHidden = { ...overrides };
delete overridesWithoutHidden['__overrides_unparsed__'];
const isRunOne = initiatingProject != null;
if (isTuiEnabled(nxJson)) {
const interceptedNxCloudLogs: (string | Uint8Array<ArrayBufferLike>)[] = [];
@ -188,6 +191,8 @@ async function getTerminalOutputLifeCycle(
if (tasks.length > 0) {
appLifeCycle = new AppLifeCycle(
tasks,
initiatingTasks.map((t) => t.id),
isRunOne ? RunMode.RunOne : RunMode.RunMany,
pinnedTasks,
nxArgs ?? {},
nxJson.tui ?? {},
@ -290,7 +295,6 @@ async function getTerminalOutputLifeCycle(
}
const { runnerOptions } = getRunner(nxArgs, nxJson);
const isRunOne = initiatingProject != null;
const useDynamicOutput = shouldUseDynamicLifeCycle(
tasks,
runnerOptions,