feat(core): add mouse support to the terminal UI (#35868)
## Current Behavior The Nx terminal UI (TUI) does not capture mouse events. Scroll only works in terminals that translate the wheel into arrow keys, so it is non-functional in terminals that don't (notably macOS Terminal.app). Clicks, double-clicks, opening the Nx Cloud link, and selecting terminal output are all unavailable. ## Expected Behavior The full-screen TUI now captures the mouse (and releases it again when dropping to the inline view and on every teardown path — normal restore, `Drop`, the panic hook, and the JS `restoreTerminal` path — so the terminal is never left emitting mouse escape sequences): - **Wheel** scrolls whatever is under the cursor (an output pane scrolls its buffer; the task list moves its selection). - **Click** a task row to select it; **click** the Nx Cloud link to open it. - **Double-click** an output pane, or a selected task row, to drop into the inline view. - **Drag** in a focused pane to select its output, with auto-scroll when the drag reaches the top/bottom edge; the selection is copied to the clipboard on release and highlighted while active. Mouse reporting uses DECSET `1000`/`1002`/`1006` (press, drag, SGR) only — not `1003` "any-motion" — to avoid a flood of hover events. Capture is intentionally **off** in the inline view, where the moving sub-region of the scrollback makes absolute mouse coordinates unreliable and native selection is preferable. ### Implementation notes - New per-frame hit-test region map (panes + task list) resolves what's under the cursor; the `TasksList` component owns its own row/cloud-link geometry. - Text selection is tracked in absolute content coordinates so it stays anchored as the pane scrolls; the highlight is painted by reverse-videoing the selected cells after `tui-term` renders (it exposes no selection API). - Unit tests cover selection containment/normalization and text extraction (`cargo test --lib tui::` — 210 passing). > [!IMPORTANT] > Mouse interaction has been verified by compilation and unit tests, but the > on-screen behavior (click targeting, drag feel, auto-scroll cadence, > wide-character selection fidelity) should be **dogfooded in a real terminal** > before this is marked ready. Opening as a draft for that reason. ## Related Issue(s) Implements the Nx TUI mouse-capture work: - NXC-3945 — enable mouse capture when entering full-screen terminal view - NXC-3944 — disable mouse capture when entering inline view - NXC-3941 — click on task in tasks list to select it - NXC-3940 — click to open cloud link - NXC-3942 — double-click terminal pane to enter inline mode - NXC-3943 — double-click already-selected task to enter inline view - NXC-3946 — text selection within terminal pane - NXC-3558 — likely fixed (TUI shifted off screen on scroll) since the wheel no longer leaks to the real terminal NXC-4199 (mouse/resize forwarding to interactive child programs) is intentionally deferred as a separate follow-up. --------- Co-authored-by: nx-cloud[bot] <71083854+nx-cloud[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
disallowed-types = [
|
||||
# We need to ensure adjustments for light and dark themes are applied appropriately
|
||||
{ path = "ratatui::style::Color", reason = "Use our utils from crate::native::tui::colors instead to ensure appropriate light/dark theme support" },
|
||||
# Embedded links need their rendered position; ratatui's Paragraph discards it.
|
||||
{ path = "ratatui::widgets::Paragraph", reason = "Use crate::native::tui::components::nx_paragraph::NxParagraph instead (supports clickable links; nx_paragraph.rs is the only sanctioned wrapper)" },
|
||||
]
|
||||
Vendored
+6
@@ -47,6 +47,12 @@ export declare class AppLifeCycle {
|
||||
registerRunningBatch(batchId: string, batchInfo: BatchInfo): void
|
||||
appendBatchOutput(batchId: string, output: string): void
|
||||
setBatchStatus(batchId: string, status: BatchStatus): void
|
||||
/**
|
||||
* Set a clickable Nx Cloud link in the TUI: `label` is the text shown,
|
||||
* `url` is opened when it's clicked. This is a `LifeCycle` method so the Nx
|
||||
* Cloud client can call it via the lifecycle it already receives.
|
||||
*/
|
||||
setCloudLink(label: string, url: string): void
|
||||
}
|
||||
|
||||
export declare class ChildProcess {
|
||||
|
||||
@@ -33,11 +33,16 @@ pub enum Action {
|
||||
UpdateTaskStatus(String, TaskStatus),
|
||||
SetTaskTiming(String, i64, i64),
|
||||
UpdateCloudMessage(String),
|
||||
/// Set a structured Nx Cloud link to render as a clickable label (display
|
||||
/// label, href URL). Distinct from `UpdateCloudMessage` so the displayed
|
||||
/// text and the opened URL can differ (e.g. "View in Nx Cloud").
|
||||
UpdateCloudLink(String, String),
|
||||
UpdateFocus(Focus),
|
||||
StartCommand(Option<u32>),
|
||||
StartTasks(Vec<Task>),
|
||||
EndTasks(Vec<TaskResult>),
|
||||
ToggleDebugMode,
|
||||
ToggleMouseCapture,
|
||||
SendConsoleMessage(String),
|
||||
ConsoleMessengerAvailable(bool),
|
||||
EndCommand,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
use arboard::Clipboard;
|
||||
|
||||
/// Copy `text` to the system clipboard, returning whether it succeeded. Keeps
|
||||
/// the `Clipboard::new()` / `set_text` dance in one place so every copy path
|
||||
/// (pane selection, full output, region selection, inline output) stays in sync.
|
||||
pub(crate) fn copy_to_clipboard(text: &str) -> bool {
|
||||
Clipboard::new()
|
||||
.and_then(|mut clipboard| clipboard.set_text(text))
|
||||
.is_ok()
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use super::{
|
||||
action::Action,
|
||||
tui::{Event, Frame},
|
||||
};
|
||||
use link::LinkRegistry;
|
||||
|
||||
pub mod countdown_popup;
|
||||
pub mod dependency_view;
|
||||
@@ -15,6 +16,8 @@ pub mod help_popup;
|
||||
pub mod help_text;
|
||||
pub mod hint_popup;
|
||||
pub mod layout_manager;
|
||||
pub mod link;
|
||||
pub mod nx_paragraph;
|
||||
pub mod task_selection_manager;
|
||||
pub mod tasks_list;
|
||||
pub mod terminal_pane;
|
||||
@@ -104,6 +107,19 @@ pub trait Component: Any + Send {
|
||||
/// * `Result<()>` - An Ok result or an error.
|
||||
fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()>;
|
||||
|
||||
/// The component's clickable external links recorded during the last
|
||||
/// `draw`, if it has any. The app hit-tests these on click. Components
|
||||
/// without links use the default (`None`).
|
||||
fn link_registry(&self) -> Option<&LinkRegistry> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Mutable access to the link registry so the app can clear it at the start
|
||||
/// of each draw pass (the component repopulates it while drawing).
|
||||
fn link_registry_mut(&mut self) -> Option<&mut LinkRegistry> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||
}
|
||||
|
||||
@@ -1,116 +1,67 @@
|
||||
use color_eyre::eyre::Result;
|
||||
use ratatui::{
|
||||
Frame,
|
||||
buffer::CellDiffOption,
|
||||
layout::{Alignment, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{
|
||||
Block, BorderType, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
|
||||
Block, BorderType, Borders, Clear, Padding, Scrollbar, ScrollbarOrientation,
|
||||
ScrollbarState, Wrap,
|
||||
},
|
||||
};
|
||||
use std::any::Any;
|
||||
use std::num::NonZeroU16;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::native::tui::lifecycle::PerformanceSummaryPayload;
|
||||
use crate::native::tui::lifecycle::{Link as SummaryLink, PerformanceSummaryPayload};
|
||||
use crate::native::tui::theme::THEME;
|
||||
use crate::native::tui::utils::{format_duration, pluralize};
|
||||
|
||||
use super::Component;
|
||||
use super::link::{Link, LinkRegistry};
|
||||
use super::nx_paragraph::{NxLine, NxParagraph, NxSpan, NxText};
|
||||
|
||||
/// Word-wrapped row count for `lines` at `width`, using the same wrapping the
|
||||
/// Paragraph applies — a hand-rolled character-wrap estimate diverges from
|
||||
/// ratatui's word wrapping.
|
||||
fn wrapped_rows(lines: &[Line], width: u16) -> usize {
|
||||
if lines.is_empty() {
|
||||
return 0;
|
||||
/// Convert a styled line into an `NxLine`, turning any occurrence of a known
|
||||
/// link phrase into a clickable [`Link`]. Replaces the old buffer-scanning OSC 8
|
||||
/// injection: the link's rendered position now falls out of `NxParagraph`'s
|
||||
/// placement instead of being recovered after the fact.
|
||||
fn linkify_line(line: Line<'static>, links: &[SummaryLink]) -> NxLine {
|
||||
let mut out: Vec<NxSpan> = Vec::new();
|
||||
for span in line.spans {
|
||||
linkify_span(span, links, &mut out);
|
||||
}
|
||||
Paragraph::new(lines.to_vec())
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(width.max(1))
|
||||
NxLine::from_spans(out)
|
||||
}
|
||||
|
||||
/// Make `visible` a clickable OSC 8 hyperlink to `href` by rewriting the buffer:
|
||||
/// ratatui's text path strips the escape framing from cells (ratatui#1028), so we
|
||||
/// scan for the rendered text and replace each per-row run with one self-contained
|
||||
/// OSC 8 cell (ForcedWidth = the run's width), blanking the rest. Whitespace is
|
||||
/// collapsed when matching so a wrapped phrase still matches; no match → no-op.
|
||||
fn inject_osc8(f: &mut Frame<'_>, inner_area: Rect, visible: &str, href: &str) {
|
||||
if visible.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Flatten the inner area, recording each kept char's (col, row); whitespace
|
||||
// runs (incl. row breaks) collapse to a single space.
|
||||
let mut flat = String::new();
|
||||
let mut pos: Vec<(u16, u16)> = Vec::new();
|
||||
let mut prev_space = true; // collapse + trim leading
|
||||
for row in inner_area.y..inner_area.bottom() {
|
||||
for col in inner_area.x..inner_area.right() {
|
||||
let ch = f
|
||||
.buffer_mut()
|
||||
.cell((col, row))
|
||||
.and_then(|c| c.symbol().chars().next())
|
||||
.unwrap_or(' ');
|
||||
if ch == ' ' {
|
||||
if !prev_space {
|
||||
flat.push(' ');
|
||||
pos.push((u16::MAX, u16::MAX)); // sentinel: collapsed space
|
||||
prev_space = true;
|
||||
fn linkify_span(span: Span<'static>, links: &[SummaryLink], out: &mut Vec<NxSpan>) {
|
||||
let style = span.style;
|
||||
let content = span.content.into_owned();
|
||||
let mut rest = content.as_str();
|
||||
loop {
|
||||
// Earliest occurrence of any link phrase in the remaining text.
|
||||
let next = links
|
||||
.iter()
|
||||
.filter(|l| !l.text.is_empty())
|
||||
.filter_map(|l| rest.find(l.text.as_str()).map(|idx| (idx, l)))
|
||||
.min_by_key(|(idx, _)| *idx);
|
||||
match next {
|
||||
Some((idx, link)) => {
|
||||
if idx > 0 {
|
||||
out.push(NxSpan::Text(Span::styled(rest[..idx].to_string(), style)));
|
||||
}
|
||||
out.push(NxSpan::Link(Link::new(
|
||||
link.text.clone(),
|
||||
link.href.clone(),
|
||||
)));
|
||||
rest = &rest[idx + link.text.len()..];
|
||||
if rest.is_empty() {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
flat.push(ch);
|
||||
pos.push((col, row));
|
||||
prev_space = false;
|
||||
}
|
||||
}
|
||||
if !prev_space {
|
||||
flat.push(' '); // a row break wraps like a space
|
||||
pos.push((u16::MAX, u16::MAX));
|
||||
prev_space = true;
|
||||
}
|
||||
}
|
||||
let target: String = visible.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
// Injects at the FIRST match. Invariant: each linked phrase must be unique
|
||||
// within the popup's rendered text, else the link lands on the wrong run.
|
||||
let Some(byte_idx) = flat.find(&target) else {
|
||||
return;
|
||||
};
|
||||
let char_start = flat[..byte_idx].chars().count();
|
||||
let target_len = target.chars().count();
|
||||
|
||||
// Group the matched chars into per-row [first_col, last_col] segments.
|
||||
let mut segments: Vec<(u16, u16, u16)> = Vec::new();
|
||||
for (col, row) in pos.iter().skip(char_start).take(target_len).copied() {
|
||||
if col == u16::MAX {
|
||||
continue; // collapsed space between rows/words
|
||||
}
|
||||
match segments.last_mut() {
|
||||
Some((r, _first, last)) if *r == row => *last = col,
|
||||
_ => segments.push((row, col, col)),
|
||||
}
|
||||
}
|
||||
|
||||
for (row, first_col, last_col) in segments {
|
||||
let mut segment_text = String::new();
|
||||
for col in first_col..=last_col {
|
||||
if let Some(cell) = f.buffer_mut().cell((col, row)) {
|
||||
segment_text.push_str(cell.symbol());
|
||||
}
|
||||
}
|
||||
let seq = format!("\x1b]8;;{href}\x07{segment_text}\x1b]8;;\x07");
|
||||
if let Some(cell) = f.buffer_mut().cell_mut((first_col, row)) {
|
||||
cell.set_symbol(&seq);
|
||||
cell.set_style(Style::default().fg(THEME.info));
|
||||
if let Some(width) = NonZeroU16::new(last_col - first_col + 1) {
|
||||
cell.set_diff_option(CellDiffOption::ForcedWidth(width));
|
||||
}
|
||||
}
|
||||
// The ForcedWidth cell owns the run's columns; blank the rest.
|
||||
for col in (first_col + 1)..=last_col {
|
||||
if let Some(cell) = f.buffer_mut().cell_mut((col, row)) {
|
||||
cell.set_symbol(" ");
|
||||
None => {
|
||||
if !rest.is_empty() {
|
||||
out.push(NxSpan::Text(Span::styled(rest.to_string(), style)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,11 +101,20 @@ pub struct CountdownPopup {
|
||||
scrollbar_state: ScrollbarState,
|
||||
content_height: usize,
|
||||
viewport_height: usize,
|
||||
/// Screen rect of the bordered popup box from the last render, used for
|
||||
/// click-outside-to-dismiss hit-testing.
|
||||
last_area: Option<Rect>,
|
||||
/// Screen rect of the inner text area (inside the border, clear of the
|
||||
/// scrollbar) from the last render, used to bound text selection/links.
|
||||
content_area: Option<Rect>,
|
||||
/// The run report shown above the hint text (None until set).
|
||||
summary: Option<PerformanceSummaryPayload>,
|
||||
/// When pinned, the auto-exit countdown is stopped and the popup stays open
|
||||
/// until the user explicitly quits.
|
||||
pinned: bool,
|
||||
/// Clickable report links recorded during render; the app hit-tests these
|
||||
/// (via the modal mouse path) to open them.
|
||||
link_registry: LinkRegistry,
|
||||
}
|
||||
|
||||
impl CountdownPopup {
|
||||
@@ -167,8 +127,11 @@ impl CountdownPopup {
|
||||
scrollbar_state: ScrollbarState::default(),
|
||||
content_height: 0,
|
||||
viewport_height: 0,
|
||||
last_area: None,
|
||||
content_area: None,
|
||||
summary: None,
|
||||
pinned: false,
|
||||
link_registry: LinkRegistry::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +228,16 @@ impl CountdownPopup {
|
||||
self.content_height > self.viewport_height
|
||||
}
|
||||
|
||||
/// The bordered popup box drawn last frame, if visible.
|
||||
pub fn last_area(&self) -> Option<Rect> {
|
||||
self.last_area
|
||||
}
|
||||
|
||||
/// The inner text area drawn last frame, if visible.
|
||||
pub fn content_area(&self) -> Option<Rect> {
|
||||
self.content_area
|
||||
}
|
||||
|
||||
pub fn start_countdown(&mut self, duration_secs: u64) {
|
||||
self.visible = true;
|
||||
self.start_time = Some(Instant::now());
|
||||
@@ -349,17 +322,12 @@ impl CountdownPopup {
|
||||
}
|
||||
}
|
||||
|
||||
/// Report-mode content: the report body, then the "longest tasks" rec so its list
|
||||
/// ends the popup.
|
||||
fn report_mode_content(
|
||||
&self,
|
||||
mut content: Vec<Line<'static>>,
|
||||
) -> (Vec<Line<'static>>, Option<usize>) {
|
||||
let url_line_index = content.len();
|
||||
/// Report-mode content: the report body, then the "longest tasks" rec so its
|
||||
/// list ends the popup. The recommendation phrases in `links` become clickable
|
||||
/// via `linkify_line` at render.
|
||||
fn report_mode_content(&self, mut content: Vec<Line<'static>>) -> Vec<Line<'static>> {
|
||||
content.extend(self.longest_tasks_lines());
|
||||
// Some(..) marks report mode so the OSC 8 pass runs for the recommendation
|
||||
// links; the index value itself is unused.
|
||||
(content, Some(url_line_index))
|
||||
content
|
||||
}
|
||||
|
||||
/// Exit-dialog content shown when there's no report (e.g. q pressed mid-run) — just
|
||||
@@ -448,12 +416,26 @@ impl CountdownPopup {
|
||||
let has_report = !report.is_empty();
|
||||
// Two modes: a finished run shows the Performance Report; otherwise (e.g. q
|
||||
// pressed mid-run) the original exit dialog with just the interactive hints.
|
||||
let (content, url_line_index) = if has_report {
|
||||
let content = if has_report {
|
||||
self.report_mode_content(report)
|
||||
} else {
|
||||
(Self::exit_dialog_content(), None)
|
||||
Self::exit_dialog_content()
|
||||
};
|
||||
|
||||
// Turn the recommendation phrases into clickable links, then lay the report
|
||||
// out through NxParagraph so each link's rect is recorded.
|
||||
self.link_registry.clear();
|
||||
let all_links: Vec<SummaryLink> = self
|
||||
.summary
|
||||
.as_ref()
|
||||
.map(|s| s.links.clone())
|
||||
.unwrap_or_default();
|
||||
let nx_content: NxText = content
|
||||
.into_iter()
|
||||
.map(|line| linkify_line(line, &all_links))
|
||||
.collect::<Vec<NxLine>>()
|
||||
.into();
|
||||
|
||||
let seconds_remaining = if let Some(start_time) = self.start_time {
|
||||
let elapsed = start_time.elapsed();
|
||||
if elapsed >= self.duration {
|
||||
@@ -511,7 +493,7 @@ impl CountdownPopup {
|
||||
// Size the popup to its content so a short cached-run report doesn't float in a
|
||||
// fixed-width box, while staying wide enough for the title row and the bottom
|
||||
// keybindings; cap at 70 so long recommendations still wrap.
|
||||
let content_width = content.iter().map(|l| l.width() as u16).max().unwrap_or(0);
|
||||
let content_width = nx_content.max_width();
|
||||
let title_row_width: u16 = title_spans.iter().map(|s| s.width() as u16).sum::<u16>()
|
||||
+ close_hint.iter().map(|s| s.width() as u16).sum::<u16>();
|
||||
let bottom_width: u16 = bottom_hints
|
||||
@@ -534,7 +516,7 @@ impl CountdownPopup {
|
||||
.inner(Rect::new(0, 0, popup_width, 1))
|
||||
.width
|
||||
.max(1);
|
||||
let estimated_rows = wrapped_rows(&content, inner_width);
|
||||
let estimated_rows = nx_content.wrapped_rows(inner_width, false);
|
||||
let popup_height = ((estimated_rows as u16).saturating_add(4))
|
||||
.min(safe_area.height.saturating_sub(4))
|
||||
.max(5);
|
||||
@@ -544,6 +526,9 @@ impl CountdownPopup {
|
||||
|
||||
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
|
||||
|
||||
// Record the popup box so the app can hit-test mouse events against it.
|
||||
self.last_area = Some(popup_area);
|
||||
|
||||
let mut block = Block::default()
|
||||
.title(Line::from(title_spans))
|
||||
.title_alignment(Alignment::Left)
|
||||
@@ -557,11 +542,16 @@ impl CountdownPopup {
|
||||
}
|
||||
|
||||
let inner_area = block.inner(popup_area);
|
||||
// Record the inner text area so the app can bound selection/link hit-tests.
|
||||
self.content_area = Some(inner_area);
|
||||
self.viewport_height = inner_area.height as usize;
|
||||
// The text area sits inside the border + padding, so it never includes
|
||||
// the scrollbar (drawn on the far-right border column).
|
||||
self.content_area = Some(inner_area);
|
||||
|
||||
// Content height in wrapped rows, driving the scrollbar and the scroll
|
||||
// bound in scroll_down.
|
||||
self.content_height = wrapped_rows(&content, inner_area.width);
|
||||
self.content_height = nx_content.wrapped_rows(inner_area.width, false);
|
||||
|
||||
let scrollable_rows = self.content_height.saturating_sub(self.viewport_height);
|
||||
let needs_scrollbar = scrollable_rows > 0;
|
||||
@@ -579,7 +569,7 @@ impl CountdownPopup {
|
||||
// rows by scroll_down's max_scroll, matching Paragraph::scroll's row-based
|
||||
// offset — slicing the unwrapped `content` with a wrapped-row offset
|
||||
// panicked the process.
|
||||
let popup = Paragraph::new(content.clone())
|
||||
let popup = NxParagraph::new(nx_content)
|
||||
.block(block.clone())
|
||||
// trim: false preserves leading whitespace so the report's
|
||||
// indentation renders.
|
||||
@@ -587,18 +577,9 @@ impl CountdownPopup {
|
||||
.scroll((self.scroll_offset as u16, 0));
|
||||
|
||||
f.render_widget(Clear, popup_area);
|
||||
f.render_widget(popup, popup_area);
|
||||
|
||||
// Turn the report's links into real OSC 8 hyperlinks (see inject_osc8).
|
||||
if url_line_index.is_some() {
|
||||
if let Some(s) = self.summary.as_ref() {
|
||||
// Hyperlink the recommendation phrases (labels and hrefs from the
|
||||
// payload); a phrase that isn't shown isn't found.
|
||||
for link in &s.links {
|
||||
inject_osc8(f, inner_area, &link.text, &link.href);
|
||||
}
|
||||
}
|
||||
}
|
||||
// NxParagraph records each rendered link's rect into the registry, which
|
||||
// the app's modal mouse handler hit-tests to open it.
|
||||
f.render_stateful_widget(popup, popup_area, &mut self.link_registry);
|
||||
|
||||
if needs_scrollbar {
|
||||
// Blank out the corners so the scrollbar arrows don't collide with
|
||||
@@ -623,14 +604,14 @@ impl CountdownPopup {
|
||||
};
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(top_text)
|
||||
NxParagraph::new(top_text)
|
||||
.alignment(Alignment::Right)
|
||||
.style(Style::default().fg(THEME.info)),
|
||||
top_right_area,
|
||||
);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(bottom_text)
|
||||
NxParagraph::new(bottom_text)
|
||||
.alignment(Alignment::Right)
|
||||
.style(Style::default().fg(THEME.info)),
|
||||
bottom_right_area,
|
||||
@@ -651,10 +632,22 @@ impl Component for CountdownPopup {
|
||||
fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> {
|
||||
if self.visible {
|
||||
self.render(f, rect);
|
||||
} else {
|
||||
self.last_area = None;
|
||||
self.content_area = None;
|
||||
self.link_registry.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn link_registry(&self) -> Option<&LinkRegistry> {
|
||||
Some(&self.link_registry)
|
||||
}
|
||||
|
||||
fn link_registry_mut(&mut self) -> Option<&mut LinkRegistry> {
|
||||
Some(&mut self.link_registry)
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
@@ -840,41 +833,31 @@ mod tests {
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer().clone();
|
||||
|
||||
// Every OSC 8 cell targets a known page; at least one the remote cache.
|
||||
let mut cache_links = 0;
|
||||
for y in 0..buffer.area.height {
|
||||
for x in 0..buffer.area.width {
|
||||
let sym = buffer.cell((x, y)).unwrap().symbol();
|
||||
if sym.contains("\x1b]8;;") {
|
||||
assert!(sym.contains(CACHE_HREF), "unexpected OSC 8 target");
|
||||
if sym.contains(CACHE_HREF) {
|
||||
cache_links += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(cache_links >= 1, "the cache phrase should be linked");
|
||||
let registry = popup
|
||||
.link_registry()
|
||||
.expect("countdown exposes a link registry");
|
||||
// The cache phrase wraps to multiple rows; it must be clickable on every
|
||||
// one of them (a rect was recorded per wrapped row).
|
||||
let mut hit_rows: Vec<u16> = (0..buffer.area.height)
|
||||
.flat_map(|y| (0..buffer.area.width).map(move |x| (x, y)))
|
||||
.filter(|&(x, y)| registry.hit_test(x, y) == Some(CACHE_HREF))
|
||||
.map(|(_, y)| y)
|
||||
.collect();
|
||||
hit_rows.sort_unstable();
|
||||
hit_rows.dedup();
|
||||
assert!(
|
||||
hit_rows.len() >= 2,
|
||||
"the wrapped cache phrase should be clickable on each of its rows"
|
||||
);
|
||||
|
||||
// The raw URL is never visible, and the phrase (start AND end, i.e. every
|
||||
// wrapped row) is consumed by the links rather than left plain.
|
||||
for y in 0..buffer.area.height {
|
||||
let mut text = String::new();
|
||||
for x in 0..buffer.area.width {
|
||||
let s = buffer.cell((x, y)).unwrap().symbol();
|
||||
text.push_str(if s.contains('\x1b') { "\u{0}" } else { s });
|
||||
}
|
||||
assert!(
|
||||
!text.contains("https://nx.dev/ci/features/remote-cache"),
|
||||
"raw remote-cache URL visible on row {y}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("Drastically reduce"),
|
||||
"phrase start left unlinked on row {y}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("team and CI"),
|
||||
"phrase end (wrapped row) left unlinked on row {y}"
|
||||
);
|
||||
}
|
||||
// The raw URL is never visible (the phrase is the display text).
|
||||
let visible: String = (0..buffer.area.height)
|
||||
.flat_map(|y| (0..buffer.area.width).map(move |x| (x, y)))
|
||||
.map(|(x, y)| buffer.cell((x, y)).unwrap().symbol().to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
!visible.contains("https://nx.dev/ci/features/remote-cache"),
|
||||
"raw remote-cache URL must not be visible"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ use std::collections::HashMap;
|
||||
|
||||
use crate::native::tasks::types::TaskGraph;
|
||||
use crate::native::tui::action::Action;
|
||||
use crate::native::tui::components::nx_paragraph::NxParagraph;
|
||||
use crate::native::tui::components::tasks_list::TaskStatus;
|
||||
use crate::native::tui::graph_utils::{get_dependency_chain_failures, is_task_continuous};
|
||||
use crate::native::tui::scroll_momentum::ScrollDirection;
|
||||
use crate::native::tui::status_icons;
|
||||
use crate::native::tui::theme::THEME;
|
||||
use ratatui::{
|
||||
@@ -12,8 +14,8 @@ use ratatui::{
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{
|
||||
Block, BorderType, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
|
||||
ScrollbarState, StatefulWidget, Widget,
|
||||
Block, BorderType, Borders, Padding, Scrollbar, ScrollbarOrientation, ScrollbarState,
|
||||
StatefulWidget, Widget,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,6 +29,14 @@ pub struct DependencyViewState {
|
||||
pub scroll_offset: usize,
|
||||
pub scrollbar_state: ScrollbarState,
|
||||
pub pane_area: ratatui::layout::Rect,
|
||||
/// Clickable dependency rows captured during the last render: screen `y` →
|
||||
/// task name. Lets a mouse click navigate to the dependency under the cursor.
|
||||
pub dep_row_hits: Vec<(u16, String)>,
|
||||
/// Horizontal extent `[x0, x1)` of the dependency rows, for click hit-testing.
|
||||
pub dep_row_x_range: (u16, u16),
|
||||
/// Inner text region from the last render, used to bound a drag-based text
|
||||
/// selection over the dependency view (mirrors the task list).
|
||||
pub selection_area: Option<ratatui::layout::Rect>,
|
||||
}
|
||||
|
||||
impl DependencyViewState {
|
||||
@@ -65,9 +75,25 @@ impl DependencyViewState {
|
||||
scroll_offset: 0,
|
||||
scrollbar_state: ScrollbarState::default(),
|
||||
pane_area,
|
||||
dep_row_hits: Vec::new(),
|
||||
dep_row_x_range: (0, 0),
|
||||
selection_area: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a click at terminal cell `(col, row)` to the dependency task under
|
||||
/// it, if any, using the row hit-map captured during the last render.
|
||||
pub fn handle_click(&self, col: u16, row: u16) -> Option<String> {
|
||||
let (x0, x1) = self.dep_row_x_range;
|
||||
if col < x0 || col >= x1 {
|
||||
return None;
|
||||
}
|
||||
self.dep_row_hits
|
||||
.iter()
|
||||
.find(|(y, _)| *y == row)
|
||||
.map(|(_, task)| task.clone())
|
||||
}
|
||||
|
||||
pub fn scroll_up(&mut self) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
}
|
||||
@@ -80,6 +106,19 @@ impl DependencyViewState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll one row in `direction`, deriving the viewport from the stored pane
|
||||
/// area. Used by the mouse wheel so it scrolls the dependency view itself
|
||||
/// (mirroring the keyboard path) rather than the hidden terminal buffer.
|
||||
pub fn scroll(&mut self, direction: ScrollDirection) {
|
||||
match direction {
|
||||
ScrollDirection::Up => self.scroll_up(),
|
||||
ScrollDirection::Down => {
|
||||
let viewport_height = Self::calculate_viewport_height(self.pane_area);
|
||||
self.scroll_down(viewport_height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the dependency view state with new data, preserving scroll position if the task is the same.
|
||||
/// Returns true if the state was updated, false if no update was needed.
|
||||
pub fn update(
|
||||
@@ -274,10 +313,13 @@ impl<'a> DependencyView<'a> {
|
||||
};
|
||||
|
||||
if Self::fits_in_buffer(&top_area, buf) {
|
||||
Paragraph::new(padding_text.clone())
|
||||
.alignment(Alignment::Right)
|
||||
.style(style)
|
||||
.render(top_area, buf);
|
||||
Widget::render(
|
||||
NxParagraph::new(padding_text.clone())
|
||||
.alignment(Alignment::Right)
|
||||
.style(style),
|
||||
top_area,
|
||||
buf,
|
||||
);
|
||||
}
|
||||
|
||||
// Render bottom padding
|
||||
@@ -289,10 +331,13 @@ impl<'a> DependencyView<'a> {
|
||||
};
|
||||
|
||||
if Self::fits_in_buffer(&bottom_area, buf) {
|
||||
Paragraph::new(padding_text)
|
||||
.alignment(Alignment::Right)
|
||||
.style(style)
|
||||
.render(bottom_area, buf);
|
||||
Widget::render(
|
||||
NxParagraph::new(padding_text)
|
||||
.alignment(Alignment::Right)
|
||||
.style(style),
|
||||
bottom_area,
|
||||
buf,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +351,7 @@ impl<'a> DependencyView<'a> {
|
||||
no_deps_style,
|
||||
)])];
|
||||
|
||||
let paragraph = Paragraph::new(no_deps_message)
|
||||
let paragraph = NxParagraph::new(no_deps_message)
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::default());
|
||||
|
||||
@@ -387,6 +432,11 @@ impl<'a> DependencyView<'a> {
|
||||
outer_area: Rect,
|
||||
buf: &mut Buffer,
|
||||
) {
|
||||
// Rebuilt below for the rows actually drawn; clear up front so the
|
||||
// early-return paths (no deps / invalid area) leave no stale click map.
|
||||
state.dep_row_hits.clear();
|
||||
state.dep_row_x_range = (0, 0);
|
||||
|
||||
if state.dependencies.is_empty() {
|
||||
self.render_no_dependencies(state, inner_area, buf);
|
||||
return;
|
||||
@@ -430,22 +480,6 @@ impl<'a> DependencyView<'a> {
|
||||
ScrollbarState::default()
|
||||
};
|
||||
|
||||
// Apply scroll offset to lines
|
||||
let visible_lines: Vec<Line> =
|
||||
if state.scroll_offset > 0 && content_height > viewport_height {
|
||||
let start = state
|
||||
.scroll_offset
|
||||
.min(content_height.saturating_sub(viewport_height));
|
||||
let end = (start + viewport_height).min(content_height);
|
||||
lines[start..end].to_vec()
|
||||
} else {
|
||||
lines
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(visible_lines)
|
||||
.alignment(Alignment::Left)
|
||||
.style(Style::default());
|
||||
|
||||
// Apply safety bounds to content area
|
||||
let content_area = Rect {
|
||||
x: inner_area.x,
|
||||
@@ -463,6 +497,39 @@ impl<'a> DependencyView<'a> {
|
||||
return; // Don't render if the area is invalid
|
||||
}
|
||||
|
||||
// First visible global line index after scrolling. Global indices 0 and 1
|
||||
// are the header and the spacing line; dependencies begin at index 2.
|
||||
let start = if state.scroll_offset > 0 && content_height > viewport_height {
|
||||
state
|
||||
.scroll_offset
|
||||
.min(content_height.saturating_sub(viewport_height))
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let end = (start + viewport_height).min(content_height);
|
||||
|
||||
// Capture the clickable dependency rows that are actually drawn so a mouse
|
||||
// click can navigate to the task under the cursor.
|
||||
let mut dep_row_hits = Vec::new();
|
||||
for global in start..end {
|
||||
let row_offset = (global - start) as u16;
|
||||
if row_offset >= content_area.height {
|
||||
break;
|
||||
}
|
||||
if let Some(dep_idx) = global.checked_sub(2)
|
||||
&& let Some(dep) = state.dependencies.get(dep_idx)
|
||||
{
|
||||
dep_row_hits.push((content_area.y + row_offset, dep.clone()));
|
||||
}
|
||||
}
|
||||
state.dep_row_hits = dep_row_hits;
|
||||
state.dep_row_x_range = (content_area.x, content_area.x + content_area.width);
|
||||
|
||||
let visible_lines: Vec<Line> = lines[start..end].to_vec();
|
||||
let paragraph = NxParagraph::new(visible_lines)
|
||||
.alignment(Alignment::Left)
|
||||
.style(Style::default());
|
||||
|
||||
Widget::render(paragraph, content_area, buf);
|
||||
|
||||
// Render scrollbar if needed (using outer_area to extend to border edge)
|
||||
@@ -543,6 +610,9 @@ impl<'a> StatefulWidget for DependencyView<'a> {
|
||||
.padding(Padding::new(2, 2, 1, 1));
|
||||
|
||||
let inner_area = block.inner(area);
|
||||
// Record the inner text region so the App can bound a drag-based text
|
||||
// selection over the dependency view.
|
||||
state.selection_area = Some(inner_area);
|
||||
block.render(area, buf);
|
||||
|
||||
// Show different content based on task status
|
||||
@@ -565,7 +635,7 @@ impl<'a> StatefulWidget for DependencyView<'a> {
|
||||
Style::default().fg(THEME.secondary_fg),
|
||||
)])];
|
||||
|
||||
let paragraph = Paragraph::new(message_line)
|
||||
let paragraph = NxParagraph::new(message_line)
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::default());
|
||||
|
||||
@@ -577,7 +647,113 @@ impl<'a> StatefulWidget for DependencyView<'a> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::native::tasks::types::TaskGraph;
|
||||
use ratatui::buffer::Buffer;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn empty_task_graph() -> TaskGraph {
|
||||
TaskGraph {
|
||||
tasks: HashMap::new(),
|
||||
dependencies: HashMap::new(),
|
||||
continuous_dependencies: HashMap::new(),
|
||||
roots: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_click_maps_rows_to_dependencies() {
|
||||
let task_graph = empty_task_graph();
|
||||
let status_map: HashMap<String, TaskStatus> = HashMap::new();
|
||||
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 60,
|
||||
height: 20,
|
||||
};
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
let mut state = DependencyViewState {
|
||||
current_task: "app:build".to_string(),
|
||||
task_status: TaskStatus::NotStarted,
|
||||
dependencies: vec![
|
||||
"lib-a:build".to_string(),
|
||||
"lib-b:build".to_string(),
|
||||
"lib-c:build".to_string(),
|
||||
],
|
||||
dependency_levels: HashMap::new(),
|
||||
is_focused: true,
|
||||
throbber_counter: 0,
|
||||
scroll_offset: 0,
|
||||
scrollbar_state: ScrollbarState::default(),
|
||||
pane_area: area,
|
||||
dep_row_hits: Vec::new(),
|
||||
dep_row_x_range: (0, 0),
|
||||
selection_area: None,
|
||||
};
|
||||
|
||||
let view = DependencyView::new(&status_map, &task_graph);
|
||||
StatefulWidget::render(view, area, &mut buf, &mut state);
|
||||
|
||||
// Inner content begins at y=2 (border + top padding). Lines are laid out as
|
||||
// header (y=2), spacing (y=3), then one dependency per row from y=4.
|
||||
assert_eq!(state.handle_click(5, 4).as_deref(), Some("lib-a:build"));
|
||||
assert_eq!(state.handle_click(5, 5).as_deref(), Some("lib-b:build"));
|
||||
assert_eq!(state.handle_click(5, 6).as_deref(), Some("lib-c:build"));
|
||||
|
||||
// Header and spacing rows are not clickable.
|
||||
assert_eq!(state.handle_click(5, 2), None);
|
||||
assert_eq!(state.handle_click(5, 3), None);
|
||||
// A row with no dependency under it is not clickable.
|
||||
assert_eq!(state.handle_click(5, 7), None);
|
||||
// Clicks outside the dependency rows' x-range miss.
|
||||
assert_eq!(state.handle_click(200, 4), None);
|
||||
|
||||
// The inner text region is recorded for drag-based selection and spans
|
||||
// the rendered dependency rows.
|
||||
let sel = state.selection_area.expect("selection area recorded");
|
||||
assert!(sel.y <= 4 && sel.y + sel.height > 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_click_respects_scroll_offset() {
|
||||
let task_graph = empty_task_graph();
|
||||
let status_map: HashMap<String, TaskStatus> = HashMap::new();
|
||||
|
||||
// Short viewport so the list scrolls: header + spacing + 8 deps = 10 lines.
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 60,
|
||||
height: 8, // inner height 4 → only a few rows visible at once
|
||||
};
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
let deps: Vec<String> = (0..8).map(|i| format!("lib-{i}:build")).collect();
|
||||
let mut state = DependencyViewState {
|
||||
current_task: "app:build".to_string(),
|
||||
task_status: TaskStatus::NotStarted,
|
||||
dependencies: deps,
|
||||
dependency_levels: HashMap::new(),
|
||||
is_focused: true,
|
||||
throbber_counter: 0,
|
||||
scroll_offset: 3,
|
||||
scrollbar_state: ScrollbarState::default(),
|
||||
pane_area: area,
|
||||
dep_row_hits: Vec::new(),
|
||||
dep_row_x_range: (0, 0),
|
||||
selection_area: None,
|
||||
};
|
||||
|
||||
let view = DependencyView::new(&status_map, &task_graph);
|
||||
StatefulWidget::render(view, area, &mut buf, &mut state);
|
||||
|
||||
// With scroll_offset 3, global line 3 (the first dependency, lib-0 is global
|
||||
// index 2) is scrolled past, so the top visible row maps to lib-1.
|
||||
let top = state.dep_row_hits.first().expect("a row should be visible");
|
||||
assert_eq!(top.1, "lib-1:build");
|
||||
assert_eq!(state.handle_click(5, top.0).as_deref(), Some("lib-1:build"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_scrollbar_padding_normal_case() {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use super::{Component, Frame};
|
||||
use crate::native::ide::detection::{SupportedEditor, get_current_editor};
|
||||
use crate::native::tui::action::Action;
|
||||
use crate::native::tui::components::nx_paragraph::NxParagraph;
|
||||
use color_eyre::eyre::Result;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{
|
||||
Block, BorderType, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
|
||||
ScrollbarState,
|
||||
Block, BorderType, Borders, Clear, Padding, Scrollbar, ScrollbarOrientation, ScrollbarState,
|
||||
},
|
||||
};
|
||||
use std::any::Any;
|
||||
@@ -25,6 +25,12 @@ pub struct HelpPopup {
|
||||
visible: bool,
|
||||
action_tx: Option<UnboundedSender<Action>>,
|
||||
console_available: bool,
|
||||
/// Screen rect of the bordered popup box from the last render, used for
|
||||
/// click-outside-to-dismiss hit-testing.
|
||||
last_area: Option<Rect>,
|
||||
/// Screen rect of the inner text area (inside the border, clear of the
|
||||
/// scrollbar) from the last render, used to bound text selection/links.
|
||||
content_area: Option<Rect>,
|
||||
}
|
||||
|
||||
impl HelpPopup {
|
||||
@@ -37,6 +43,8 @@ impl HelpPopup {
|
||||
visible: false,
|
||||
action_tx: None,
|
||||
console_available: false,
|
||||
last_area: None,
|
||||
content_area: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +52,16 @@ impl HelpPopup {
|
||||
self.visible = visible;
|
||||
}
|
||||
|
||||
/// The bordered popup box drawn last frame, if visible.
|
||||
pub fn last_area(&self) -> Option<Rect> {
|
||||
self.last_area
|
||||
}
|
||||
|
||||
/// The inner text area drawn last frame, if visible.
|
||||
pub fn content_area(&self) -> Option<Rect> {
|
||||
self.content_area
|
||||
}
|
||||
|
||||
pub fn set_console_available(&mut self, available: bool) {
|
||||
self.console_available = available;
|
||||
}
|
||||
@@ -139,6 +157,9 @@ impl HelpPopup {
|
||||
])
|
||||
.split(popup_layout[1])[1];
|
||||
|
||||
// Record the popup box so the app can hit-test mouse events against it.
|
||||
self.last_area = Some(popup_area);
|
||||
|
||||
let mut keybindings = vec![
|
||||
// Misc
|
||||
("?", "Toggle this popup"),
|
||||
@@ -171,7 +192,11 @@ impl HelpPopup {
|
||||
"<tab>",
|
||||
"Move focus between task list and output panes 1 and 2",
|
||||
),
|
||||
("c", "Copy focused output to clipboard"),
|
||||
("c", "Copy selection (or full output) to clipboard"),
|
||||
(
|
||||
"F10",
|
||||
"Toggle mouse capture (off lets your terminal select cells natively)",
|
||||
),
|
||||
("", ""),
|
||||
// Interactive Mode
|
||||
("i", "Interact with a continuous task when it is in focus"),
|
||||
@@ -321,6 +346,9 @@ impl HelpPopup {
|
||||
|
||||
let inner_area = block.inner(popup_area);
|
||||
self.viewport_height = inner_area.height as usize;
|
||||
// The text area sits inside the border + padding, so it never includes
|
||||
// the scrollbar (drawn on the far-right border column).
|
||||
self.content_area = Some(inner_area);
|
||||
|
||||
// Calculate wrapped height by measuring each line
|
||||
let wrapped_height = content
|
||||
@@ -358,7 +386,7 @@ impl HelpPopup {
|
||||
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)
|
||||
let popup = NxParagraph::new(visible_content)
|
||||
.block(block)
|
||||
.alignment(Alignment::Left)
|
||||
.wrap(ratatui::widgets::Wrap { trim: true });
|
||||
@@ -392,14 +420,14 @@ impl HelpPopup {
|
||||
|
||||
// Render padding text
|
||||
f.render_widget(
|
||||
Paragraph::new(top_text)
|
||||
NxParagraph::new(top_text)
|
||||
.alignment(Alignment::Right)
|
||||
.style(Style::default().fg(THEME.info)),
|
||||
top_right_area,
|
||||
);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(bottom_text)
|
||||
NxParagraph::new(bottom_text)
|
||||
.alignment(Alignment::Right)
|
||||
.style(Style::default().fg(THEME.info)),
|
||||
bottom_right_area,
|
||||
@@ -425,6 +453,9 @@ impl Component for HelpPopup {
|
||||
fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> {
|
||||
if self.visible {
|
||||
self.render(f, rect);
|
||||
} else {
|
||||
self.last_area = None;
|
||||
self.content_area = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
};
|
||||
|
||||
use crate::native::tui::components::nx_paragraph::NxParagraph;
|
||||
use crate::native::tui::theme::THEME;
|
||||
|
||||
pub struct HelpText {
|
||||
@@ -70,7 +70,7 @@ impl HelpText {
|
||||
Span::styled("?", key_style),
|
||||
];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(hint)).alignment(if self.align_left {
|
||||
NxParagraph::new(Line::from(hint)).alignment(if self.align_left {
|
||||
Alignment::Left
|
||||
} else {
|
||||
Alignment::Right
|
||||
@@ -110,7 +110,7 @@ impl HelpText {
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(shortcuts)).alignment(Alignment::Right),
|
||||
NxParagraph::new(Line::from(shortcuts)).alignment(Alignment::Right),
|
||||
safe_area,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph},
|
||||
widgets::{Block, BorderType, Borders, Clear, Padding},
|
||||
};
|
||||
use std::any::Any;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::native::tui::components::nx_paragraph::NxParagraph;
|
||||
use crate::native::tui::theme::THEME;
|
||||
|
||||
use super::Component;
|
||||
@@ -27,6 +28,12 @@ pub struct HintPopup {
|
||||
message: String,
|
||||
shown_at: Option<Instant>,
|
||||
auto_dismiss_duration: Duration,
|
||||
/// Screen rect of the bordered popup box from the last render, used for
|
||||
/// click-outside-to-dismiss hit-testing.
|
||||
last_area: Option<Rect>,
|
||||
/// Screen rect of the inner text area (inside the border) from the last
|
||||
/// render, used to bound text selection.
|
||||
content_area: Option<Rect>,
|
||||
}
|
||||
|
||||
impl HintPopup {
|
||||
@@ -36,9 +43,21 @@ impl HintPopup {
|
||||
message: String::new(),
|
||||
shown_at: None,
|
||||
auto_dismiss_duration: AUTO_DISMISS_DURATION,
|
||||
last_area: None,
|
||||
content_area: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bordered popup box drawn last frame, if visible.
|
||||
pub fn last_area(&self) -> Option<Rect> {
|
||||
self.last_area
|
||||
}
|
||||
|
||||
/// The inner text area drawn last frame, if visible.
|
||||
pub fn content_area(&self) -> Option<Rect> {
|
||||
self.content_area
|
||||
}
|
||||
|
||||
/// Shows the popup with the given message
|
||||
pub fn show(&mut self, message: String) {
|
||||
self.visible = true;
|
||||
@@ -92,8 +111,11 @@ impl HintPopup {
|
||||
// Create popup area with fixed dimensions
|
||||
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
|
||||
|
||||
// Record the popup box so the app can hit-test mouse events against it.
|
||||
self.last_area = Some(popup_area);
|
||||
|
||||
let content = vec![Line::from(vec![Span::styled(
|
||||
&self.message,
|
||||
self.message.clone(),
|
||||
Style::default().fg(THEME.primary_fg),
|
||||
)])];
|
||||
|
||||
@@ -123,7 +145,10 @@ impl HintPopup {
|
||||
.border_style(Style::default().fg(THEME.info))
|
||||
.padding(Padding::proportional(1));
|
||||
|
||||
let popup = Paragraph::new(content)
|
||||
// The text area sits inside the border + padding.
|
||||
self.content_area = Some(block.inner(popup_area));
|
||||
|
||||
let popup = NxParagraph::new(content)
|
||||
.block(block)
|
||||
.wrap(ratatui::widgets::Wrap { trim: true });
|
||||
|
||||
@@ -143,6 +168,9 @@ impl Component for HintPopup {
|
||||
fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> {
|
||||
if self.visible {
|
||||
self.render(f, rect);
|
||||
} else {
|
||||
self.last_area = None;
|
||||
self.content_area = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! A reusable, clickable link to an external HTTP resource.
|
||||
//!
|
||||
//! Ratatui spans don't know where they were rendered, so a clickable piece of
|
||||
//! text has to record its own rect at draw time. [`Link`] is a
|
||||
//! [`StatefulWidget`] whose state is a [`LinkRegistry`]: rendering a link draws
|
||||
//! its (styled, underlined) display text into the given area and pushes the
|
||||
//! drawn rect plus the underlying `href` into the registry. The app hit-tests
|
||||
//! the registry on click and opens the matching `href`.
|
||||
//!
|
||||
//! The display text and the `href` are intentionally separate, so a link can
|
||||
//! show "View in Nx Cloud" while opening `https://nx.app/...`. This also
|
||||
//! subsumes the "truncate the visible URL but open the full one" behaviour: the
|
||||
//! registered `href` is always the full target even when the display is clipped.
|
||||
//!
|
||||
//! Links are styled persistently (underline + the info/accent colour) rather
|
||||
//! than reacting to pointer hover. The TUI deliberately enables only a narrow
|
||||
//! set of mouse modes (no all-motion tracking), so bare hover produces no
|
||||
//! events to react to — see `tui.rs`.
|
||||
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Position, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::Span,
|
||||
widgets::StatefulWidget,
|
||||
};
|
||||
|
||||
use crate::native::tui::theme::THEME;
|
||||
|
||||
/// A single rendered link's clickable rect and the resource it opens.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct LinkHit {
|
||||
area: Rect,
|
||||
href: String,
|
||||
}
|
||||
|
||||
/// Per-frame collection of rendered links for one component.
|
||||
///
|
||||
/// Cleared at the top of every draw pass and repopulated as links render, so a
|
||||
/// component that isn't drawn this frame holds no stale hits.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct LinkRegistry {
|
||||
hits: Vec<LinkHit>,
|
||||
}
|
||||
|
||||
impl LinkRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self { hits: Vec::new() }
|
||||
}
|
||||
|
||||
/// Drop all recorded hits. Call at the start of each draw.
|
||||
pub fn clear(&mut self) {
|
||||
self.hits.clear();
|
||||
}
|
||||
|
||||
/// Record a rendered link.
|
||||
pub fn push(&mut self, area: Rect, href: String) {
|
||||
self.hits.push(LinkHit { area, href });
|
||||
}
|
||||
|
||||
/// Return the `href` of the link at `(col, row)`, if any. Later-registered
|
||||
/// links win on overlap (they were drawn on top).
|
||||
pub fn hit_test(&self, col: u16, row: u16) -> Option<&str> {
|
||||
self.hits
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|hit| hit.area.contains(Position::new(col, row)))
|
||||
.map(|hit| hit.href.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// A clickable link to an external HTTP resource.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Link {
|
||||
display: String,
|
||||
href: String,
|
||||
dim: bool,
|
||||
}
|
||||
|
||||
impl Link {
|
||||
pub fn new(display: impl Into<String>, href: impl Into<String>) -> Self {
|
||||
Self {
|
||||
display: display.into(),
|
||||
href: href.into(),
|
||||
dim: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the link dimmed (e.g. for an unfocused pane).
|
||||
pub fn dim(mut self, dim: bool) -> Self {
|
||||
self.dim = dim;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn href(&self) -> &str {
|
||||
&self.href
|
||||
}
|
||||
|
||||
/// The text shown for the link (distinct from the opened `href`).
|
||||
pub fn display(&self) -> &str {
|
||||
&self.display
|
||||
}
|
||||
|
||||
pub(crate) fn style(&self) -> Style {
|
||||
let mut style = Style::default()
|
||||
.fg(THEME.info)
|
||||
.add_modifier(Modifier::UNDERLINED);
|
||||
if self.dim {
|
||||
style = style.add_modifier(Modifier::DIM);
|
||||
}
|
||||
style
|
||||
}
|
||||
}
|
||||
|
||||
impl StatefulWidget for &Link {
|
||||
type State = LinkRegistry;
|
||||
|
||||
fn render(self, area: Rect, buf: &mut Buffer, registry: &mut Self::State) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let text = fit_with_ellipsis(&self.display, area.width as usize);
|
||||
let drawn_width = (display_width(&text) as u16).min(area.width);
|
||||
if drawn_width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
buf.set_stringn(area.x, area.y, &text, area.width as usize, self.style());
|
||||
|
||||
registry.push(
|
||||
Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: drawn_width,
|
||||
height: 1,
|
||||
},
|
||||
self.href.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Display width (in terminal columns) of a string, honouring wide characters.
|
||||
fn display_width(text: &str) -> usize {
|
||||
Span::raw(text).width()
|
||||
}
|
||||
|
||||
/// Fit `display` into `max_width` columns, appending an ellipsis when it must be
|
||||
/// truncated. Width-aware so wide characters never overflow the area.
|
||||
fn fit_with_ellipsis(display: &str, max_width: usize) -> String {
|
||||
if max_width == 0 {
|
||||
return String::new();
|
||||
}
|
||||
if display_width(display) <= max_width {
|
||||
return display.to_string();
|
||||
}
|
||||
// No room for text plus an ellipsis — just fill with dots.
|
||||
if max_width <= 3 {
|
||||
return ".".repeat(max_width);
|
||||
}
|
||||
|
||||
let budget = max_width - 3;
|
||||
let mut out = String::new();
|
||||
let mut width = 0usize;
|
||||
for ch in display.chars() {
|
||||
let char_width = display_width(&ch.to_string());
|
||||
if width + char_width > budget {
|
||||
break;
|
||||
}
|
||||
out.push(ch);
|
||||
width += char_width;
|
||||
}
|
||||
out.push_str("...");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rect(x: u16, y: u16, width: u16, height: u16) -> Rect {
|
||||
Rect {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_returns_href_for_point_inside() {
|
||||
let mut registry = LinkRegistry::new();
|
||||
registry.push(rect(5, 2, 16, 1), "https://nx.app/run".to_string());
|
||||
|
||||
assert_eq!(registry.hit_test(5, 2), Some("https://nx.app/run"));
|
||||
assert_eq!(registry.hit_test(20, 2), Some("https://nx.app/run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_returns_none_for_point_outside() {
|
||||
let mut registry = LinkRegistry::new();
|
||||
registry.push(rect(5, 2, 16, 1), "https://nx.app/run".to_string());
|
||||
|
||||
assert_eq!(registry.hit_test(4, 2), None); // left of link
|
||||
assert_eq!(registry.hit_test(21, 2), None); // right of link
|
||||
assert_eq!(registry.hit_test(10, 3), None); // wrong row
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_is_empty_when_nothing_registered() {
|
||||
let registry = LinkRegistry::new();
|
||||
assert_eq!(registry.hit_test(0, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_overlap_prefers_last_registered() {
|
||||
let mut registry = LinkRegistry::new();
|
||||
registry.push(rect(0, 0, 10, 1), "first".to_string());
|
||||
registry.push(rect(0, 0, 10, 1), "second".to_string());
|
||||
|
||||
assert_eq!(registry.hit_test(3, 0), Some("second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_drops_all_hits() {
|
||||
let mut registry = LinkRegistry::new();
|
||||
registry.push(rect(0, 0, 10, 1), "x".to_string());
|
||||
registry.clear();
|
||||
assert_eq!(registry.hit_test(3, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_draws_display_text_and_registers_href() {
|
||||
let area = rect(0, 0, 20, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let mut registry = LinkRegistry::new();
|
||||
|
||||
let link = Link::new("View in Nx Cloud", "https://nx.app/runs/abc");
|
||||
StatefulWidget::render(&link, area, &mut buf, &mut registry);
|
||||
|
||||
let rendered: String = (0..16).map(|x| buf[(x, 0)].symbol()).collect();
|
||||
assert_eq!(rendered, "View in Nx Cloud");
|
||||
|
||||
// The drawn text (16 cols), not the whole area, is the click target, and
|
||||
// the full href is recorded.
|
||||
assert_eq!(registry.hit_test(0, 0), Some("https://nx.app/runs/abc"));
|
||||
assert_eq!(registry.hit_test(15, 0), Some("https://nx.app/runs/abc"));
|
||||
assert_eq!(registry.hit_test(16, 0), None); // blank space past the text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_styles_text_underlined_in_info_colour() {
|
||||
let area = rect(0, 0, 10, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let mut registry = LinkRegistry::new();
|
||||
|
||||
let link = Link::new("docs", "https://nx.dev");
|
||||
StatefulWidget::render(&link, area, &mut buf, &mut registry);
|
||||
|
||||
let cell = &buf[(0, 0)];
|
||||
assert_eq!(cell.fg, THEME.info);
|
||||
assert!(cell.modifier.contains(Modifier::UNDERLINED));
|
||||
assert!(!cell.modifier.contains(Modifier::DIM));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_dim_applies_dim_modifier() {
|
||||
let area = rect(0, 0, 10, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let mut registry = LinkRegistry::new();
|
||||
|
||||
let link = Link::new("docs", "https://nx.dev").dim(true);
|
||||
StatefulWidget::render(&link, area, &mut buf, &mut registry);
|
||||
|
||||
assert!(buf[(0, 0)].modifier.contains(Modifier::DIM));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_truncates_with_ellipsis_but_registers_full_href() {
|
||||
let area = rect(0, 0, 8, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let mut registry = LinkRegistry::new();
|
||||
|
||||
let link = Link::new("View in Nx Cloud", "https://nx.app/runs/abc");
|
||||
StatefulWidget::render(&link, area, &mut buf, &mut registry);
|
||||
|
||||
let rendered: String = (0..8).map(|x| buf[(x, 0)].symbol()).collect();
|
||||
assert_eq!(rendered, "View ...");
|
||||
// Clicking the truncated display still opens the full href.
|
||||
assert_eq!(registry.hit_test(0, 0), Some("https://nx.app/runs/abc"));
|
||||
assert_eq!(registry.hit_test(7, 0), Some("https://nx.app/runs/abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_into_zero_width_area_registers_nothing() {
|
||||
let area = rect(0, 0, 0, 1);
|
||||
let mut buf = Buffer::empty(rect(0, 0, 1, 1));
|
||||
let mut registry = LinkRegistry::new();
|
||||
|
||||
let link = Link::new("docs", "https://nx.dev");
|
||||
StatefulWidget::render(&link, area, &mut buf, &mut registry);
|
||||
|
||||
assert_eq!(registry.hit_test(0, 0), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
//! `NxParagraph` — a paragraph widget that can embed clickable [`Link`]s and
|
||||
//! reports their rendered positions, so links never need a post-render buffer
|
||||
//! scan.
|
||||
//!
|
||||
//! For link-less content it delegates to ratatui's `Paragraph` (byte-identical
|
||||
//! output, zero migration churn). When a line actually contains a [`Link`] it
|
||||
//! owns the placement instead, drawing each span with `Buffer::set_stringn` and
|
||||
//! recording every link's drawn rect into a [`LinkRegistry`] — the position is a
|
||||
//! byproduct of placing the span, not something recovered afterwards.
|
||||
//!
|
||||
//! This module is the ONLY sanctioned user of ratatui's `Paragraph`; everywhere
|
||||
//! else it's banned via clippy `disallowed_types`. Use `NxParagraph` instead.
|
||||
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Alignment, Rect},
|
||||
style::Style,
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, StatefulWidget, Widget, Wrap},
|
||||
};
|
||||
|
||||
use super::link::{Link, LinkRegistry};
|
||||
|
||||
fn text_width(s: &str) -> u16 {
|
||||
Span::raw(s).width() as u16
|
||||
}
|
||||
|
||||
/// One inline piece of a line: plain styled text, or a clickable link.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NxSpan {
|
||||
Text(Span<'static>),
|
||||
Link(Link),
|
||||
}
|
||||
|
||||
impl NxSpan {
|
||||
fn content(&self) -> String {
|
||||
match self {
|
||||
NxSpan::Text(s) => s.content.to_string(),
|
||||
NxSpan::Link(l) => l.display().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn style(&self) -> Style {
|
||||
match self {
|
||||
NxSpan::Text(s) => s.style,
|
||||
NxSpan::Link(l) => l.style(),
|
||||
}
|
||||
}
|
||||
|
||||
fn href(&self) -> Option<String> {
|
||||
match self {
|
||||
NxSpan::Link(l) => Some(l.href().to_string()),
|
||||
NxSpan::Text(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The equivalent ratatui span (keeps styled text, drops link semantics).
|
||||
fn to_span(&self) -> Span<'static> {
|
||||
match self {
|
||||
NxSpan::Text(s) => s.clone(),
|
||||
NxSpan::Link(l) => Span::styled(l.display().to_string(), l.style()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Span<'static>> for NxSpan {
|
||||
fn from(s: Span<'static>) -> Self {
|
||||
NxSpan::Text(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Link> for NxSpan {
|
||||
fn from(l: Link) -> Self {
|
||||
NxSpan::Link(l)
|
||||
}
|
||||
}
|
||||
|
||||
/// A line of [`NxSpan`]s.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct NxLine {
|
||||
spans: Vec<NxSpan>,
|
||||
}
|
||||
|
||||
impl NxLine {
|
||||
pub fn from_spans(spans: Vec<NxSpan>) -> Self {
|
||||
Self { spans }
|
||||
}
|
||||
|
||||
fn has_links(&self) -> bool {
|
||||
self.spans.iter().any(|s| matches!(s, NxSpan::Link(_)))
|
||||
}
|
||||
|
||||
fn to_line(&self) -> Line<'static> {
|
||||
Line::from(self.spans.iter().map(NxSpan::to_span).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Unwrapped display width of the line.
|
||||
pub fn width(&self) -> u16 {
|
||||
self.spans.iter().map(|s| text_width(&s.content())).sum()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Line<'static>> for NxLine {
|
||||
fn from(l: Line<'static>) -> Self {
|
||||
Self {
|
||||
spans: l.spans.into_iter().map(NxSpan::Text).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Span<'static>> for NxLine {
|
||||
fn from(s: Span<'static>) -> Self {
|
||||
Self {
|
||||
spans: vec![NxSpan::Text(s)],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<NxSpan>> for NxLine {
|
||||
fn from(spans: Vec<NxSpan>) -> Self {
|
||||
Self { spans }
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph content: a sequence of [`NxLine`]s.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct NxText {
|
||||
lines: Vec<NxLine>,
|
||||
}
|
||||
|
||||
impl NxText {
|
||||
fn has_links(&self) -> bool {
|
||||
self.lines.iter().any(NxLine::has_links)
|
||||
}
|
||||
|
||||
/// Widest unwrapped line.
|
||||
pub fn max_width(&self) -> u16 {
|
||||
self.lines.iter().map(NxLine::width).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Number of visual rows when word-wrapped to `width`.
|
||||
pub fn wrapped_rows(&self, width: u16, trim: bool) -> usize {
|
||||
self.layout_rows(width.max(1), true, trim).len()
|
||||
}
|
||||
|
||||
fn to_text(&self) -> Text<'static> {
|
||||
Text::from(self.lines.iter().map(NxLine::to_line).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Lay the content out into visual rows of placed pieces, exactly as it will
|
||||
/// be drawn — the basis for both rendering link rects and counting rows.
|
||||
fn layout_rows(&self, width: u16, wrap: bool, trim: bool) -> Vec<Vec<Piece>> {
|
||||
let mut rows = Vec::new();
|
||||
for line in &self.lines {
|
||||
if wrap {
|
||||
wrap_line(line, width.max(1), trim, &mut rows);
|
||||
} else {
|
||||
rows.push(
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let text = s.content();
|
||||
let w = text_width(&text);
|
||||
Piece {
|
||||
text,
|
||||
style: s.style(),
|
||||
href: s.href(),
|
||||
width: w,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for NxText {
|
||||
fn from(s: &str) -> Self {
|
||||
Text::raw(s.to_string()).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for NxText {
|
||||
fn from(s: String) -> Self {
|
||||
Text::raw(s).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Span<'static>> for NxText {
|
||||
fn from(s: Span<'static>) -> Self {
|
||||
Self {
|
||||
lines: vec![NxLine::from(s)],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Line<'static>> for NxText {
|
||||
fn from(l: Line<'static>) -> Self {
|
||||
Self {
|
||||
lines: vec![NxLine::from(l)],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Text<'static>> for NxText {
|
||||
fn from(t: Text<'static>) -> Self {
|
||||
Self {
|
||||
lines: t.lines.into_iter().map(NxLine::from).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<Line<'static>>> for NxText {
|
||||
fn from(lines: Vec<Line<'static>>) -> Self {
|
||||
Self {
|
||||
lines: lines.into_iter().map(NxLine::from).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<NxLine> for NxText {
|
||||
fn from(line: NxLine) -> Self {
|
||||
Self { lines: vec![line] }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<NxLine>> for NxText {
|
||||
fn from(lines: Vec<NxLine>) -> Self {
|
||||
Self { lines }
|
||||
}
|
||||
}
|
||||
|
||||
/// A placed run of text on a single visual row, with its display width and
|
||||
/// (optional) link target.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Piece {
|
||||
text: String,
|
||||
style: Style,
|
||||
href: Option<String>,
|
||||
width: u16,
|
||||
}
|
||||
|
||||
/// Greedily word-wrap one line into `rows` at `width` columns (matching
|
||||
/// ratatui's `Wrap { trim }` closely enough; the only consumers are link-bearing
|
||||
/// paragraphs, whose snapshots are reconciled against this).
|
||||
fn wrap_line(line: &NxLine, width: u16, trim: bool, rows: &mut Vec<Vec<Piece>>) {
|
||||
// Tokenize into whitespace / non-whitespace runs, carrying each run's style
|
||||
// and link target.
|
||||
let mut tokens: Vec<(String, Style, Option<String>, bool)> = Vec::new();
|
||||
for span in &line.spans {
|
||||
let content = span.content();
|
||||
let style = span.style();
|
||||
let href = span.href();
|
||||
let mut cur = String::new();
|
||||
let mut cur_is_space: Option<bool> = None;
|
||||
for ch in content.chars() {
|
||||
let is_space = ch == ' ';
|
||||
match cur_is_space {
|
||||
Some(s) if s == is_space => cur.push(ch),
|
||||
_ => {
|
||||
if let Some(was_space) = cur_is_space {
|
||||
tokens.push((std::mem::take(&mut cur), style, href.clone(), was_space));
|
||||
}
|
||||
cur.push(ch);
|
||||
cur_is_space = Some(is_space);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(was_space) = cur_is_space {
|
||||
tokens.push((cur, style, href.clone(), was_space));
|
||||
}
|
||||
}
|
||||
|
||||
let mut row: Vec<Piece> = Vec::new();
|
||||
let mut row_w: u16 = 0;
|
||||
for (text, style, href, is_space) in tokens {
|
||||
let tw = text_width(&text);
|
||||
|
||||
if is_space && row.is_empty() && trim {
|
||||
continue; // trim leading whitespace at the start of a wrapped row
|
||||
}
|
||||
|
||||
if row_w + tw > width && !row.is_empty() {
|
||||
rows.push(std::mem::take(&mut row));
|
||||
row_w = 0;
|
||||
if is_space && trim {
|
||||
continue; // the space that caused the wrap is dropped when trimming
|
||||
}
|
||||
}
|
||||
|
||||
// A single token wider than the line is hard-broken across rows.
|
||||
if tw > width {
|
||||
if !row.is_empty() {
|
||||
rows.push(std::mem::take(&mut row));
|
||||
row_w = 0;
|
||||
}
|
||||
for chunk in hard_break(&text, width) {
|
||||
let cw = text_width(&chunk);
|
||||
rows.push(vec![Piece {
|
||||
text: chunk,
|
||||
style,
|
||||
href: href.clone(),
|
||||
width: cw,
|
||||
}]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
push_piece(&mut row, &mut row_w, text, style, href, tw);
|
||||
}
|
||||
rows.push(row); // a trailing (possibly empty) row preserves blank lines
|
||||
}
|
||||
|
||||
/// Append a run to the current row, merging into the previous piece when the
|
||||
/// style and link target match (so a wrapped link stays one rect per row).
|
||||
fn push_piece(
|
||||
row: &mut Vec<Piece>,
|
||||
row_w: &mut u16,
|
||||
text: String,
|
||||
style: Style,
|
||||
href: Option<String>,
|
||||
tw: u16,
|
||||
) {
|
||||
match row.last_mut() {
|
||||
Some(last) if last.style == style && last.href == href => {
|
||||
last.text.push_str(&text);
|
||||
last.width += tw;
|
||||
}
|
||||
_ => row.push(Piece {
|
||||
text,
|
||||
style,
|
||||
href,
|
||||
width: tw,
|
||||
}),
|
||||
}
|
||||
*row_w += tw;
|
||||
}
|
||||
|
||||
/// Break an over-wide token into chunks each no wider than `width` columns.
|
||||
fn hard_break(text: &str, width: u16) -> Vec<String> {
|
||||
let mut chunks = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut cur_w = 0u16;
|
||||
for ch in text.chars() {
|
||||
let cw = text_width(&ch.to_string());
|
||||
if cur_w + cw > width && !cur.is_empty() {
|
||||
chunks.push(std::mem::take(&mut cur));
|
||||
cur_w = 0;
|
||||
}
|
||||
cur.push(ch);
|
||||
cur_w += cw;
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
chunks.push(cur);
|
||||
}
|
||||
chunks
|
||||
}
|
||||
|
||||
/// A paragraph that can embed clickable [`Link`]s. Drop-in for ratatui's
|
||||
/// `Paragraph`; render with `render_stateful_widget(.., &mut LinkRegistry)` to
|
||||
/// capture link positions, or `render_widget` when there are no links.
|
||||
pub struct NxParagraph<'a> {
|
||||
text: NxText,
|
||||
block: Option<Block<'a>>,
|
||||
style: Style,
|
||||
wrap: Option<Wrap>,
|
||||
scroll: (u16, u16),
|
||||
alignment: Alignment,
|
||||
}
|
||||
|
||||
impl<'a> NxParagraph<'a> {
|
||||
pub fn new(text: impl Into<NxText>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
block: None,
|
||||
style: Style::default(),
|
||||
wrap: None,
|
||||
scroll: (0, 0),
|
||||
alignment: Alignment::Left,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn block(mut self, block: Block<'a>) -> Self {
|
||||
self.block = Some(block);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn style(mut self, style: Style) -> Self {
|
||||
self.style = style;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn wrap(mut self, wrap: Wrap) -> Self {
|
||||
self.wrap = Some(wrap);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn scroll(mut self, scroll: (u16, u16)) -> Self {
|
||||
self.scroll = scroll;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn alignment(mut self, alignment: Alignment) -> Self {
|
||||
self.alignment = alignment;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn left_aligned(self) -> Self {
|
||||
self.alignment(Alignment::Left)
|
||||
}
|
||||
|
||||
pub fn centered(self) -> Self {
|
||||
self.alignment(Alignment::Center)
|
||||
}
|
||||
|
||||
pub fn right_aligned(self) -> Self {
|
||||
self.alignment(Alignment::Right)
|
||||
}
|
||||
|
||||
/// Number of visual rows the content wraps to at `width` — uses the same
|
||||
/// layout as rendering, so scroll math stays consistent.
|
||||
pub fn line_count(&self, width: u16) -> usize {
|
||||
self.text
|
||||
.layout_rows(width.max(1), self.wrap.is_some(), self.trim())
|
||||
.len()
|
||||
}
|
||||
|
||||
fn trim(&self) -> bool {
|
||||
self.wrap.map(|w| w.trim).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Equivalent ratatui `Paragraph` for the link-less fast path. This module
|
||||
/// is the sanctioned wrapper, so ratatui's banned widget is allowed here.
|
||||
#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
|
||||
fn to_ratatui(self) -> ratatui::widgets::Paragraph<'a> {
|
||||
let mut p = ratatui::widgets::Paragraph::new(self.text.to_text())
|
||||
.style(self.style)
|
||||
.scroll(self.scroll)
|
||||
.alignment(self.alignment);
|
||||
if let Some(block) = self.block {
|
||||
p = p.block(block);
|
||||
}
|
||||
if let Some(wrap) = self.wrap {
|
||||
p = p.wrap(wrap);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Own the placement so each link's drawn rect can be recorded.
|
||||
fn render_with_links(self, area: Rect, buf: &mut Buffer, registry: &mut LinkRegistry) {
|
||||
let style = self.style;
|
||||
let alignment = self.alignment;
|
||||
let wrap = self.wrap.is_some();
|
||||
let trim = self.trim();
|
||||
let scroll = self.scroll;
|
||||
|
||||
let text_area = if let Some(block) = self.block {
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
inner
|
||||
} else {
|
||||
area
|
||||
};
|
||||
if text_area.width == 0 || text_area.height == 0 {
|
||||
return;
|
||||
}
|
||||
buf.set_style(text_area, style);
|
||||
|
||||
let rows = self.text.layout_rows(text_area.width, wrap, trim);
|
||||
let scroll_y = scroll.0 as usize;
|
||||
let scroll_x = scroll.1;
|
||||
for (vis_idx, row) in rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(scroll_y)
|
||||
.take(text_area.height as usize)
|
||||
{
|
||||
let y = text_area.y + (vis_idx - scroll_y) as u16;
|
||||
let row_w: u16 = row.iter().map(|p| p.width).sum();
|
||||
// Row origin before horizontal scroll, per alignment.
|
||||
let aligned_x = match alignment {
|
||||
Alignment::Left => text_area.x,
|
||||
Alignment::Center => text_area.x + text_area.width.saturating_sub(row_w) / 2,
|
||||
Alignment::Right => text_area.x + text_area.width.saturating_sub(row_w),
|
||||
};
|
||||
let mut col_in_row: u16 = 0;
|
||||
for piece in row {
|
||||
// Where the piece's start lands once horizontally scrolled.
|
||||
let start = aligned_x as i32 + col_in_row as i32 - scroll_x as i32;
|
||||
col_in_row = col_in_row.saturating_add(piece.width);
|
||||
|
||||
// Clip the part scrolled off the left edge.
|
||||
let (text, draw_x) = if start >= text_area.x as i32 {
|
||||
(piece.text.clone(), start as u16)
|
||||
} else {
|
||||
let hidden = (text_area.x as i32 - start) as u16;
|
||||
if hidden >= piece.width {
|
||||
continue; // entirely scrolled off the left
|
||||
}
|
||||
(drop_leading_cols(&piece.text, hidden), text_area.x)
|
||||
};
|
||||
if draw_x >= text_area.right() {
|
||||
continue; // off the right edge
|
||||
}
|
||||
let max = (text_area.right() - draw_x) as usize;
|
||||
let (end_x, _) = buf.set_stringn(draw_x, y, &text, max, piece.style);
|
||||
if let Some(href) = &piece.href {
|
||||
let drawn_w = end_x.saturating_sub(draw_x);
|
||||
if drawn_w > 0 {
|
||||
registry.push(
|
||||
Rect {
|
||||
x: draw_x,
|
||||
y,
|
||||
width: drawn_w,
|
||||
height: 1,
|
||||
},
|
||||
href.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the first `skip` display columns from `text` (for horizontal scroll). A
|
||||
/// wide character straddling the boundary is dropped whole.
|
||||
fn drop_leading_cols(text: &str, skip: u16) -> String {
|
||||
let mut dropped = 0u16;
|
||||
let mut chars = text.chars();
|
||||
while dropped < skip {
|
||||
match chars.next() {
|
||||
Some(ch) => dropped = dropped.saturating_add(text_width(&ch.to_string())),
|
||||
None => return String::new(),
|
||||
}
|
||||
}
|
||||
chars.as_str().to_string()
|
||||
}
|
||||
|
||||
impl Widget for NxParagraph<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
if self.text.has_links() {
|
||||
// No registry to record into, but still draw correctly (links render
|
||||
// as styled text, just not clickable).
|
||||
let mut throwaway = LinkRegistry::new();
|
||||
self.render_with_links(area, buf, &mut throwaway);
|
||||
} else {
|
||||
self.to_ratatui().render(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StatefulWidget for NxParagraph<'_> {
|
||||
type State = LinkRegistry;
|
||||
|
||||
fn render(self, area: Rect, buf: &mut Buffer, registry: &mut Self::State) {
|
||||
if self.text.has_links() {
|
||||
self.render_with_links(area, buf, registry);
|
||||
} else {
|
||||
self.to_ratatui().render(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rect(x: u16, y: u16, width: u16, height: u16) -> Rect {
|
||||
Rect {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_link_rect_inline() {
|
||||
let area = rect(0, 0, 30, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let mut registry = LinkRegistry::new();
|
||||
|
||||
let line = NxLine::from_spans(vec![
|
||||
Span::raw("see ").into(),
|
||||
Link::new("the docs", "https://nx.dev").into(),
|
||||
Span::raw(" now").into(),
|
||||
]);
|
||||
StatefulWidget::render(NxParagraph::new(line), area, &mut buf, &mut registry);
|
||||
|
||||
let rendered: String = (0..16).map(|x| buf[(x, 0)].symbol()).collect();
|
||||
assert_eq!(rendered, "see the docs now");
|
||||
// Only "the docs" (cols 4..12) is the link.
|
||||
assert_eq!(registry.hit_test(3, 0), None);
|
||||
assert_eq!(registry.hit_test(4, 0), Some("https://nx.dev"));
|
||||
assert_eq!(registry.hit_test(11, 0), Some("https://nx.dev"));
|
||||
assert_eq!(registry.hit_test(12, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_link_records_a_rect_per_row() {
|
||||
let area = rect(0, 0, 10, 3);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let mut registry = LinkRegistry::new();
|
||||
|
||||
// "alpha beta gamma" (16 cols) wraps within width 10.
|
||||
let line = NxLine::from_spans(vec![Link::new("alpha beta gamma", "u").into()]);
|
||||
StatefulWidget::render(
|
||||
NxParagraph::new(line).wrap(Wrap { trim: false }),
|
||||
area,
|
||||
&mut buf,
|
||||
&mut registry,
|
||||
);
|
||||
|
||||
// Link is clickable on both wrapped rows.
|
||||
assert_eq!(registry.hit_test(0, 0), Some("u"));
|
||||
assert_eq!(registry.hit_test(0, 1), Some("u"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_less_paragraph_renders_and_registers_nothing() {
|
||||
let area = rect(0, 0, 12, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let mut registry = LinkRegistry::new();
|
||||
|
||||
StatefulWidget::render(
|
||||
NxParagraph::new(Span::raw("plain text")),
|
||||
area,
|
||||
&mut buf,
|
||||
&mut registry,
|
||||
);
|
||||
|
||||
let rendered: String = (0..10).map(|x| buf[(x, 0)].symbol()).collect();
|
||||
assert_eq!(rendered, "plain text");
|
||||
assert_eq!(registry.hit_test(0, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_label_that_wraps_mid_label_is_clickable_on_every_row() {
|
||||
// "hello my dear friend" is one link; at width 10 the label wraps. Every
|
||||
// wrapped row-segment of it must be clickable, and the whole label renders.
|
||||
let area = rect(0, 0, 10, 5);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let mut registry = LinkRegistry::new();
|
||||
let line = NxLine::from_spans(vec![Link::new("hello my dear friend", "u").into()]);
|
||||
StatefulWidget::render(
|
||||
NxParagraph::new(line).wrap(Wrap { trim: true }),
|
||||
area,
|
||||
&mut buf,
|
||||
&mut registry,
|
||||
);
|
||||
|
||||
let mut hit_rows: Vec<u16> = (0..area.height)
|
||||
.flat_map(|y| (0..area.width).map(move |x| (x, y)))
|
||||
.filter(|&(x, y)| registry.hit_test(x, y) == Some("u"))
|
||||
.map(|(_, y)| y)
|
||||
.collect();
|
||||
hit_rows.sort_unstable();
|
||||
hit_rows.dedup();
|
||||
assert!(
|
||||
hit_rows.len() >= 2,
|
||||
"a wrapped link label must be clickable on each of its rows, got {hit_rows:?}"
|
||||
);
|
||||
|
||||
let text: String = (0..area.height)
|
||||
.map(|y| {
|
||||
(0..area.width)
|
||||
.map(|x| buf[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
for word in ["hello", "my", "dear", "friend"] {
|
||||
assert!(text.contains(word), "label word {word:?} did not render");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_scroll_clips_and_shifts_link_rects() {
|
||||
let area = rect(0, 0, 10, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let mut registry = LinkRegistry::new();
|
||||
// "abc" + link "LINK" (cols 3..7) + "defghij", unwrapped (wider than area).
|
||||
let line = NxLine::from_spans(vec![
|
||||
Span::raw("abc").into(),
|
||||
Link::new("LINK", "u").into(),
|
||||
Span::raw("defghij").into(),
|
||||
]);
|
||||
// Scroll right 4 cols: 'a','b','c','L' are hidden; row starts at 'I'.
|
||||
StatefulWidget::render(
|
||||
NxParagraph::new(line).scroll((0, 4)),
|
||||
area,
|
||||
&mut buf,
|
||||
&mut registry,
|
||||
);
|
||||
|
||||
let rendered: String = (0..10).map(|x| buf[(x, 0)].symbol()).collect();
|
||||
assert_eq!(rendered, "INKdefghij");
|
||||
// Only the visible tail of the link ("INK", screen cols 0..3) is clickable.
|
||||
assert_eq!(registry.hit_test(0, 0), Some("u"));
|
||||
assert_eq!(registry.hit_test(2, 0), Some("u"));
|
||||
assert_eq!(registry.hit_test(3, 0), None); // 'd', past the link
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_count_matches_wrapping() {
|
||||
let para = NxParagraph::new(Span::raw("alpha beta gamma")).wrap(Wrap { trim: false });
|
||||
// 16 cols / width 10 -> 2 rows.
|
||||
assert_eq!(para.line_count(10), 2);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,10 @@ use hashbrown::{HashMap, HashSet};
|
||||
use parking_lot::Mutex;
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
layout::{Alignment, Constraint, Direction, Layout, Position, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{
|
||||
Block, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table,
|
||||
},
|
||||
widgets::{Block, Cell, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::any::Any;
|
||||
@@ -16,9 +14,11 @@ use std::sync::Arc;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
|
||||
use super::help_text::HelpText;
|
||||
use super::link::{Link, LinkRegistry};
|
||||
use super::task_selection_manager::{
|
||||
ScrollMetrics, SelectionEntry, SelectionMode, TaskSection, TaskSelectionManager,
|
||||
};
|
||||
use crate::native::tui::components::nx_paragraph::NxParagraph;
|
||||
use crate::native::{
|
||||
tasks::types::{Task, TaskResult},
|
||||
tui::{
|
||||
@@ -259,6 +259,9 @@ pub struct TasksList {
|
||||
filter_persisted: bool, // Whether the filter is in a persisted state
|
||||
spacebar_mode: bool, // Whether we're in spacebar mode (output follows selection)
|
||||
cloud_message: Option<String>,
|
||||
/// Structured Nx Cloud link (display label, href URL). When set, it renders
|
||||
/// as a clickable label in place of the raw `cloud_message`.
|
||||
cloud_link: Option<(String, String)>,
|
||||
max_parallel: usize, // Maximum number of parallel tasks
|
||||
title_text: String,
|
||||
pub action_tx: Option<UnboundedSender<Action>>,
|
||||
@@ -272,10 +275,32 @@ pub struct TasksList {
|
||||
// and running batch groups, in start order. Excludes tasks nested in a batch
|
||||
// (the batch group represents them).
|
||||
in_progress_entries: Vec<SelectionEntry>,
|
||||
needs_sort: bool, // Deferred sort flag - sort once per render frame
|
||||
needs_sort: bool, // Deferred sort flag - sort once per render frame
|
||||
/// Screen rect of the scrollable rows area (the table minus its header
|
||||
/// overhead), captured during render so mouse clicks can map a row to an
|
||||
/// entry. Each visible viewport entry occupies one row.
|
||||
rows_hit_area: Option<Rect>,
|
||||
/// External links (currently the Nx Cloud message link) recorded during
|
||||
/// render. The app hit-tests this on click to open the link.
|
||||
link_registry: LinkRegistry,
|
||||
/// Screen rect of the table's text region (excluding the scrollbar column),
|
||||
/// captured during render so a drag can select task ids/statuses/durations.
|
||||
text_selection_area: Option<Rect>,
|
||||
perf_report_available: bool, // Whether the performance report exists yet (run finished)
|
||||
}
|
||||
|
||||
/// Outcome of a mouse click landing inside the task list, returned to the App so
|
||||
/// it can react (focus, mode switch, or open a link) outside the component's
|
||||
/// borrow.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TaskListClick {
|
||||
/// A task/batch row was selected.
|
||||
Select,
|
||||
/// A task/batch row was double-clicked — open and focus it in the main
|
||||
/// terminal pane area (the same as pressing Enter on the row).
|
||||
OpenInPane,
|
||||
}
|
||||
|
||||
impl TasksList {
|
||||
/// Creates a new TasksList with the given tasks.
|
||||
/// Converts the input tasks into TaskItems and initializes the UI state.
|
||||
@@ -310,6 +335,7 @@ impl TasksList {
|
||||
filter_persisted: false,
|
||||
spacebar_mode: false,
|
||||
cloud_message: None,
|
||||
cloud_link: None,
|
||||
max_parallel: DEFAULT_MAX_PARALLEL,
|
||||
title_text,
|
||||
action_tx: None,
|
||||
@@ -321,6 +347,9 @@ impl TasksList {
|
||||
terminal_width: None,
|
||||
in_progress_entries: Vec::new(),
|
||||
needs_sort: false,
|
||||
rows_hit_area: None,
|
||||
link_registry: LinkRegistry::new(),
|
||||
text_selection_area: None,
|
||||
perf_report_available: false,
|
||||
};
|
||||
|
||||
@@ -1935,7 +1964,7 @@ impl TasksList {
|
||||
Line::from(vec![Span::styled(instruction_text, filter_style)]),
|
||||
];
|
||||
|
||||
let filter_paragraph = Paragraph::new(filter_lines).alignment(Alignment::Left);
|
||||
let filter_paragraph = NxParagraph::new(filter_lines).alignment(Alignment::Left);
|
||||
f.render_widget(filter_paragraph, filter_area);
|
||||
}
|
||||
|
||||
@@ -1947,6 +1976,76 @@ impl TasksList {
|
||||
total_entries > dynamic_viewport_height
|
||||
}
|
||||
|
||||
/// The table's text region from the last render, used to bound a drag-based
|
||||
/// text selection (and exclude the scrollbar).
|
||||
pub fn selection_area(&self) -> Option<Rect> {
|
||||
self.text_selection_area
|
||||
}
|
||||
|
||||
/// Resolve a left-click at terminal cell `(col, row)` within the task list.
|
||||
///
|
||||
/// The cloud link is checked first; otherwise the row is mapped to a viewport
|
||||
/// entry (rows begin `TABLE_HEADER_OVERHEAD_ROWS` below the table top and are
|
||||
/// one entry tall). A task/batch row updates the selection and returns
|
||||
/// `Select`, or `OpenInPane` on a double-click; a single click on a batch row
|
||||
/// also toggles its expansion. Returns `None` when the click doesn't land on
|
||||
/// anything actionable (header, blank row, gap).
|
||||
pub fn handle_click(&mut self, col: u16, row: u16, is_double: bool) -> Option<TaskListClick> {
|
||||
// External links (e.g. the cloud message) are hit-tested by the app via
|
||||
// `link_registry`, before row clicks, so they aren't handled here.
|
||||
let area = self.rows_hit_area?;
|
||||
if !area.contains(Position::new(col, row)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Rows begin below the header overhead; clicks above that aren't rows.
|
||||
let first_row_y = area.y.saturating_add(TABLE_HEADER_OVERHEAD_ROWS);
|
||||
if row < first_row_y {
|
||||
return None;
|
||||
}
|
||||
let index = (row - first_row_y) as usize;
|
||||
|
||||
// Map the row index to the visible viewport entry. Blank/placeholder
|
||||
// rows are `None` and are not selectable.
|
||||
let entry = {
|
||||
let manager = self.selection_manager.lock();
|
||||
manager
|
||||
.get_viewport_entries()
|
||||
.into_iter()
|
||||
.nth(index)
|
||||
.flatten()
|
||||
}?;
|
||||
|
||||
match &entry {
|
||||
SelectionEntry::Task(task_id) => {
|
||||
self.selection_manager.lock().select_task(task_id);
|
||||
}
|
||||
SelectionEntry::BatchGroup(batch_id) => {
|
||||
self.selection_manager.lock().select_batch_group(batch_id);
|
||||
// A single click on a batch row toggles its expansion so the
|
||||
// mouse can open/close batches like the ←/→ keys do. (The first
|
||||
// click of a double-click toggles; the second opens it in a pane.)
|
||||
if !is_double {
|
||||
let expanded = self
|
||||
.get_batch_group_by_id(batch_id)
|
||||
.map(|batch| batch.is_expanded)
|
||||
.unwrap_or(false);
|
||||
if expanded {
|
||||
self.collapse_batch(batch_id);
|
||||
} else {
|
||||
self.expand_batch(batch_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is_double {
|
||||
Some(TaskListClick::OpenInPane)
|
||||
} else {
|
||||
Some(TaskListClick::Select)
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the main task table with scrollbar if needed.
|
||||
fn render_task_table(
|
||||
&mut self,
|
||||
@@ -1956,6 +2055,9 @@ impl TasksList {
|
||||
needs_scrollbar: bool,
|
||||
scroll_metrics: &ScrollMetrics,
|
||||
) {
|
||||
// Record the table rect for mouse hit-testing. Rows start
|
||||
// TABLE_HEADER_OVERHEAD_ROWS below the top and are one viewport entry tall.
|
||||
self.rows_hit_area = Some(table_area);
|
||||
let visible_entries = self.selection_manager.lock().get_viewport_entries();
|
||||
let selected_style = Style::default()
|
||||
.fg(THEME.primary_fg)
|
||||
@@ -2288,6 +2390,11 @@ impl TasksList {
|
||||
|
||||
f.render_widget(t, table_render_area);
|
||||
|
||||
// The text region is the table minus the scrollbar/padding, so a drag
|
||||
// selection never grabs the scrollbar glyph. (Set after rendering to
|
||||
// avoid overlapping the immutable `header` borrow above.)
|
||||
self.text_selection_area = Some(table_render_area);
|
||||
|
||||
// Render scrollbar if needed
|
||||
if let Some(scrollbar_area) = scrollbar_area {
|
||||
// Position scrollbar below top_margin + header, spanning the spacing row and content
|
||||
@@ -2567,87 +2674,107 @@ impl TasksList {
|
||||
help_text.render(f, help_text_area);
|
||||
}
|
||||
|
||||
/// Renders messages received from Nx Cloud
|
||||
fn render_cloud_message(&self, f: &mut Frame<'_>, cloud_message_area: Rect, is_dimmed: bool) {
|
||||
if let Some(message) = &self.cloud_message {
|
||||
let available_width = cloud_message_area.width;
|
||||
// Ensure minimum width to render anything
|
||||
if available_width == 0 || cloud_message_area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let message_style = if is_dimmed {
|
||||
Style::default().fg(THEME.secondary_fg).dim()
|
||||
} else {
|
||||
Style::default().fg(THEME.secondary_fg)
|
||||
};
|
||||
|
||||
// No URL present in the message, render the message as is if it fits, otherwise truncate
|
||||
if !message.contains("https://") {
|
||||
let message_line = Line::from(Span::styled(message.as_str(), message_style));
|
||||
// Line fits as is
|
||||
if message_line.width() <= available_width as usize {
|
||||
let cloud_message_paragraph =
|
||||
Paragraph::new(message_line).alignment(Alignment::Left);
|
||||
f.render_widget(cloud_message_paragraph, cloud_message_area);
|
||||
return;
|
||||
}
|
||||
// Line doesn't fit, truncate
|
||||
let max_message_render_len = available_width.saturating_sub(3); // Reserve for "..."
|
||||
let truncated_message =
|
||||
format!("{}...", &message[..max_message_render_len as usize]);
|
||||
let cloud_message_paragraph =
|
||||
Paragraph::new(Line::from(Span::styled(truncated_message, message_style)))
|
||||
.alignment(Alignment::Left);
|
||||
f.render_widget(cloud_message_paragraph, cloud_message_area);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find URL position
|
||||
let url_start_pos = message.find("https://").unwrap_or(message.len());
|
||||
// Figure out the "prefix" (i.e. any message contents before the URL)
|
||||
let prefix = &message[0..url_start_pos];
|
||||
let url = &message[url_start_pos..];
|
||||
|
||||
let prefix_len = prefix.len() as u16;
|
||||
let url_len = url.len() as u16;
|
||||
|
||||
let mut spans = vec![];
|
||||
|
||||
let url_style = if is_dimmed {
|
||||
Style::default().fg(THEME.info).underlined().dim()
|
||||
} else {
|
||||
Style::default().fg(THEME.info).underlined()
|
||||
};
|
||||
|
||||
// Determine what fits, prioritizing the URL
|
||||
if url_len <= available_width {
|
||||
// Full URL Fits, check if the full message does, and if so, render the full thing
|
||||
if prefix_len + url_len <= available_width {
|
||||
spans.push(Span::styled(prefix, message_style));
|
||||
spans.push(Span::styled(url, url_style));
|
||||
} else {
|
||||
// Only URL fits, do not render the prefix
|
||||
spans.push(Span::styled(url, url_style));
|
||||
}
|
||||
} else if available_width >= MIN_CLOUD_URL_WIDTH {
|
||||
// Full URL doesn't fit, but Truncated URL does.
|
||||
let max_url_render_len = available_width.saturating_sub(3); // Reserve for "..."
|
||||
let truncated_url = format!("{}...", &url[..max_url_render_len as usize]);
|
||||
spans.push(Span::styled(truncated_url, url_style));
|
||||
} else {
|
||||
// Not enough space for even truncated URL, show nothing...
|
||||
// Hopefully in this situation user can make their terminal bigger or switch layout mode
|
||||
}
|
||||
|
||||
if !spans.is_empty() {
|
||||
let message_line = Line::from(spans);
|
||||
let cloud_message_paragraph =
|
||||
Paragraph::new(message_line).alignment(Alignment::Left);
|
||||
|
||||
f.render_widget(cloud_message_paragraph, cloud_message_area);
|
||||
}
|
||||
/// Renders messages received from Nx Cloud.
|
||||
///
|
||||
/// When the message contains a URL, the URL is rendered as a clickable
|
||||
/// [`Link`] (recorded in `link_registry` for the app to hit-test). The link
|
||||
/// truncates its display with an ellipsis when space is tight while still
|
||||
/// opening the full href.
|
||||
fn render_cloud_message(
|
||||
&mut self,
|
||||
f: &mut Frame<'_>,
|
||||
cloud_message_area: Rect,
|
||||
is_dimmed: bool,
|
||||
) {
|
||||
let available_width = cloud_message_area.width;
|
||||
if available_width == 0 || cloud_message_area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// A structured cloud link takes precedence: render its label as a
|
||||
// clickable link that opens the (different) href. Clone so the borrow of
|
||||
// `self.cloud_link` ends before we touch `&mut self.link_registry`.
|
||||
if let Some((label, url)) = self.cloud_link.clone() {
|
||||
let link = Link::new(label, url).dim(is_dimmed);
|
||||
f.render_stateful_widget(&link, cloud_message_area, &mut self.link_registry);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clone so the borrow of `self.cloud_message` ends before we render the
|
||||
// link, which needs `&mut self.link_registry`.
|
||||
let Some(message) = self.cloud_message.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let message_style = if is_dimmed {
|
||||
Style::default().fg(THEME.secondary_fg).dim()
|
||||
} else {
|
||||
Style::default().fg(THEME.secondary_fg)
|
||||
};
|
||||
|
||||
// No URL present: render the message as-is if it fits, otherwise truncate.
|
||||
if !message.contains("https://") {
|
||||
let message_line = Line::from(Span::styled(message.clone(), message_style));
|
||||
if message_line.width() <= available_width as usize {
|
||||
let cloud_message_paragraph =
|
||||
NxParagraph::new(message_line).alignment(Alignment::Left);
|
||||
f.render_widget(cloud_message_paragraph, cloud_message_area);
|
||||
return;
|
||||
}
|
||||
let max_message_render_len = available_width.saturating_sub(3) as usize; // Reserve for "..."
|
||||
let truncated_message = format!("{}...", &message[..max_message_render_len]);
|
||||
let cloud_message_paragraph =
|
||||
NxParagraph::new(Line::from(Span::styled(truncated_message, message_style)))
|
||||
.alignment(Alignment::Left);
|
||||
f.render_widget(cloud_message_paragraph, cloud_message_area);
|
||||
return;
|
||||
}
|
||||
|
||||
// Split into a plain-text prefix and the URL.
|
||||
let url_start_pos = message.find("https://").unwrap_or(message.len());
|
||||
let prefix = &message[0..url_start_pos];
|
||||
let url = &message[url_start_pos..];
|
||||
let prefix_width = Span::raw(prefix).width() as u16;
|
||||
let url_width = Span::raw(url).width() as u16;
|
||||
|
||||
// The full URL doesn't fit and there isn't even room for a useful
|
||||
// truncation: render nothing (user can widen the terminal).
|
||||
if url_width > available_width && available_width < MIN_CLOUD_URL_WIDTH {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the prefix only when it fits alongside the full URL; otherwise the
|
||||
// URL link takes the whole row (the link truncates itself if needed).
|
||||
let show_prefix = prefix_width > 0 && prefix_width + url_width <= available_width;
|
||||
let link_x = if show_prefix {
|
||||
let prefix_area = Rect {
|
||||
width: prefix_width,
|
||||
..cloud_message_area
|
||||
};
|
||||
let prefix_paragraph =
|
||||
NxParagraph::new(Line::from(Span::styled(prefix.to_string(), message_style)))
|
||||
.alignment(Alignment::Left);
|
||||
f.render_widget(prefix_paragraph, prefix_area);
|
||||
cloud_message_area.x.saturating_add(prefix_width)
|
||||
} else {
|
||||
cloud_message_area.x
|
||||
};
|
||||
|
||||
let link_width = cloud_message_area.right().saturating_sub(link_x);
|
||||
if link_width == 0 {
|
||||
return;
|
||||
}
|
||||
let link_area = Rect {
|
||||
x: link_x,
|
||||
y: cloud_message_area.y,
|
||||
width: link_width,
|
||||
height: 1,
|
||||
};
|
||||
|
||||
// Display text and href are the same here (the URL); a caller that wants
|
||||
// friendly text like "View in Nx Cloud" passes a distinct display.
|
||||
let link = Link::new(url, url).dim(is_dimmed);
|
||||
f.render_stateful_widget(&link, link_area, &mut self.link_registry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2658,6 +2785,11 @@ impl Component for TasksList {
|
||||
}
|
||||
|
||||
fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> {
|
||||
// Reset per-frame link hit-testing; repopulated as the cloud message
|
||||
// renders below. (The app also clears this before the draw pass, but
|
||||
// clearing here keeps the component correct when drawn directly, e.g. in
|
||||
// tests.)
|
||||
self.link_registry.clear();
|
||||
// Flush any pending sort before rendering
|
||||
self.prepare_for_render();
|
||||
|
||||
@@ -2668,7 +2800,9 @@ impl Component for TasksList {
|
||||
// --- 1. Initial Context ---
|
||||
let filter_is_active = self.filter_mode || !self.filter_text.is_empty();
|
||||
let is_dimmed = !self.is_task_list_focused();
|
||||
let has_cloud_message = self.cloud_message.is_some();
|
||||
// A structured cloud link takes precedence over a raw cloud message, but
|
||||
// either counts as "has cloud content" for laying out the bottom bar.
|
||||
let has_cloud_message = self.cloud_message.is_some() || self.cloud_link.is_some();
|
||||
|
||||
// --- 2. Determine Bottom Layout Mode ---
|
||||
enum BottomLayoutMode {
|
||||
@@ -2681,7 +2815,10 @@ impl Component for TasksList {
|
||||
if has_cloud_message {
|
||||
// Calculate the actual cloud message width that will be rendered
|
||||
// This accounts for the URL-only fallback when the full message doesn't fit
|
||||
let cloud_text_width = if let Some(message) = &self.cloud_message {
|
||||
let cloud_text_width = if let Some((label, _url)) = &self.cloud_link {
|
||||
// A structured link renders just its (short) label.
|
||||
Span::raw(label.as_str()).width() as u16
|
||||
} else if let Some(message) = &self.cloud_message {
|
||||
if message.contains("https://") {
|
||||
let url_start_pos = message.find("https://").unwrap_or(message.len());
|
||||
let prefix = &message[0..url_start_pos];
|
||||
@@ -3037,6 +3174,9 @@ impl Component for TasksList {
|
||||
Action::UpdateCloudMessage(message) => {
|
||||
self.cloud_message = Some(message);
|
||||
}
|
||||
Action::UpdateCloudLink(label, url) => {
|
||||
self.cloud_link = Some((label, url));
|
||||
}
|
||||
Action::ScrollUp => {
|
||||
self.scroll_up();
|
||||
}
|
||||
@@ -3075,6 +3215,14 @@ impl Component for TasksList {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn link_registry(&self) -> Option<&LinkRegistry> {
|
||||
Some(&self.link_registry)
|
||||
}
|
||||
|
||||
fn link_registry_mut(&mut self) -> Option<&mut LinkRegistry> {
|
||||
Some(&mut self.link_registry)
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
@@ -4057,6 +4205,113 @@ mod tests {
|
||||
insta::assert_snapshot!(terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_message_url_is_registered_as_a_clickable_link() {
|
||||
let (mut tasks_list, test_tasks) = create_test_tasks_list();
|
||||
let mut terminal = create_test_terminal(120, 15);
|
||||
|
||||
for task in &test_tasks {
|
||||
tasks_list
|
||||
.update(Action::UpdateTaskStatus(
|
||||
task.id.clone(),
|
||||
TaskStatus::Success,
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let url = "https://nx.app/runs/KnGk4A47qk";
|
||||
tasks_list
|
||||
.update(Action::UpdateCloudMessage(format!(
|
||||
"View logs and run details at {url}"
|
||||
)))
|
||||
.ok();
|
||||
|
||||
render_to_test_backend(&mut terminal, &mut tasks_list);
|
||||
|
||||
let registry = tasks_list
|
||||
.link_registry()
|
||||
.expect("the task list exposes a link registry");
|
||||
|
||||
// Every clickable cell resolves to the full URL, and the clickable region
|
||||
// spans exactly the URL's width on a single row (the prefix text is not
|
||||
// part of the link).
|
||||
let mut hits = 0usize;
|
||||
for y in 0..15u16 {
|
||||
for x in 0..120u16 {
|
||||
if let Some(href) = registry.hit_test(x, y) {
|
||||
assert_eq!(href, url, "only the cloud URL should be clickable");
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
hits,
|
||||
url.chars().count(),
|
||||
"the clickable region matches the URL width"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_link_renders_label_and_opens_url() {
|
||||
let (mut tasks_list, test_tasks) = create_test_tasks_list();
|
||||
let mut terminal = create_test_terminal(120, 15);
|
||||
|
||||
for task in &test_tasks {
|
||||
tasks_list
|
||||
.update(Action::UpdateTaskStatus(
|
||||
task.id.clone(),
|
||||
TaskStatus::Success,
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let label = "View in Nx Cloud";
|
||||
let url = "https://nx.app/runs/KnGk4A47qk";
|
||||
tasks_list
|
||||
.update(Action::UpdateCloudLink(label.to_string(), url.to_string()))
|
||||
.ok();
|
||||
|
||||
render_to_test_backend(&mut terminal, &mut tasks_list);
|
||||
|
||||
// The friendly label is drawn (not the URL) and clicking anywhere on it
|
||||
// opens the full URL. Scan row-major so horizontal text is contiguous.
|
||||
let buffer = terminal.backend().buffer().clone();
|
||||
let rendered: String = (0..buffer.area.height)
|
||||
.map(|y| {
|
||||
(0..buffer.area.width)
|
||||
.map(|x| buffer[(x, y)].symbol().to_string())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
rendered.contains(label),
|
||||
"the friendly label should be rendered somewhere"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains(url),
|
||||
"the raw URL should not be shown when a structured link is set"
|
||||
);
|
||||
|
||||
let registry = tasks_list
|
||||
.link_registry()
|
||||
.expect("the task list exposes a link registry");
|
||||
let mut hits = 0usize;
|
||||
for y in 0..15u16 {
|
||||
for x in 0..120u16 {
|
||||
if let Some(href) = registry.hit_test(x, y) {
|
||||
assert_eq!(href, url, "the link opens the full URL");
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
hits,
|
||||
label.chars().count(),
|
||||
"the clickable region matches the label width"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_focused() {
|
||||
let (mut tasks_list, test_tasks) = create_test_tasks_list();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use arboard::Clipboard;
|
||||
use crate::native::tui::clipboard::copy_to_clipboard;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
@@ -6,14 +6,15 @@ use ratatui::{
|
||||
style::{Modifier, Style, Stylize},
|
||||
text::{Line, Span},
|
||||
widgets::{
|
||||
Block, BorderType, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
|
||||
ScrollbarState, StatefulWidget, Widget,
|
||||
Block, BorderType, Borders, Padding, Scrollbar, ScrollbarOrientation, ScrollbarState,
|
||||
StatefulWidget, Widget,
|
||||
},
|
||||
};
|
||||
use std::{io, sync::Arc, time::Instant};
|
||||
use tracing::debug;
|
||||
use tui_term::widget::PseudoTerminal;
|
||||
|
||||
use crate::native::tui::components::nx_paragraph::NxParagraph;
|
||||
use crate::native::tui::components::tasks_list::TaskStatus;
|
||||
use crate::native::tui::scroll_momentum::{ScrollDirection, ScrollMomentum};
|
||||
use crate::native::tui::theme::THEME;
|
||||
@@ -23,6 +24,41 @@ use crate::native::tui::utils::{
|
||||
use crate::native::tui::vt100_adapter::Vt100CttScreen;
|
||||
use crate::native::tui::{action::Action, pty::PtyInstance};
|
||||
|
||||
/// A text selection within a terminal pane, tracked in absolute content
|
||||
/// (visual-row, column) coordinates so it stays anchored to the text as the
|
||||
/// pane scrolls.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct TextSelection {
|
||||
anchor: (usize, usize),
|
||||
cursor: (usize, usize),
|
||||
/// Whether a drag is currently in progress (vs. a finalized selection).
|
||||
dragging: bool,
|
||||
}
|
||||
|
||||
impl TextSelection {
|
||||
/// Normalized `(start, end)` with `start <= end` in reading order.
|
||||
fn range(&self) -> ((usize, usize), (usize, usize)) {
|
||||
if self.anchor <= self.cursor {
|
||||
(self.anchor, self.cursor)
|
||||
} else {
|
||||
(self.cursor, self.anchor)
|
||||
}
|
||||
}
|
||||
|
||||
/// True once the selection spans more than its origin cell (a real drag).
|
||||
fn is_nonempty(&self) -> bool {
|
||||
self.anchor != self.cursor
|
||||
}
|
||||
|
||||
/// Whether content cell `(row, col)` falls inside the selection (inclusive).
|
||||
fn contains(&self, row: usize, col: usize) -> bool {
|
||||
let (start, end) = self.range();
|
||||
let after_start = row > start.0 || (row == start.0 && col >= start.1);
|
||||
let before_end = row < end.0 || (row == end.0 && col <= end.1);
|
||||
after_start && before_end
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for terminal pane layout and display constants
|
||||
#[derive(Debug, Clone)]
|
||||
struct TerminalPaneConfig {
|
||||
@@ -53,6 +89,11 @@ pub struct TerminalPaneData {
|
||||
scroll_momentum: ScrollMomentum,
|
||||
// Transient status message with timestamp for auto-clear
|
||||
pub status_message: Option<(String, Instant)>,
|
||||
/// Active text selection within this pane, if any (NXC-3946).
|
||||
selection: Option<TextSelection>,
|
||||
/// Inner content rect (inside borders/padding) captured during the last
|
||||
/// render, used to translate mouse coordinates into content coordinates.
|
||||
last_content_area: Option<Rect>,
|
||||
}
|
||||
|
||||
impl TerminalPaneData {
|
||||
@@ -64,6 +105,8 @@ impl TerminalPaneData {
|
||||
can_be_interactive: false,
|
||||
scroll_momentum: ScrollMomentum::new(),
|
||||
status_message: None,
|
||||
selection: None,
|
||||
last_content_area: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,24 +159,31 @@ impl TerminalPaneData {
|
||||
pty_mut.scroll_down(12);
|
||||
return Ok(None);
|
||||
}
|
||||
// Handle 'c' for copying when not in interactive mode
|
||||
// Handle 'c' for copying when not in interactive mode. Prefer an
|
||||
// active selection; fall back to copying the whole output.
|
||||
KeyCode::Char('c') if !self.is_interactive => {
|
||||
let status_message = if let Some(screen) = pty.get_screen() {
|
||||
// Unformatted output (no ANSI escape codes)
|
||||
let output = screen.all_contents();
|
||||
match Clipboard::new() {
|
||||
Ok(mut clipboard) => {
|
||||
if clipboard.set_text(output).is_ok() {
|
||||
Some("Output copied")
|
||||
} else {
|
||||
Some("Copy failed")
|
||||
}
|
||||
let status_message =
|
||||
if let Some(sel) = self.selection.filter(|s| s.is_nonempty()) {
|
||||
let (start, end) = sel.range();
|
||||
let text = pty.selected_text(start, end);
|
||||
if text.is_empty() {
|
||||
None
|
||||
} else if copy_to_clipboard(&text) {
|
||||
Some("Selection copied")
|
||||
} else {
|
||||
Some("Copy failed")
|
||||
}
|
||||
Err(_) => Some("Copy failed"),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
} else if let Some(screen) = pty.get_screen() {
|
||||
// Unformatted output (no ANSI escape codes)
|
||||
let output = screen.all_contents();
|
||||
if copy_to_clipboard(&output) {
|
||||
Some("Output copied")
|
||||
} else {
|
||||
Some("Copy failed")
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Set status message outside the pty borrow
|
||||
if let Some(msg) = status_message {
|
||||
self.status_message = Some((msg.to_owned(), Instant::now()));
|
||||
@@ -225,6 +275,130 @@ impl TerminalPaneData {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Public entry point for mouse-wheel scrolling of the terminal output.
|
||||
/// Reuses the same momentum model as keyboard scrolling so the wheel and
|
||||
/// arrow keys feel consistent.
|
||||
pub fn handle_mouse_scroll(&mut self, direction: ScrollDirection) {
|
||||
self.scroll(direction);
|
||||
}
|
||||
|
||||
// --- Text selection (NXC-3946) -------------------------------------------
|
||||
|
||||
/// Translate a terminal cell `(col, row)` into absolute content coordinates
|
||||
/// `(visual_row, column)` within this pane, if the pane has rendered content
|
||||
/// and the cell falls inside the content area.
|
||||
pub fn content_coords_at(&self, col: u16, row: u16) -> Option<(usize, usize)> {
|
||||
let area = self.last_content_area?;
|
||||
if col < area.x
|
||||
|| col >= area.x.saturating_add(area.width)
|
||||
|| row < area.y
|
||||
|| row >= area.y.saturating_add(area.height)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let pty = self.pty.as_ref()?;
|
||||
let screen_row = (row - area.y) as usize;
|
||||
let screen_col = (col - area.x) as usize;
|
||||
// Map the on-screen row to an absolute content row: the top visible row
|
||||
// is `total - viewport_height - scrollback` rows from the start.
|
||||
let top = pty
|
||||
.get_total_content_rows()
|
||||
.saturating_sub(area.height as usize)
|
||||
.saturating_sub(pty.get_scroll_offset());
|
||||
Some((top + screen_row, screen_col))
|
||||
}
|
||||
|
||||
/// Like [`content_coords_at`](Self::content_coords_at) but clamps the cell
|
||||
/// into the content area first, so a drag that strays outside the pane still
|
||||
/// extends the selection to the nearest edge.
|
||||
pub fn content_coords_clamped(&self, col: u16, row: u16) -> Option<(usize, usize)> {
|
||||
let area = self.last_content_area?;
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return None;
|
||||
}
|
||||
let c = col.clamp(area.x, area.x + area.width - 1);
|
||||
let r = row.clamp(area.y, area.y + area.height - 1);
|
||||
self.content_coords_at(c, r)
|
||||
}
|
||||
|
||||
/// Vertical edge of the content area the cell is at, for drag auto-scroll:
|
||||
/// `-1` at/above the top edge, `1` at/below the bottom edge, `0` otherwise.
|
||||
pub fn content_edge(&self, row: u16) -> i8 {
|
||||
match self.last_content_area {
|
||||
Some(area) if area.height > 0 => {
|
||||
if row <= area.y {
|
||||
-1
|
||||
} else if row >= area.y + area.height - 1 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin a selection drag at the given content coordinates.
|
||||
pub fn begin_selection(&mut self, row: usize, col: usize) {
|
||||
self.selection = Some(TextSelection {
|
||||
anchor: (row, col),
|
||||
cursor: (row, col),
|
||||
dragging: true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Update the in-progress selection's cursor to new content coordinates.
|
||||
pub fn update_selection(&mut self, row: usize, col: usize) {
|
||||
if let Some(sel) = &mut self.selection
|
||||
&& sel.dragging
|
||||
{
|
||||
sel.cursor = (row, col);
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish the current selection drag. A plain click (no movement) clears the
|
||||
/// selection. Returns true if a non-empty selection remains.
|
||||
pub fn finish_selection(&mut self) -> bool {
|
||||
if let Some(sel) = &mut self.selection {
|
||||
sel.dragging = false;
|
||||
if !sel.is_nonempty() {
|
||||
self.selection = None;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Clear any selection.
|
||||
pub fn clear_selection(&mut self) {
|
||||
self.selection = None;
|
||||
}
|
||||
|
||||
/// Copy the current selection to the clipboard and set a status message.
|
||||
pub fn copy_selection(&mut self) {
|
||||
let Some(sel) = self.selection else {
|
||||
return;
|
||||
};
|
||||
if !sel.is_nonempty() {
|
||||
return;
|
||||
}
|
||||
let Some(pty) = self.pty.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let (start, end) = sel.range();
|
||||
let text = pty.selected_text(start, end);
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let msg = if copy_to_clipboard(&text) {
|
||||
"Selection copied"
|
||||
} else {
|
||||
"Copy failed"
|
||||
};
|
||||
self.status_message = Some((msg.to_owned(), Instant::now()));
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TerminalPaneData {
|
||||
@@ -432,7 +606,7 @@ impl<'a> TerminalPane<'a> {
|
||||
impl<'a> StatefulWidget for TerminalPane<'a> {
|
||||
type State = TerminalPaneState;
|
||||
|
||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||
fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||
// Clamp to the buffer to avoid rendering outside bounds
|
||||
let safe_area = area.intersection(*buf.area());
|
||||
if safe_area.width == 0 || safe_area.height == 0 {
|
||||
@@ -444,7 +618,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
|
||||
if safe_area.width < 5 || safe_area.height < 5 {
|
||||
// Just render a minimal indicator instead of a full pane
|
||||
let text = "...";
|
||||
let paragraph = Paragraph::new(text)
|
||||
let paragraph = NxParagraph::new(text)
|
||||
.style(Style::default().fg(THEME.secondary_fg))
|
||||
.alignment(Alignment::Center);
|
||||
Widget::render(paragraph, safe_area, buf);
|
||||
@@ -547,7 +721,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
|
||||
},
|
||||
)])];
|
||||
|
||||
let paragraph = Paragraph::new(message)
|
||||
let paragraph = NxParagraph::new(message)
|
||||
.block(block)
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::default());
|
||||
@@ -570,7 +744,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
|
||||
},
|
||||
)])];
|
||||
|
||||
let paragraph = Paragraph::new(message)
|
||||
let paragraph = NxParagraph::new(message)
|
||||
.block(block)
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::default());
|
||||
@@ -591,7 +765,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
|
||||
},
|
||||
)])];
|
||||
|
||||
let paragraph = Paragraph::new(message)
|
||||
let paragraph = NxParagraph::new(message)
|
||||
.block(block)
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::default());
|
||||
@@ -613,7 +787,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
|
||||
message_style,
|
||||
)])];
|
||||
|
||||
let paragraph = Paragraph::new(message)
|
||||
let paragraph = NxParagraph::new(message)
|
||||
.block(block)
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::default());
|
||||
@@ -642,7 +816,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
|
||||
message_style,
|
||||
)])];
|
||||
|
||||
let paragraph = Paragraph::new(message)
|
||||
let paragraph = NxParagraph::new(message)
|
||||
.block(block)
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::default());
|
||||
@@ -653,20 +827,36 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
|
||||
|
||||
let inner_area = block.inner(safe_area);
|
||||
|
||||
// Record the content rect so mouse events can map cells to content
|
||||
// coordinates for text selection. Scoped so the mutable borrow ends
|
||||
// before the immutable render borrow below.
|
||||
if let Some(pty_data) = &mut self.pty_data {
|
||||
pty_data.last_content_area = Some(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;
|
||||
// Read every value that needs the parser lock BEFORE acquiring the
|
||||
// `screen` read guard below. parking_lot's RwLock is non-reentrant
|
||||
// and writer-preferring: a blocking `read()` taken while `screen`
|
||||
// is still held deadlocks the render thread against the PTY's
|
||||
// output-writer thread the moment that thread has a `write()`
|
||||
// queued. That is what froze the entire TUI when selecting text in
|
||||
// a pane that was still producing output.
|
||||
let viewport_height = inner_area.height;
|
||||
let current_scroll = pty.get_scroll_offset();
|
||||
// Calculate content based on expected dimensions, not current PTY
|
||||
// dimensions. This prevents scrollbar flash when the PTY hasn't
|
||||
// been resized yet.
|
||||
let total_content_rows =
|
||||
self.calculate_content_rows_for_viewport(pty, viewport_height);
|
||||
// Absolute content-row count used to map the selection overlay,
|
||||
// matching `content_coords_at`'s basis.
|
||||
let selection_content_rows = pty.get_total_content_rows();
|
||||
|
||||
if let Some(screen) = pty.get_screen() {
|
||||
// Cache expected viewport height for consistent calculations
|
||||
state.expected_viewport_height = Some(viewport_height);
|
||||
|
||||
let current_scroll = pty.get_scroll_offset();
|
||||
|
||||
// Calculate content based on expected dimensions, not current PTY dimensions
|
||||
// This prevents scrollbar flash when PTY hasn't been resized yet
|
||||
let total_content_rows =
|
||||
self.calculate_content_rows_for_viewport(pty, viewport_height);
|
||||
let scrollable_rows =
|
||||
total_content_rows.saturating_sub(viewport_height as usize);
|
||||
|
||||
@@ -690,6 +880,27 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
|
||||
PseudoTerminal::new(Vt100CttScreen::wrap(&screen)).block(block);
|
||||
Widget::render(pseudo_term, safe_area, buf);
|
||||
|
||||
// Overlay the text-selection highlight (NXC-3946). tui-term has
|
||||
// no selection concept, so we reverse-video the selected cells
|
||||
// after it has rendered. Map each visible cell to its content
|
||||
// row and test it against the selection.
|
||||
if let Some(selection) = pty_data.selection {
|
||||
let top = selection_content_rows
|
||||
.saturating_sub(inner_area.height as usize)
|
||||
.saturating_sub(current_scroll);
|
||||
for sy in 0..inner_area.height {
|
||||
let content_row = top + sy as usize;
|
||||
for sx in 0..inner_area.width {
|
||||
if selection.contains(content_row, sx as usize)
|
||||
&& let Some(cell) =
|
||||
buf.cell_mut((inner_area.x + sx, inner_area.y + sy))
|
||||
{
|
||||
cell.modifier |= Modifier::REVERSED;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only render scrollbar if needed
|
||||
if needs_scrollbar {
|
||||
let scrollbar = Scrollbar::default()
|
||||
@@ -811,10 +1022,13 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
|
||||
height: 1,
|
||||
};
|
||||
|
||||
Paragraph::new(bottom_text)
|
||||
.alignment(Alignment::Right)
|
||||
.style(border_style)
|
||||
.render(bottom_right_area, buf);
|
||||
Widget::render(
|
||||
NxParagraph::new(bottom_text)
|
||||
.alignment(Alignment::Right)
|
||||
.style(border_style),
|
||||
bottom_right_area,
|
||||
buf,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -845,10 +1059,13 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
|
||||
height: 1,
|
||||
};
|
||||
|
||||
Paragraph::new(help_line)
|
||||
.alignment(Alignment::Left)
|
||||
.style(border_style)
|
||||
.render(bottom_left_area, buf);
|
||||
Widget::render(
|
||||
NxParagraph::new(help_line)
|
||||
.alignment(Alignment::Left)
|
||||
.style(border_style),
|
||||
bottom_left_area,
|
||||
buf,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -870,10 +1087,13 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
|
||||
height: 1,
|
||||
};
|
||||
|
||||
Paragraph::new(padding_text.clone())
|
||||
.alignment(Alignment::Right)
|
||||
.style(border_style)
|
||||
.render(top_right_area, buf);
|
||||
Widget::render(
|
||||
NxParagraph::new(padding_text.clone())
|
||||
.alignment(Alignment::Right)
|
||||
.style(border_style),
|
||||
top_right_area,
|
||||
buf,
|
||||
);
|
||||
|
||||
// Bottom padding (only if interactive status is not being displayed)
|
||||
if !show_interactive_status {
|
||||
@@ -886,10 +1106,13 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
|
||||
height: 1,
|
||||
};
|
||||
|
||||
Paragraph::new(padding_text)
|
||||
.alignment(Alignment::Right)
|
||||
.style(border_style)
|
||||
.render(bottom_right_area, buf);
|
||||
Widget::render(
|
||||
NxParagraph::new(padding_text)
|
||||
.alignment(Alignment::Right)
|
||||
.style(border_style),
|
||||
bottom_right_area,
|
||||
buf,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -922,10 +1145,13 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
|
||||
height: 1,
|
||||
};
|
||||
|
||||
Paragraph::new(duration_line)
|
||||
.alignment(Alignment::Right)
|
||||
.style(border_style)
|
||||
.render(duration_area, buf);
|
||||
Widget::render(
|
||||
NxParagraph::new(duration_line)
|
||||
.alignment(Alignment::Right)
|
||||
.style(border_style),
|
||||
duration_area,
|
||||
buf,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -940,6 +1166,54 @@ mod tests {
|
||||
use super::*;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
#[test]
|
||||
fn test_text_selection_contains_inclusive_range() {
|
||||
let sel = TextSelection {
|
||||
anchor: (1, 2),
|
||||
cursor: (3, 4),
|
||||
dragging: false,
|
||||
};
|
||||
// Before the start of the selection.
|
||||
assert!(!sel.contains(1, 1));
|
||||
assert!(!sel.contains(0, 9));
|
||||
// Start cell is inclusive.
|
||||
assert!(sel.contains(1, 2));
|
||||
// A fully-covered middle row.
|
||||
assert!(sel.contains(2, 0));
|
||||
assert!(sel.contains(2, 999));
|
||||
// End row is covered up to and including the end column.
|
||||
assert!(sel.contains(3, 4));
|
||||
assert!(!sel.contains(3, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_selection_normalizes_reversed_drag() {
|
||||
// Dragging up/left puts the cursor before the anchor; the range should
|
||||
// still be normalized.
|
||||
let sel = TextSelection {
|
||||
anchor: (3, 4),
|
||||
cursor: (1, 2),
|
||||
dragging: true,
|
||||
};
|
||||
assert!(sel.contains(1, 2));
|
||||
assert!(sel.contains(2, 10));
|
||||
assert!(sel.contains(3, 4));
|
||||
assert!(!sel.contains(1, 1));
|
||||
assert!(!sel.contains(3, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_selection_empty_is_single_cell() {
|
||||
let sel = TextSelection {
|
||||
anchor: (2, 5),
|
||||
cursor: (2, 5),
|
||||
dragging: true,
|
||||
};
|
||||
assert!(!sel.is_nonempty());
|
||||
assert!(sel.contains(2, 5));
|
||||
assert!(!sel.contains(2, 6));
|
||||
}
|
||||
|
||||
// Helper function to create a TerminalPane for testing
|
||||
fn create_terminal_pane() -> TerminalPane<'static> {
|
||||
TerminalPane::new()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use arboard::Clipboard;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use hashbrown::HashSet;
|
||||
@@ -6,7 +5,6 @@ use parking_lot::Mutex;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Size};
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
@@ -16,6 +14,7 @@ use tracing::debug;
|
||||
/// Duration before status messages are automatically cleared
|
||||
const STATUS_MESSAGE_DURATION: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
use crate::native::tui::components::nx_paragraph::NxParagraph;
|
||||
use crate::native::tui::utils::{
|
||||
calculate_actual_duration_ms, format_duration_with_estimate, get_task_status_style,
|
||||
};
|
||||
@@ -25,6 +24,7 @@ use crate::native::{
|
||||
};
|
||||
|
||||
use super::action::Action;
|
||||
use super::clipboard::copy_to_clipboard;
|
||||
use super::components::countdown_popup::CountdownPopup;
|
||||
use super::components::task_selection_manager::SelectionEntry;
|
||||
use super::components::tasks_list::TaskStatus;
|
||||
@@ -491,12 +491,10 @@ impl TuiApp for InlineApp {
|
||||
// Unformatted output (no ANSI escape codes)
|
||||
let output = screen.all_contents();
|
||||
drop(state); // Release lock before clipboard operations
|
||||
if let Ok(mut clipboard) = Clipboard::new() {
|
||||
if clipboard.set_text(output).is_ok() {
|
||||
// Show status message in bottom chrome
|
||||
self.status_message =
|
||||
Some((String::from("Output copied"), Instant::now()));
|
||||
}
|
||||
if copy_to_clipboard(&output) {
|
||||
// Show status message in bottom chrome
|
||||
self.status_message =
|
||||
Some((String::from("Output copied"), Instant::now()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -781,20 +779,19 @@ impl InlineApp {
|
||||
use crate::native::tui::theme::THEME;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
|
||||
let height = lines_to_render as u16;
|
||||
|
||||
// Call insert_before on the dereferenced Terminal
|
||||
// This only works with inline viewport
|
||||
if let Ok(()) = tui.insert_before(height, |buf| {
|
||||
// Convert batched scrollback lines to ratatui Lines
|
||||
let lines: Vec<Line> =
|
||||
batch.iter().map(|line| Line::from(line.as_str())).collect();
|
||||
// Convert batched scrollback lines to owned ratatui Lines
|
||||
let lines: Vec<Line<'static>> =
|
||||
batch.iter().map(|line| Line::from(line.clone())).collect();
|
||||
|
||||
// Create a paragraph with the buffered scrollback content
|
||||
let paragraph =
|
||||
Paragraph::new(lines).style(Style::default().fg(THEME.secondary_fg));
|
||||
NxParagraph::new(lines).style(Style::default().fg(THEME.secondary_fg));
|
||||
|
||||
// Render using the Widget trait
|
||||
use ratatui::widgets::Widget;
|
||||
@@ -993,7 +990,7 @@ impl InlineApp {
|
||||
Span::styled(task_name.clone(), status_style),
|
||||
];
|
||||
|
||||
f.render_widget(Paragraph::new(Line::from(left_spans)), chunks[0]);
|
||||
f.render_widget(NxParagraph::new(Line::from(left_spans)), chunks[0]);
|
||||
|
||||
// Build right side: status msg + esc hint + interactive hint (if space) + cloud message (if space) + duration
|
||||
let mut right_spans = Vec::new();
|
||||
@@ -1032,7 +1029,7 @@ impl InlineApp {
|
||||
));
|
||||
}
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(right_spans)), chunks[1]);
|
||||
f.render_widget(NxParagraph::new(Line::from(right_spans)), chunks[1]);
|
||||
}
|
||||
|
||||
fn render_inline_main_content(&mut self, f: &mut ratatui::Frame, area: ratatui::layout::Rect) {
|
||||
@@ -1057,9 +1054,9 @@ impl InlineApp {
|
||||
use crate::native::tui::theme::THEME;
|
||||
use ratatui::layout::Alignment;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::widgets::{Block, Borders};
|
||||
|
||||
let message = Paragraph::new(" Waiting for tasks to start... ")
|
||||
let message = NxParagraph::new(" Waiting for tasks to start... ")
|
||||
.style(Style::default().fg(THEME.secondary_fg))
|
||||
.alignment(Alignment::Center)
|
||||
.block(Block::default().borders(Borders::NONE));
|
||||
|
||||
@@ -760,6 +760,15 @@ impl AppLifeCycle {
|
||||
self.with_app(|app| app.set_batch_status(batch_id, status));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set a clickable Nx Cloud link in the TUI: `label` is the text shown,
|
||||
/// `url` is opened when it's clicked. This is a `LifeCycle` method so the Nx
|
||||
/// Cloud client can call it via the lifecycle it already receives.
|
||||
#[napi]
|
||||
pub fn set_cloud_link(&self, label: String, url: String) -> napi::Result<()> {
|
||||
self.with_app(|app| app.set_cloud_link(label, url));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
@@ -767,6 +776,10 @@ pub fn restore_terminal() -> napi::Result<()> {
|
||||
// Clear terminal progress indicator
|
||||
App::clear_terminal_progress();
|
||||
|
||||
// Disable mouse capture (safe even if it was never enabled) so the terminal
|
||||
// stops emitting mouse escape sequences once the TUI tears down.
|
||||
let _ = super::tui::disable_mouse_capture();
|
||||
|
||||
// Drain pending terminal responses (e.g., OSC color query responses)
|
||||
// to prevent escape sequences from leaking to the terminal on exit
|
||||
super::tui::drain_stdin();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod action;
|
||||
pub mod app;
|
||||
pub mod clipboard;
|
||||
pub mod components;
|
||||
pub mod config;
|
||||
pub mod escape_sequences;
|
||||
|
||||
@@ -712,6 +712,97 @@ impl PtyInstance {
|
||||
self.parser.read().screen().get_total_content_rows()
|
||||
}
|
||||
|
||||
/// Extract the plain text covered by a selection expressed in absolute
|
||||
/// content-row + column coordinates (both ends inclusive).
|
||||
///
|
||||
/// The selection arrives in *visual* (wrapped) row coordinates — the basis
|
||||
/// `content_coords_at`, the overlay, and `get_total_content_rows()` share.
|
||||
/// The buffer is read once as unwrapped logical lines with
|
||||
/// `Screen::all_contents` (scrollback included, no scrolling). Each visual
|
||||
/// endpoint is translated to a character offset inside its logical line by
|
||||
/// counting how many visual rows the preceding lines occupy, then the
|
||||
/// unwrapped lines are sliced directly between the two offsets. This yields
|
||||
/// unwrapped text without ever materializing the wrapped rows, and a
|
||||
/// newline appears only at real line breaks. Trailing whitespace is trimmed
|
||||
/// per line.
|
||||
pub fn selected_text(&self, start: (usize, usize), end: (usize, usize)) -> String {
|
||||
let (start_row, start_col) = start;
|
||||
let (end_row, end_col) = end;
|
||||
|
||||
let (contents, cols) = {
|
||||
let parser = self.parser.read();
|
||||
let screen = parser.screen();
|
||||
let (_, cols) = screen.size();
|
||||
(screen.all_contents(), cols as usize)
|
||||
};
|
||||
if cols == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let logical: Vec<&str> = contents.lines().collect();
|
||||
|
||||
// Translate an absolute visual row to a position in the unwrapped lines:
|
||||
// the logical line it falls on, plus the character offset where that
|
||||
// visual sub-row begins (`sub_row * cols`, since the terminal hard-wraps
|
||||
// at the column count). `None` once the row is past the wrapped content
|
||||
// (the viewport's trailing empty rows, trimmed from `all_contents`).
|
||||
let locate = |visual_row: usize| -> Option<(usize, usize)> {
|
||||
let mut consumed = 0usize;
|
||||
for (index, line) in logical.iter().enumerate() {
|
||||
let height = wrap_logical_line(line, cols);
|
||||
if visual_row < consumed + height {
|
||||
return Some((index, (visual_row - consumed) * cols));
|
||||
}
|
||||
consumed += height;
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
// A selection that starts past the content selects nothing.
|
||||
let Some((start_line, start_base)) = locate(start_row) else {
|
||||
return String::new();
|
||||
};
|
||||
let start_offset = start_base + start_col;
|
||||
// An end dragged into the empty rows below the content selects through
|
||||
// the end of the last logical line.
|
||||
let (end_line, end_offset) = match locate(end_row) {
|
||||
Some((line, base)) => (line, base + end_col + 1),
|
||||
None => {
|
||||
let last = logical.len().saturating_sub(1);
|
||||
(
|
||||
last,
|
||||
logical.get(last).map_or(0, |line| line.chars().count()),
|
||||
)
|
||||
}
|
||||
};
|
||||
if end_line < start_line {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for (offset, line) in logical[start_line..=end_line].iter().enumerate() {
|
||||
let line_index = start_line + offset;
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let len = chars.len();
|
||||
let (from, to) = if start_line == end_line {
|
||||
(start_offset.min(len), end_offset.min(len))
|
||||
} else if line_index == start_line {
|
||||
(start_offset.min(len), len)
|
||||
} else if line_index == end_line {
|
||||
(0, end_offset.min(len))
|
||||
} else {
|
||||
(0, len)
|
||||
};
|
||||
let slice: String = chars[from..to.max(from)].iter().collect();
|
||||
lines.push(slice.trim_end().to_string());
|
||||
}
|
||||
// Drop blank lines dragged past the end of the content so the copy
|
||||
// doesn't gain trailing newlines.
|
||||
while lines.last().is_some_and(|line| line.is_empty()) {
|
||||
lines.pop();
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Checks if the process is likely in interactive/raw mode
|
||||
/// Uses both alternate screen detection and cursor movement patterns
|
||||
pub fn handles_arrow_keys(&self) -> bool {
|
||||
@@ -855,6 +946,80 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selected_text_single_and_multi_row() {
|
||||
let pty = PtyInstance::non_interactive();
|
||||
pty.process_output(b"line one\r\nline two\r\nline three\r\n");
|
||||
|
||||
// Single row, partial columns.
|
||||
assert_eq!(pty.selected_text((0, 0), (0, 3)), "line");
|
||||
// Whole second row (trailing whitespace trimmed).
|
||||
assert_eq!(pty.selected_text((1, 0), (1, 20)), "line two");
|
||||
// Spanning two rows: tail of row 0 + head of row 1.
|
||||
assert_eq!(pty.selected_text((0, 5), (1, 3)), "one\nline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selected_text_out_of_range_is_empty() {
|
||||
let pty = PtyInstance::non_interactive();
|
||||
pty.process_output(b"hello\r\n");
|
||||
// A start row past the content yields nothing rather than panicking.
|
||||
assert_eq!(pty.selected_text((9999, 0), (9999, 5)), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selected_text_joins_wrapped_rows_unwrapped() {
|
||||
let pty = PtyInstance::non_interactive();
|
||||
// 100 chars at 80 cols wraps onto two visual rows (0: 80, 1: 20).
|
||||
let long = "a".repeat(100);
|
||||
pty.process_output(format!("{long}\r\n").as_bytes());
|
||||
|
||||
// Selecting across both visual rows yields the original unwrapped line
|
||||
// with no newline injected at the wrap point.
|
||||
assert_eq!(pty.selected_text((0, 0), (1, 19)), long);
|
||||
assert!(!pty.selected_text((0, 0), (1, 19)).contains('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selected_text_partial_span_across_wrap_boundary() {
|
||||
let pty = PtyInstance::non_interactive();
|
||||
// A 100-char line of repeating digits so each column maps to a known
|
||||
// character: index i holds the digit (i % 10).
|
||||
let line: String = (0..100)
|
||||
.map(|i| char::from(b'0' + (i % 10) as u8))
|
||||
.collect();
|
||||
pty.process_output(format!("{line}\r\n").as_bytes());
|
||||
|
||||
// Select from visual row 0 col 75 through visual row 1 col 4. The wrap
|
||||
// is at column 80, so this is character offsets 75..=84 of the unwrapped
|
||||
// line — the slice must cross the wrap point with the right columns.
|
||||
assert_eq!(pty.selected_text((0, 75), (1, 4)), "5678901234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selected_text_trailing_empty_rows_dont_shift_columns() {
|
||||
let pty = PtyInstance::non_interactive();
|
||||
pty.process_output(b"alpha\r\nbravo\r\n");
|
||||
|
||||
// Dragging the selection end down into the empty rows below the content
|
||||
// must not pull the end column up onto "bravo" (the previous bug, where
|
||||
// the trimmed re-wrapped array clamped end_row and truncated the line).
|
||||
assert_eq!(pty.selected_text((0, 0), (5, 3)), "alpha\nbravo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selected_text_includes_scrollback_rows() {
|
||||
let pty = PtyInstance::non_interactive();
|
||||
// 24-row viewport: writing 30 lines pushes the first 6 into scrollback.
|
||||
for i in 0..30 {
|
||||
pty.process_output(format!("line {i}\r\n").as_bytes());
|
||||
}
|
||||
// Absolute row 0 is the oldest scrollback row, lining up with the
|
||||
// coordinates `content_coords_at` produces.
|
||||
assert_eq!(pty.selected_text((0, 0), (0, 5)), "line 0");
|
||||
assert_eq!(pty.selected_text((2, 0), (2, 5)), "line 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handles_arrow_keys_enquirer_style_output() {
|
||||
let pty = create_test_pty_instance(false);
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::native::tui::theme::THEME;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::{
|
||||
cursor,
|
||||
event::{self, Event as CrosstermEvent, KeyEvent, KeyEventKind},
|
||||
event::{self, Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent},
|
||||
execute,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
@@ -37,6 +37,7 @@ pub enum Event {
|
||||
FocusLost,
|
||||
Paste(String),
|
||||
Key(KeyEvent),
|
||||
Mouse(MouseEvent),
|
||||
Resize(u16, u16),
|
||||
}
|
||||
|
||||
@@ -54,6 +55,39 @@ pub struct Tui {
|
||||
pub current_mode: TuiMode,
|
||||
}
|
||||
|
||||
/// DEC private mode sequences for enabling mouse reporting.
|
||||
///
|
||||
/// We deliberately enable a narrow set of modes rather than crossterm's bundled
|
||||
/// `EnableMouseCapture` (which also enables `?1003h`, "any-event" / all-motion
|
||||
/// tracking). The modes we use:
|
||||
/// - `?1000h` — normal tracking: button press and release.
|
||||
/// - `?1002h` — button-event tracking: adds motion reports **while a button is
|
||||
/// held** (drag). This is what text selection needs; it does NOT report bare
|
||||
/// hover motion, avoiding a flood of events when the user merely moves the mouse.
|
||||
/// - `?1006h` — SGR extended coordinates, so columns/rows past 223 are reported
|
||||
/// correctly and release events are distinguishable.
|
||||
const ENABLE_MOUSE_CAPTURE_SEQ: &[u8] = b"\x1b[?1000h\x1b[?1002h\x1b[?1006h";
|
||||
/// Disable sequence — the same modes reset, in reverse order.
|
||||
const DISABLE_MOUSE_CAPTURE_SEQ: &[u8] = b"\x1b[?1006l\x1b[?1002l\x1b[?1000l";
|
||||
|
||||
/// Enable mouse reporting on the terminal (stderr — the same stream the TUI
|
||||
/// renders to). Idempotent: re-enabling already-enabled modes is a no-op for
|
||||
/// the terminal.
|
||||
pub(crate) fn enable_mouse_capture() -> std::io::Result<()> {
|
||||
let mut stderr = std::io::stderr();
|
||||
stderr.write_all(ENABLE_MOUSE_CAPTURE_SEQ)?;
|
||||
stderr.flush()
|
||||
}
|
||||
|
||||
/// Disable mouse reporting on the terminal. Safe to call even when capture was
|
||||
/// never enabled (the terminal ignores resets for modes that aren't set), so we
|
||||
/// can call it unconditionally on every teardown path.
|
||||
pub(crate) fn disable_mouse_capture() -> std::io::Result<()> {
|
||||
let mut stderr = std::io::stderr();
|
||||
stderr.write_all(DISABLE_MOUSE_CAPTURE_SEQ)?;
|
||||
stderr.flush()
|
||||
}
|
||||
|
||||
/// Drain any pending input from stdin to prevent escape sequence leakage.
|
||||
/// This consumes terminal responses (e.g., from OSC color queries) that may
|
||||
/// arrive after a query was sent but before the response was fully read.
|
||||
@@ -266,6 +300,9 @@ impl Tui {
|
||||
CrosstermEvent::Paste(s) => {
|
||||
_event_tx.send(Event::Paste(s)).unwrap();
|
||||
},
|
||||
CrosstermEvent::Mouse(mouse) => {
|
||||
_event_tx.send(Event::Mouse(mouse)).unwrap();
|
||||
},
|
||||
_ => {
|
||||
debug!("Unhandled Crossterm Event: {:?}", evt);
|
||||
continue;
|
||||
@@ -361,6 +398,10 @@ impl Tui {
|
||||
match mode {
|
||||
TuiMode::FullScreen => {
|
||||
execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?;
|
||||
// Capture the mouse only in fullscreen. This enables scroll-wheel,
|
||||
// click and drag handling, at the cost of the terminal's own
|
||||
// click-drag text selection (we provide in-app selection instead).
|
||||
enable_mouse_capture()?;
|
||||
}
|
||||
TuiMode::Inline => {
|
||||
// Inline terminal must exist (created upfront if stdin is TTY)
|
||||
@@ -369,6 +410,10 @@ impl Tui {
|
||||
"Cannot enter inline mode: inline terminal not available (stdin is not a TTY)"
|
||||
));
|
||||
}
|
||||
// Inline mode intentionally does NOT capture the mouse: the inline
|
||||
// viewport occupies a moving sub-region of the normal scrollback,
|
||||
// so absolute mouse coordinates don't map to widgets, and leaving
|
||||
// the mouse uncaptured preserves the terminal's native selection.
|
||||
execute!(std::io::stderr(), cursor::Hide)?;
|
||||
execute!(std::io::stderr(), cursor::MoveTo(0, 0))?;
|
||||
execute!(
|
||||
@@ -394,8 +439,14 @@ impl Tui {
|
||||
if crossterm::terminal::is_raw_mode_enabled()? {
|
||||
self.flush()?;
|
||||
|
||||
// Drain pending terminal responses (e.g., OSC color query responses)
|
||||
// to prevent escape sequences from leaking to the terminal on exit
|
||||
// Disable mouse capture before anything else so the terminal stops
|
||||
// sending mouse escape sequences. Safe to call unconditionally — the
|
||||
// terminal ignores resets for modes that were never set (e.g. inline).
|
||||
let _ = disable_mouse_capture();
|
||||
|
||||
// Drain pending terminal responses (e.g., OSC color query responses,
|
||||
// and any in-flight mouse reports) to prevent escape sequences from
|
||||
// leaking to the terminal on exit
|
||||
drain_stdin();
|
||||
|
||||
// Only leave alternate screen if we're in full-screen mode
|
||||
@@ -492,6 +543,8 @@ impl Tui {
|
||||
// Clear the new terminal's buffers to force a full redraw
|
||||
self.terminal_mut().clear()?;
|
||||
execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?;
|
||||
// Capture the mouse in fullscreen (NXC-3945).
|
||||
enable_mouse_capture()?;
|
||||
// Fresh event channel + restart, mirroring reinitialize_inline_terminal.
|
||||
let (new_tx, new_rx) = mpsc::unbounded_channel();
|
||||
self.event_tx = new_tx;
|
||||
@@ -508,6 +561,8 @@ impl Tui {
|
||||
if let Err(e) = self.reinitialize_inline_terminal().await {
|
||||
// Roll the terminal back to the mode we came from so we never
|
||||
// surface a half-switched ("neither") terminal to the caller.
|
||||
// (Mouse capture is released only after this point, so the
|
||||
// fullscreen rollback still has the mouse captured.)
|
||||
self.current_mode = previous_mode;
|
||||
if previous_mode == TuiMode::FullScreen {
|
||||
let _ = execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide);
|
||||
@@ -515,6 +570,9 @@ impl Tui {
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
// Release the mouse when dropping to inline (NXC-3944) so the
|
||||
// terminal regains native scroll/selection in the inline viewport.
|
||||
disable_mouse_capture()?;
|
||||
execute!(std::io::stderr(), cursor::Hide)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,6 +392,15 @@ pub trait TuiApp: Send {
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Set a structured Nx Cloud link (display label + href URL) to display.
|
||||
///
|
||||
/// Default implementation stores the link directly in TuiState.
|
||||
/// Mode-specific implementations override this to also dispatch a UI action
|
||||
/// so the rendered TasksList picks the link up.
|
||||
fn set_cloud_link(&mut self, label: String, url: String) {
|
||||
self.state().lock().set_cloud_link(Some((label, url)));
|
||||
}
|
||||
|
||||
/// Set the run report shown in the exit-countdown popup.
|
||||
///
|
||||
/// Default implementation is a no-op; mode-specific implementations forward
|
||||
|
||||
@@ -83,6 +83,9 @@ pub struct TuiState {
|
||||
|
||||
// === Cloud Message ===
|
||||
cloud_message: Option<String>,
|
||||
/// Structured Nx Cloud link (display label, href URL), shown as a clickable
|
||||
/// label in place of the raw cloud message when set.
|
||||
cloud_link: Option<(String, String)>,
|
||||
|
||||
// === Performance Report ===
|
||||
/// Stored here (not on the per-instance popup) so it survives mode switches;
|
||||
@@ -154,6 +157,7 @@ impl TuiState {
|
||||
is_forced_shutdown: false,
|
||||
user_has_interacted: false,
|
||||
cloud_message: None,
|
||||
cloud_link: None,
|
||||
exit_summary: None,
|
||||
ui_pane_tasks: [None, None],
|
||||
ui_spacebar_mode: false,
|
||||
@@ -499,6 +503,16 @@ impl TuiState {
|
||||
self.cloud_message.as_deref()
|
||||
}
|
||||
|
||||
/// Set the structured cloud link (display label, href URL).
|
||||
pub fn set_cloud_link(&mut self, link: Option<(String, String)>) {
|
||||
self.cloud_link = link;
|
||||
}
|
||||
|
||||
/// Get the structured cloud link (if any).
|
||||
pub fn get_cloud_link(&self) -> Option<&(String, String)> {
|
||||
self.cloud_link.as_ref()
|
||||
}
|
||||
|
||||
// === UI State Methods (for mode switching persistence) ===
|
||||
|
||||
/// Save the UI state from full-screen mode for later restoration
|
||||
|
||||
@@ -93,6 +93,13 @@ export interface LifeCycle {
|
||||
appendBatchOutput?(batchId: string, output: string): void;
|
||||
|
||||
setBatchStatus?(batchId: string, status: BatchStatus): void;
|
||||
|
||||
/**
|
||||
* Set a clickable Nx Cloud link in the terminal UI: `label` is the text
|
||||
* shown, `url` is opened when it's clicked. Implemented by the TUI lifecycle;
|
||||
* callers (e.g. the Nx Cloud client) should feature-detect it.
|
||||
*/
|
||||
setCloudLink?(label: string, url: string): void | Promise<void>;
|
||||
}
|
||||
|
||||
export class CompositeLifeCycle implements LifeCycle {
|
||||
@@ -255,4 +262,12 @@ export class CompositeLifeCycle implements LifeCycle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setCloudLink(label: string, url: string): Promise<void> {
|
||||
for (let l of this.lifeCycles) {
|
||||
if (l.setCloudLink) {
|
||||
await l.setCloudLink(label, url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { prompt } from 'enquirer';
|
||||
import { join } from 'node:path';
|
||||
import { stripVTControlCharacters } from 'node:util';
|
||||
|
||||
import type { Observable } from 'rxjs';
|
||||
@@ -28,6 +27,7 @@ import { NxArgs } from '../utils/command-line-utils';
|
||||
import { handleErrors } from '../utils/handle-errors';
|
||||
import { isCI } from '../utils/is-ci';
|
||||
import { isNxCloudDisabled, isNxCloudUsed } from '../utils/nx-cloud-utils';
|
||||
import { getBundleInstallDefaultLocation } from '../nx-cloud/update-manager';
|
||||
import { logger } from '../utils/logger';
|
||||
import {
|
||||
createNxKeyLicenseeInformation,
|
||||
@@ -133,15 +133,22 @@ async function getTerminalOutputLifeCycle(
|
||||
if (isTuiEnabled()) {
|
||||
const interceptedNxCloudLogs: (string | Uint8Array<ArrayBufferLike>)[] = [];
|
||||
|
||||
// Resolve where the Nx Cloud client bundle actually loads from so the
|
||||
// stack-trace check below matches its frames. It is NOT always
|
||||
// `{workspaceRoot}/.nx/cache/cloud`: in a git worktree the cache dir is
|
||||
// shared with the main repo, and it can also be relocated via
|
||||
// NX_CACHE_DIRECTORY, a custom `cacheDirectory` in nx.json, or the lerna
|
||||
// `node_modules/.cache` location. Using the client's own resolver keeps the
|
||||
// interception working in all of those cases.
|
||||
const nxCloudClientDir = getBundleInstallDefaultLocation();
|
||||
|
||||
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')
|
||||
);
|
||||
const isNxCloudLog = stackTrace.includes(nxCloudClientDir);
|
||||
if (!isNxCloudLog) {
|
||||
return originalMethod(...args);
|
||||
}
|
||||
@@ -276,9 +283,7 @@ async function getTerminalOutputLifeCycle(
|
||||
|
||||
// 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')
|
||||
);
|
||||
const isNxCloudLog = stackTrace.includes(nxCloudClientDir);
|
||||
if (isNxCloudLog) {
|
||||
interceptedNxCloudLogs.push(chunk);
|
||||
// Do not bother to store logs with only whitespace characters, they aren't relevant for the TUI
|
||||
@@ -305,9 +310,7 @@ async function getTerminalOutputLifeCycle(
|
||||
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')
|
||||
);
|
||||
const isNxCloudLog = stackTrace.includes(nxCloudClientDir);
|
||||
if (!isNxCloudLog) {
|
||||
return originalMethod(...args);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user