From 2ac43a2bda008610abdf9ef87709a7aeaeb572c2 Mon Sep 17 00:00:00 2001 From: TimelordUK Date: Sat, 29 Aug 2026 11:44:51 +0100 Subject: [PATCH 1/4] fix(viewport): translate DataTable indices to visual positions for column widths Column widths are computed against DataView display order and cached by visual position, but most callers were looking them up with DataTable (source) indices. The two index spaces coincide only for SELECT *, where visible_columns is [0, 1, 2, ...]. Under a narrower projection - SELECT a, b, c from a wider table - visible_columns holds arbitrary source indices, so any source index >= the projected column count ran off the end of the width vector and silently fell back to DEFAULT_COL_WIDTH (15). Wide values were then truncated: a timestamp like "27/08/2026 11:59:12" rendered as "27/08/2026 11:5", and recalculating widths could not fix it because the lookup itself was out of range. Beyond the visible truncation this also skewed calculate_visible_column_indices, so horizontal packing and scroll offsets were computed from wrong widths under any projection. - Add DataView::visual_index_of_column to translate source -> visual position, accounting for virtual columns occupying visual slots. - Add ViewportManager::get_column_width_by_datatable_index for callers that legitimately hold source indices; pass the visual index directly where one was already in scope. - Rename the width-cache parameter to visual_idx, document the distinction, and warn when a lookup falls out of range instead of failing silently. Co-Authored-By: Claude Opus 5 --- src/data/data_view.rs | 31 +++ src/ui/viewport/column_width_calculator.rs | 24 ++- src/ui/viewport_manager.rs | 207 +++++++-------------- tests/main.rs | 3 + tests/projection_column_width_tests.rs | 143 ++++++++++++++ 5 files changed, 269 insertions(+), 139 deletions(-) create mode 100644 tests/projection_column_width_tests.rs diff --git a/src/data/data_view.rs b/src/data/data_view.rs index 941893e0..0cd499d3 100644 --- a/src/data/data_view.rs +++ b/src/data/data_view.rs @@ -628,6 +628,37 @@ impl DataView { self.visible_columns.clone() } + /// Translate a `DataTable` (source) column index into its visual position. + /// + /// The two index spaces only coincide when the view projects every source column in + /// source order (`SELECT *`). Under a narrower projection - `SELECT a, b, c` from a + /// wider table - `visible_columns` holds arbitrary source indices, so anything keyed + /// by visual position (column names, row values, column widths) must be looked up + /// through this translation rather than with the source index directly. + /// + /// Returns `None` if the column is not part of this view. + #[must_use] + pub fn visual_index_of_column(&self, datatable_index: usize) -> Option { + let real_position = self + .visible_columns + .iter() + .position(|&c| c == datatable_index)?; + + // Virtual columns occupy visual slots too, so shift past any that sort before + // this one. Mirrors the assembly order in `get_all_column_names`/`get_row`. + let shift = self + .virtual_columns + .iter() + .filter(|vcol| match vcol.position { + VirtualColumnPosition::Left => true, + VirtualColumnPosition::Index(idx) => idx <= real_position, + VirtualColumnPosition::Right => false, + }) + .count(); + + Some(real_position + shift) + } + /// Get display column names in order (pinned first, then visible) #[must_use] pub fn get_display_column_names(&self) -> Vec { diff --git a/src/ui/viewport/column_width_calculator.rs b/src/ui/viewport/column_width_calculator.rs index e8c38800..229f4210 100644 --- a/src/ui/viewport/column_width_calculator.rs +++ b/src/ui/viewport/column_width_calculator.rs @@ -147,21 +147,37 @@ impl ColumnWidthCalculator { } } - /// Get cached column width for a specific `DataTable` column index + /// Get cached column width for a column's **visual position**. + /// + /// Widths are computed against `DataView`'s display order, so `visual_idx` must be a + /// visual position - not a `DataTable` source index. The two only coincide when the + /// view projects every source column in order; under a narrower projection a source + /// index runs off the end of the cache and silently returns `DEFAULT_COL_WIDTH`, + /// which truncates wide values. Callers holding a source index should translate it + /// via `DataView::visual_index_of_column` first. pub fn get_column_width( &mut self, dataview: &DataView, viewport_rows: &std::ops::Range, - col_idx: usize, + visual_idx: usize, ) -> u16 { if self.cache_dirty { self.recalculate_column_widths(dataview, viewport_rows); } self.column_widths - .get(col_idx) + .get(visual_idx) .copied() - .unwrap_or(DEFAULT_COL_WIDTH) + .unwrap_or_else(|| { + tracing::warn!( + target: "viewport_manager", + "get_column_width: visual index {} out of range ({} columns) - likely a DataTable index used as a visual position; falling back to {}w", + visual_idx, + self.column_widths.len(), + DEFAULT_COL_WIDTH + ); + DEFAULT_COL_WIDTH + }) } /// Get all cached column widths, ensuring they're up to date diff --git a/src/ui/viewport_manager.rs b/src/ui/viewport_manager.rs index 6a14f723..094a8d14 100644 --- a/src/ui/viewport_manager.rs +++ b/src/ui/viewport_manager.rs @@ -13,12 +13,12 @@ /// → Renderer (pixels on screen) use std::ops::Range; use std::sync::Arc; -use tracing::debug; +use tracing::{debug, warn}; use crate::data::data_view::DataView; use crate::data::datatable::DataRow; use crate::ui::viewport::column_width_calculator::{ - COLUMN_PADDING, MAX_COL_WIDTH, MAX_COL_WIDTH_DATA_FOCUS, MIN_COL_WIDTH, + COLUMN_PADDING, DEFAULT_COL_WIDTH, MAX_COL_WIDTH, MAX_COL_WIDTH_DATA_FOCUS, MIN_COL_WIDTH, }; use crate::ui::viewport::{ColumnPackingMode, ColumnWidthCalculator}; @@ -750,10 +750,29 @@ impl ViewportManager { .get_all_column_widths(&self.dataview, &self.viewport_rows) } - /// Get column width for a specific column - pub fn get_column_width(&mut self, col_idx: usize) -> u16 { + /// Get column width for a specific column, by **visual** position. + pub fn get_column_width(&mut self, visual_idx: usize) -> u16 { self.width_calculator - .get_column_width(&self.dataview, &self.viewport_rows, col_idx) + .get_column_width(&self.dataview, &self.viewport_rows, visual_idx) + } + + /// Get column width for a column identified by its **`DataTable` (source)** index. + /// + /// `ColumnWidthCalculator` keys its cache by visual position, so a source index has + /// to be translated first. Passing a source index straight through reads past the + /// end of the width vector under any projection narrower than the source table, and + /// silently yields `DEFAULT_COL_WIDTH` - which truncates wide values such as + /// timestamps to 15 characters. + fn get_column_width_by_datatable_index(&mut self, datatable_idx: usize) -> u16 { + let Some(visual_idx) = self.dataview.visual_index_of_column(datatable_idx) else { + warn!(target: "viewport_manager", + "get_column_width_by_datatable_index: DataTable column {} is not in this view, using default width", + datatable_idx); + return DEFAULT_COL_WIDTH; + }; + + self.width_calculator + .get_column_width(&self.dataview, &self.viewport_rows, visual_idx) } /// Get visible rows in the current viewport @@ -899,11 +918,7 @@ impl ViewportManager { } let datatable_idx = display_columns[visual_idx]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - datatable_idx, - ); + let width = self.get_column_width(visual_idx); // Always include pinned columns, even if they exceed available width used_width += width + separator_width; @@ -939,11 +954,7 @@ impl ViewportManager { // Get the DataTable index for this visual position let datatable_idx = display_columns[visual_idx]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - datatable_idx, - ); + let width = self.get_column_width(visual_idx); if used_width + width + separator_width <= available_width { used_width += width + separator_width; @@ -999,15 +1010,15 @@ impl ViewportManager { column_count.max(1) // Always show at least one column } - /// Get calculated widths for specific columns - /// This is useful for rendering when we know which columns will be displayed - pub fn get_column_widths_for(&mut self, column_indices: &[usize]) -> Vec { - column_indices + /// Get calculated widths for specific columns, by **visual** position. + /// This is useful for rendering when we know which columns will be displayed. + /// + /// Note: `calculate_visible_column_indices` returns `DataTable` indices, not visual + /// positions - feed those to `get_column_width_by_datatable_index` instead. + pub fn get_column_widths_for(&mut self, visual_indices: &[usize]) -> Vec { + visual_indices .iter() - .map(|&idx| { - self.width_calculator - .get_column_width(&self.dataview, &self.viewport_rows, idx) - }) + .map(|&visual_idx| self.get_column_width(visual_idx)) .collect() } @@ -1048,18 +1059,15 @@ impl ViewportManager { return 0; } - let pinned = self.dataview.get_pinned_columns(); + // Owned copy: the width lookups below take &mut self + let pinned = self.dataview.get_pinned_columns().to_vec(); let _pinned_count = pinned.len(); // Calculate how much width is used by pinned columns let mut pinned_width = 0u16; let separator_width = 1u16; - for &col_idx in pinned { - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + for &col_idx in &pinned { + let width = self.get_column_width_by_datatable_index(col_idx); pinned_width += width + separator_width; } @@ -1079,11 +1087,7 @@ impl ViewportManager { // Get the last scrollable column let last_col_idx = *scrollable_columns.last().unwrap(); - let last_col_width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - last_col_idx, - ); + let last_col_width = self.get_column_width_by_datatable_index(last_col_idx); tracing::debug!( "Starting calculation: last_col_idx={}, width={}w, available={}w, scrollable_cols={}", @@ -1098,11 +1102,7 @@ impl ViewportManager { // Now work backwards through scrollable columns to find how many more we can fit for (idx, &col_idx) in scrollable_columns.iter().enumerate().rev().skip(1) { - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + let width = self.get_column_width_by_datatable_index(col_idx); let width_with_separator = width + separator_width; @@ -1142,11 +1142,7 @@ impl ViewportManager { let mut can_see_last = false; for idx in best_offset..scrollable_columns.len() { let col_idx = scrollable_columns[idx]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + let width = self.get_column_width_by_datatable_index(col_idx); test_width += width + separator_width; if test_width > available_for_scrollable { @@ -1173,11 +1169,7 @@ impl ViewportManager { test_width = 0; for idx in best_offset..scrollable_columns.len() { let col_idx = scrollable_columns[idx]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + let width = self.get_column_width_by_datatable_index(col_idx); test_width += width + separator_width; if test_width > available_for_scrollable { @@ -1210,7 +1202,8 @@ impl ViewportManager { output.push_str("========== VIEWPORT MANAGER DEBUG ==========\n"); let total_cols = self.dataview.column_count(); - let pinned = self.dataview.get_pinned_columns(); + // Owned copy: the width lookups below take &mut self + let pinned = self.dataview.get_pinned_columns().to_vec(); let pinned_count = pinned.len(); output.push_str(&format!("Total columns: {total_cols}\n")); @@ -1326,12 +1319,8 @@ impl ViewportManager { // Calculate available width for scrollable columns let separator_width = 1u16; let mut pinned_width = 0u16; - for &col_idx in pinned { - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + for &col_idx in &pinned { + let width = self.get_column_width_by_datatable_index(col_idx); pinned_width += width + separator_width; } let available_for_scrollable = available_width.saturating_sub(pinned_width); @@ -1505,11 +1494,7 @@ impl ViewportManager { for &col_idx in &visible_indices { x_positions.push(current_x); - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + let width = self.get_column_width_by_datatable_index(col_idx); current_x += width + separator_width; } @@ -1543,11 +1528,7 @@ impl ViewportManager { // Process columns in DataView's order (pinned first, then display order) for &col_idx in &ordered_columns { - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + let width = self.get_column_width_by_datatable_index(col_idx); if used_width + width + separator_width <= available_width { visible_indices.push(col_idx); @@ -1709,10 +1690,7 @@ impl ViewportManager { // Get the actual calculated widths for the visible columns let widths: Vec = visible_column_indices .iter() - .map(|&dt_idx| { - self.width_calculator - .get_column_width(&self.dataview, &self.viewport_rows, dt_idx) - }) + .map(|&dt_idx| self.get_column_width_by_datatable_index(dt_idx)) .collect(); debug!(target: "viewport_manager", @@ -1807,11 +1785,7 @@ impl ViewportManager { let separator_width = 1u16; for &col_idx in &visible_indices { - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + let width = self.get_column_width_by_datatable_index(col_idx); used_width += width + separator_width; } @@ -1822,20 +1796,18 @@ impl ViewportManager { let wasted_space = available_width.saturating_sub(used_width); - // Find the next column that didn't fit - let next_column_width = if visible_indices.is_empty() { - None - } else { - let last_visible = *visible_indices.last().unwrap(); - if last_visible + 1 < self.dataview.column_count() { - Some(self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - last_visible + 1, - )) - } else { - None + // visible_indices holds DataTable indices; widths are keyed by visual position + let visible_visual: Vec = visible_indices + .iter() + .filter_map(|&dt_idx| self.dataview.visual_index_of_column(dt_idx)) + .collect(); + + // Find the next column that didn't fit (the one after the last visible, visually) + let next_column_width = match visible_visual.last() { + Some(&last_visual) if last_visual + 1 < self.dataview.column_count() => { + Some(self.get_column_width(last_visual + 1)) } + _ => None, }; // Find ALL columns that COULD fit in the wasted space @@ -1846,7 +1818,7 @@ impl ViewportManager { .get_all_column_widths(&self.dataview, &self.viewport_rows); for (idx, &width) in all_widths.iter().enumerate() { // Skip already visible columns - if !visible_indices.contains(&idx) && width + separator_width <= wasted_space { + if !visible_visual.contains(&idx) && width + separator_width <= wasted_space { columns_that_could_fit.push((idx, width)); } } @@ -1866,10 +1838,7 @@ impl ViewportManager { visible_columns: visible_indices.len(), column_widths: visible_indices .iter() - .map(|&idx| { - self.width_calculator - .get_column_width(&self.dataview, &self.viewport_rows, idx) - }) + .map(|&idx| self.get_column_width_by_datatable_index(idx)) .collect(), next_column_width, columns_that_could_fit, @@ -1979,13 +1948,8 @@ impl ViewportManager { // Calculate pinned width let mut pinned_width = 0u16; - for i in 0..pinned_count { - let col_idx = display_columns[i]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + for visual_idx in 0..pinned_count { + let width = self.get_column_width(visual_idx); pinned_width += width + 3; // separator width } @@ -1997,12 +1961,7 @@ impl ViewportManager { // Work backwards from the last column to find the best scroll position for visual_idx in (pinned_count..=last_visual_column).rev() { - let col_idx = display_columns[visual_idx]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + let width = self.get_column_width(visual_idx); accumulated_width += width + 3; // separator width if accumulated_width > available_for_scrollable { @@ -3502,10 +3461,8 @@ impl ViewportManager { // This needs to calculate based on visual columns let display_columns = self.dataview.get_display_columns(); let mut total_width_needed = 0u16; - for &dt_idx in &display_columns { - let width = - self.width_calculator - .get_column_width(&self.dataview, &self.viewport_rows, dt_idx); + for visual_idx in 0..display_columns.len() { + let width = self.get_column_width(visual_idx); total_width_needed += width + 1; // +1 for separator } @@ -3575,12 +3532,7 @@ impl ViewportManager { // First account for pinned column widths for visual_idx in 0..pinned_count { if visual_idx < display_columns.len() { - let dt_idx = display_columns[visual_idx]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - dt_idx, - ); + let width = self.get_column_width(visual_idx); used_width += width + separator_width; } } @@ -3590,12 +3542,7 @@ impl ViewportManager { let visual_start = pinned_count + new_scroll_offset; for visual_idx in visual_start..display_columns.len() { - let dt_idx = display_columns[visual_idx]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - dt_idx, - ); + let width = self.get_column_width(visual_idx); if used_width + width + separator_width <= terminal_width { used_width += width + separator_width; scrollable_columns_that_fit += 1; @@ -3674,12 +3621,7 @@ impl ViewportManager { for visual_idx in 0..pinned_count { if visual_idx < display_columns.len() { - let dt_idx = display_columns[visual_idx]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - dt_idx, - ); + let width = self.get_column_width(visual_idx); pinned_width += width + separator_width; } } @@ -3719,12 +3661,7 @@ impl ViewportManager { for test_scrollable_idx in test_scroll_offset..max_scrollable_columns { let visual_idx = pinned_count + test_scrollable_idx; if visual_idx < display_columns.len() { - let dt_idx = display_columns[visual_idx]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - dt_idx, - ); + let width = self.get_column_width(visual_idx); if used_width + width + separator_width <= available_for_scrollable { used_width += width + separator_width; diff --git a/tests/main.rs b/tests/main.rs index 602ddd44..0609d904 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -216,3 +216,6 @@ mod tui_integration_test; #[path = "viewport_manager_test.rs"] mod viewport_manager_test; + +#[path = "projection_column_width_tests.rs"] +mod projection_column_width_tests; diff --git a/tests/projection_column_width_tests.rs b/tests/projection_column_width_tests.rs new file mode 100644 index 00000000..f631f83e --- /dev/null +++ b/tests/projection_column_width_tests.rs @@ -0,0 +1,143 @@ +//! Regression tests for column widths under a narrower-than-source projection. +//! +//! `DataView` projections keep the original `DataTable` and record source column +//! indices in `visible_columns`. Anything keyed by visual position - notably the +//! column width cache - must translate those indices first. Using a source index +//! directly reads past the end of the width vector and silently falls back to +//! DEFAULT_COL_WIDTH (15), truncating wide values such as "27/08/2026 11:59:12". + +use std::sync::Arc; + +use sql_cli::data::data_view::DataView; +use sql_cli::data::datatable::{DataColumn, DataRow, DataTable, DataValue}; +use sql_cli::ui::viewport_manager::ViewportManager; + +const STARTED: &str = "27/08/2026 11:59:12"; +const FINISHED: &str = "27/08/2026 12:04:31"; + +/// A wide export (like a TeamCity build API dump) where the interesting columns +/// sit past the width of the projection that selects them. +fn wide_source_table() -> DataTable { + let headers = [ + "BuildTypeId", + "Number", + "State", + "Status", + "StatusText", + "Branch", + "Agent", + "TriggeredBy", + "Comment", + "Project", + "Job", + "JobId", + "Started", + "Finished", + "DurationSec", + ]; + + let mut table = DataTable::new("tc"); + for header in headers { + table.add_column(DataColumn::new(header)); + } + + for i in 0..20 { + table + .add_row(DataRow::new(vec![ + DataValue::String(format!("bt_{i}")), + DataValue::String(i.to_string()), + DataValue::String("finished".into()), + DataValue::String("SUCCESS".into()), + DataValue::String("Success".into()), + DataValue::String("main".into()), + DataValue::String(format!("agent-{i}")), + DataValue::String("scheduler".into()), + DataValue::String(String::new()), + DataValue::String("ServerOps".into()), + DataValue::String("DeployUpdate".into()), + DataValue::String(format!("job-{i}")), + DataValue::String(STARTED.into()), + DataValue::String(FINISHED.into()), + DataValue::String("319".into()), + ])) + .unwrap(); + } + + table +} + +/// SELECT Project, Job, JobId, Started, Finished, DurationSec FROM tc +fn projected_view() -> DataView { + DataView::new(Arc::new(wide_source_table())).with_columns(vec![9, 10, 11, 12, 13, 14]) +} + +#[test] +fn visual_index_of_column_maps_source_indices_to_display_positions() { + let view = projected_view(); + + assert_eq!(view.visual_index_of_column(9), Some(0), "Project"); + assert_eq!(view.visual_index_of_column(12), Some(3), "Started"); + assert_eq!(view.visual_index_of_column(14), Some(5), "DurationSec"); + + // Columns outside the projection have no visual position + assert_eq!(view.visual_index_of_column(0), None, "BuildTypeId"); + assert_eq!(view.visual_index_of_column(99), None, "out of range"); +} + +#[test] +fn projected_datetime_columns_are_not_truncated() { + let mut vm = ViewportManager::new(Arc::new(projected_view())); + vm.update_terminal_size(200, 30); + + let (headers, _rows, widths) = vm.get_visual_display(200, &[]); + + for (name, value) in [("Started", STARTED), ("Finished", FINISHED)] { + let pos = headers + .iter() + .position(|h| h == name) + .unwrap_or_else(|| panic!("{name} column missing from {headers:?}")); + + assert!( + widths[pos] >= value.len() as u16, + "{name} width {} truncates {value:?} ({} chars); widths={widths:?}", + widths[pos], + value.len() + ); + } +} + +#[test] +fn projected_widths_match_the_equivalent_unprojected_view() { + // The same six columns, but as the only columns in the source table, so that + // visual and DataTable indices coincide. Widths must agree either way. + let mut narrow = DataTable::new("tc_narrow"); + for header in ["Project", "Job", "JobId", "Started", "Finished", "DurationSec"] { + narrow.add_column(DataColumn::new(header)); + } + for i in 0..20 { + narrow + .add_row(DataRow::new(vec![ + DataValue::String("ServerOps".into()), + DataValue::String("DeployUpdate".into()), + DataValue::String(format!("job-{i}")), + DataValue::String(STARTED.into()), + DataValue::String(FINISHED.into()), + DataValue::String("319".into()), + ])) + .unwrap(); + } + + let mut wide_vm = ViewportManager::new(Arc::new(projected_view())); + wide_vm.update_terminal_size(200, 30); + let (wide_headers, _, wide_widths) = wide_vm.get_visual_display(200, &[]); + + let mut narrow_vm = ViewportManager::new(Arc::new(DataView::new(Arc::new(narrow)))); + narrow_vm.update_terminal_size(200, 30); + let (narrow_headers, _, narrow_widths) = narrow_vm.get_visual_display(200, &[]); + + assert_eq!(wide_headers, narrow_headers); + assert_eq!( + wide_widths, narrow_widths, + "projection changed column widths: {wide_widths:?} vs {narrow_widths:?}" + ); +} From 0d50a41645d57f9a790f9649111be9ca42aba4ba Mon Sep 17 00:00:00 2001 From: TimelordUK Date: Sat, 29 Aug 2026 12:00:26 +0100 Subject: [PATCH 2/4] fix(tui): stop writing to stderr while the alternate screen is active Entering history search (Ctrl+R) printed two unconditional debug lines straight to stderr: enhanced_tui.rs "[DEBUG] Using AppStateContainer for history search" app_state_container "[DEBUG] Created N matches in history_search" The TUI owns the terminal, so each line scrolls the display out from under ratatui. Its renderer diffs against what it believes is on screen, so every subsequent frame repaints only changed cells onto a display that has shifted - leaving artifacts that persist until something forces a full repaint. Opening the F5 debug view and escaping back happened to do exactly that, which is why it "fixed" the display. Convert these and the [History] diagnostics in history.rs to tracing, so they reach the log file and the F5 debug view instead of the screen. Guard against recurrence with #![deny(clippy::print_stdout, clippy::print_stderr)] on the ui module tree and on app_state_container, both of which run with the alternate screen active. CI already runs clippy, so a stray print now fails the build rather than silently corrupting the display. Co-Authored-By: Claude Opus 5 --- src/app_state_container.rs | 12 ++++++++--- src/history.rs | 44 +++++++++++++++++++++----------------- src/ui/enhanced_tui.rs | 6 +++--- src/ui/mod.rs | 7 ++++++ 4 files changed, 43 insertions(+), 26 deletions(-) diff --git a/src/app_state_container.rs b/src/app_state_container.rs index 1fb3e251..4b12ad02 100644 --- a/src/app_state_container.rs +++ b/src/app_state_container.rs @@ -1,3 +1,8 @@ +//! Shared application state driven by the TUI. +//! +//! Like `crate::ui`, this must not write to stdout/stderr - see `ui/mod.rs`. +#![deny(clippy::print_stdout, clippy::print_stderr)] + use crate::api_client::QueryResponse; use crate::buffer::{AppMode, BufferAPI, BufferManager, SortOrder}; use crate::debug_service::DebugLevel; @@ -17,7 +22,7 @@ use std::cell::RefCell; use std::collections::{HashMap, VecDeque}; use std::fmt; use std::time::{Duration, Instant}; -use tracing::{info, trace}; +use tracing::{debug, info, trace}; /// Platform type for key handling #[derive(Debug, Clone, PartialEq)] @@ -3607,8 +3612,9 @@ impl AppStateContainer { }) .collect(); - eprintln!( - "[DEBUG] Created {} matches in history_search", + debug!( + target: "history", + "Created {} matches in history_search", history_search.matches.len() ); diff --git a/src/history.rs b/src/history.rs index 72803ea2..e092a44d 100644 --- a/src/history.rs +++ b/src/history.rs @@ -5,6 +5,7 @@ use chrono::{DateTime, Utc}; use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; use serde::{Deserialize, Serialize}; +use tracing::{debug, error, info, warn}; use std::collections::HashMap; use std::fs; use std::path::PathBuf; @@ -493,7 +494,7 @@ impl CommandHistory { // SAFETY: Create backup before clearing let current_count = self.entries.len(); if current_count > 0 { - eprintln!("[HISTORY WARNING] Clearing {current_count} entries - creating backup"); + warn!(target: "history", "Clearing {current_count} entries - creating backup"); if let Ok(content) = serde_json::to_string_pretty(&self.entries) { self.protection.backup_before_write(&content, current_count); } @@ -506,14 +507,14 @@ impl CommandHistory { fn load_from_file(&mut self) -> Result<()> { if !self.history_file.exists() { - eprintln!("[History] No history file found at {:?}", self.history_file); + debug!(target: "history", "No history file found at {:?}", self.history_file); return Ok(()); } - eprintln!("[History] Loading history from {:?}", self.history_file); + debug!(target: "history", "Loading history from {:?}", self.history_file); let content = fs::read_to_string(&self.history_file)?; if content.trim().is_empty() { - eprintln!("[History] History file is empty"); + debug!(target: "history", "History file is empty"); return Ok(()); } @@ -521,18 +522,19 @@ impl CommandHistory { let entries: Vec = match serde_json::from_str(&content) { Ok(entries) => entries, Err(e) => { - eprintln!("[History] ERROR: Failed to parse history file: {e}"); - eprintln!("[History] Attempting recovery from backup..."); + error!(target: "history", "Failed to parse history file: {e}"); + warn!(target: "history", "Attempting recovery from backup..."); // Try to recover from backup if let Some(backup_content) = self.protection.recover_from_backup() { - eprintln!("[History] Found backup, attempting to restore..."); + warn!(target: "history", "Found backup, attempting to restore..."); // Try to parse the backup match serde_json::from_str::>(&backup_content) { Ok(backup_entries) => { - eprintln!( - "[History] Successfully recovered {} entries from backup", + warn!( + target: "history", + "Successfully recovered {} entries from backup", backup_entries.len() ); @@ -545,30 +547,31 @@ impl CommandHistory { self.history_file.with_extension("json"), &corrupted_path, ); - eprintln!("[History] Corrupted file moved to {corrupted_path:?}"); + warn!(target: "history", "Corrupted file moved to {corrupted_path:?}"); backup_entries } Err(backup_err) => { - eprintln!("[History] Backup also corrupted: {backup_err}"); - eprintln!("[History] Starting with empty history"); + error!(target: "history", "Backup also corrupted: {backup_err}"); + warn!(target: "history", "Starting with empty history"); Vec::new() } } } else { - eprintln!("[History] No backup available, starting with empty history"); + warn!(target: "history", "No backup available, starting with empty history"); // Move the corrupted file for investigation let corrupted_path = self.history_file.with_extension("json.corrupted"); let _ = fs::copy(&self.history_file, &corrupted_path); - eprintln!("[History] Corrupted file copied to {corrupted_path:?}"); + warn!(target: "history", "Corrupted file copied to {corrupted_path:?}"); Vec::new() } } }; - eprintln!( - "[History] Loaded {} entries from history file", + debug!( + target: "history", + "Loaded {} entries from history file", entries.len() ); let original_count = entries.len(); @@ -594,7 +597,7 @@ impl CommandHistory { // Log if we removed duplicates (only on first load, not every save) let removed_count = original_count - deduplicated.len(); if removed_count > 0 { - eprintln!("[sql-cli] Cleaned {removed_count} duplicate commands from history"); + info!(target: "history", "Cleaned {removed_count} duplicate commands from history"); } // Rebuild command counts @@ -607,8 +610,9 @@ impl CommandHistory { } self.entries = deduplicated; - eprintln!( - "[History] Final history contains {} unique entries", + debug!( + target: "history", + "Final history contains {} unique entries", self.entries.len() ); Ok(()) @@ -635,7 +639,7 @@ impl CommandHistory { .protection .validate_write(old_count, new_count, &new_content) { - eprintln!("[HISTORY PROTECTION] Write blocked! Attempting recovery from backup..."); + error!(target: "history", "Write blocked! Attempting recovery from backup..."); if let Some(backup_content) = self.protection.recover_from_backup() { fs::write(&self.history_file, backup_content)?; return Ok(()); diff --git a/src/ui/enhanced_tui.rs b/src/ui/enhanced_tui.rs index 26b14be8..c7d272a7 100644 --- a/src/ui/enhanced_tui.rs +++ b/src/ui/enhanced_tui.rs @@ -2287,7 +2287,7 @@ impl EnhancedTuiApp { // Special handling for History mode - initialize history search if mode == AppMode::History { - eprintln!("[DEBUG] Using AppStateContainer for history search"); + debug!(target: "history", "Using AppStateContainer for history search"); let current_input = self.get_input_text(); // Start history search @@ -4895,8 +4895,8 @@ impl EnhancedTuiApp { static FILTER_DEPTH: AtomicUsize = AtomicUsize::new(0); let depth = FILTER_DEPTH.fetch_add(1, Ordering::SeqCst); if depth > 0 { - eprintln!( - "WARNING: apply_filter re-entrancy detected! depth={}, pattern='{}', thread={:?}", + warn!( + "apply_filter re-entrancy detected! depth={}, pattern='{}', thread={:?}", depth, pattern, std::thread::current().id() diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a155d373..b7e50fd6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,6 +1,13 @@ //! User interface layer //! //! This module contains the main TUI application and related UI components. +//! +//! Nothing here may write to stdout/stderr directly. The TUI owns the terminal +//! via the alternate screen, and a stray `println!`/`eprintln!` injects a line +//! that scrolls the display out from under ratatui's diff - leaving artifacts +//! until something forces a full repaint. Use `tracing` instead; those records +//! reach the log file and the F5 debug view. +#![deny(clippy::print_stdout, clippy::print_stderr)] pub mod behaviors; pub mod debug; From 8ac516936c7d33ea930e9ec2fb5affc91fafa9a7 Mon Sep 17 00:00:00 2001 From: TimelordUK Date: Sat, 29 Aug 2026 12:14:14 +0100 Subject: [PATCH 3/4] feat(tui): add Ctrl+L redraw, fix navigation debug index, add sample data Ctrl+L now forces a full repaint - the universal terminal convention, and an escape hatch for display corruption from anything outside our control (a resize race, a stray write from a dependency, ssh noise). It sits in the global key map so it works in every mode, and clears ratatui's screen buffer so the next frame repaints every cell instead of diffing against a stale one. Ctrl+L was free: viewport lock is bound to Space and Ctrl+Space, never Ctrl+L. The help text in help_widget.rs claiming otherwise was simply stale, and is corrected here alongside the new binding. The navigation debug panel searched the display-column list for current_column, but current_column is a visual position while that list holds DataTable indices - so any projection reported "WARNING: Current column 0 not found in display order!" against a perfectly healthy view. It now indexes directly and reports both spaces, which is the useful thing to see when debugging exactly this class of confusion. Also adds data/tc_builds_sample.csv: a wide TeamCity-shaped export whose timestamp columns sit past the width of a typical projection over it. This is the shape that surfaced the column-width and redraw bugs. Co-Authored-By: Claude Opus 5 --- data/tc_builds_sample.csv | 201 ++++++++++++++++++++++++++++++++ src/help_text.rs | 1 + src/ui/enhanced_tui.rs | 42 ++++--- src/ui/input/action_handlers.rs | 10 ++ src/ui/input/actions.rs | 1 + src/ui/key_handling/mapper.rs | 42 +++++++ src/widgets/help_widget.rs | 4 +- 7 files changed, 286 insertions(+), 15 deletions(-) create mode 100644 data/tc_builds_sample.csv diff --git a/data/tc_builds_sample.csv b/data/tc_builds_sample.csv new file mode 100644 index 00000000..639d94e1 --- /dev/null +++ b/data/tc_builds_sample.csv @@ -0,0 +1,201 @@ +BuildTypeId,Number,State,Status,StatusText,Branch,Agent,TriggeredBy,Comment,Project,Job,JobId,Started,Finished,DurationSec +Srv_Deploy_0,1000,finished,FAILURE,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4000,27/08/2026 09:00:00,27/08/2026 09:10:06,606 +Srv_Deploy_1,1001,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4001,27/08/2026 09:02:17,27/08/2026 09:05:46,209 +Srv_Deploy_2,1002,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4002,27/08/2026 09:04:34,27/08/2026 09:05:53,79 +Srv_Deploy_3,1003,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4003,27/08/2026 09:06:51,27/08/2026 09:20:31,820 +Srv_Deploy_4,1004,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4004,27/08/2026 09:09:08,27/08/2026 09:17:42,514 +Srv_Deploy_5,1005,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4005,27/08/2026 09:11:25,27/08/2026 09:24:52,807 +Srv_Deploy_6,1006,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4006,27/08/2026 09:13:42,27/08/2026 09:23:03,561 +Srv_Deploy_0,1007,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4007,27/08/2026 09:15:59,27/08/2026 09:23:03,424 +Srv_Deploy_1,1008,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4008,27/08/2026 09:18:16,27/08/2026 09:31:14,778 +Srv_Deploy_2,1009,finished,FAILURE,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4009,27/08/2026 09:20:33,27/08/2026 09:24:00,207 +Srv_Deploy_3,1010,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4010,27/08/2026 09:22:50,27/08/2026 09:27:08,258 +Srv_Deploy_4,1011,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4011,27/08/2026 09:25:07,27/08/2026 09:37:50,763 +Srv_Deploy_5,1012,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4012,27/08/2026 09:27:24,27/08/2026 09:29:49,145 +Srv_Deploy_6,1013,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4013,27/08/2026 09:29:41,27/08/2026 09:34:51,310 +Srv_Deploy_0,1014,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4014,27/08/2026 09:31:58,27/08/2026 09:44:30,752 +Srv_Deploy_1,1015,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4015,27/08/2026 09:34:15,27/08/2026 09:40:14,359 +Srv_Deploy_2,1016,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4016,27/08/2026 09:36:32,27/08/2026 09:49:02,750 +Srv_Deploy_3,1017,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4017,27/08/2026 09:38:49,27/08/2026 09:49:52,663 +Srv_Deploy_4,1018,finished,FAILURE,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4018,27/08/2026 09:41:06,27/08/2026 09:55:51,885 +Srv_Deploy_5,1019,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4019,27/08/2026 09:43:23,27/08/2026 09:46:52,209 +Srv_Deploy_6,1020,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4020,27/08/2026 09:45:40,27/08/2026 09:56:30,650 +Srv_Deploy_0,1021,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4021,27/08/2026 09:47:57,27/08/2026 09:55:24,447 +Srv_Deploy_1,1022,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4022,27/08/2026 09:50:14,27/08/2026 09:56:32,378 +Srv_Deploy_2,1023,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4023,27/08/2026 09:52:31,27/08/2026 10:06:48,857 +Srv_Deploy_3,1024,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4024,27/08/2026 09:54:48,27/08/2026 10:08:16,808 +Srv_Deploy_4,1025,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4025,27/08/2026 09:57:05,27/08/2026 10:06:01,536 +Srv_Deploy_5,1026,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4026,27/08/2026 09:59:22,27/08/2026 10:05:41,379 +Srv_Deploy_6,1027,finished,FAILURE,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4027,27/08/2026 10:01:39,27/08/2026 10:13:35,716 +Srv_Deploy_0,1028,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4028,27/08/2026 10:03:56,27/08/2026 10:16:43,767 +Srv_Deploy_1,1029,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4029,27/08/2026 10:06:13,27/08/2026 10:16:01,588 +Srv_Deploy_2,1030,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4030,27/08/2026 10:08:30,27/08/2026 10:17:33,543 +Srv_Deploy_3,1031,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4031,27/08/2026 10:10:47,27/08/2026 10:12:56,129 +Srv_Deploy_4,1032,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4032,27/08/2026 10:13:04,27/08/2026 10:27:07,843 +Srv_Deploy_5,1033,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4033,27/08/2026 10:15:21,27/08/2026 10:19:09,228 +Srv_Deploy_6,1034,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4034,27/08/2026 10:17:38,27/08/2026 10:19:14,96 +Srv_Deploy_0,1035,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4035,27/08/2026 10:19:55,27/08/2026 10:22:27,152 +Srv_Deploy_1,1036,finished,FAILURE,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4036,27/08/2026 10:22:12,27/08/2026 10:33:00,648 +Srv_Deploy_2,1037,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4037,27/08/2026 10:24:29,27/08/2026 10:31:37,428 +Srv_Deploy_3,1038,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4038,27/08/2026 10:26:46,27/08/2026 10:29:17,151 +Srv_Deploy_4,1039,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4039,27/08/2026 10:29:03,27/08/2026 10:42:54,831 +Srv_Deploy_5,1040,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4040,27/08/2026 10:31:20,27/08/2026 10:42:18,658 +Srv_Deploy_6,1041,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4041,27/08/2026 10:33:37,27/08/2026 10:39:33,356 +Srv_Deploy_0,1042,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4042,27/08/2026 10:35:54,27/08/2026 10:50:30,876 +Srv_Deploy_1,1043,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4043,27/08/2026 10:38:11,27/08/2026 10:43:23,312 +Srv_Deploy_2,1044,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4044,27/08/2026 10:40:28,27/08/2026 10:49:40,552 +Srv_Deploy_3,1045,finished,FAILURE,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4045,27/08/2026 10:42:45,27/08/2026 10:53:09,624 +Srv_Deploy_4,1046,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4046,27/08/2026 10:45:02,27/08/2026 10:59:45,883 +Srv_Deploy_5,1047,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4047,27/08/2026 10:47:19,27/08/2026 10:59:56,757 +Srv_Deploy_6,1048,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4048,27/08/2026 10:49:36,27/08/2026 10:55:45,369 +Srv_Deploy_0,1049,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4049,27/08/2026 10:51:53,27/08/2026 10:59:37,464 +Srv_Deploy_1,1050,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4050,27/08/2026 10:54:10,27/08/2026 11:02:14,484 +Srv_Deploy_2,1051,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4051,27/08/2026 10:56:27,27/08/2026 11:02:27,360 +Srv_Deploy_3,1052,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4052,27/08/2026 10:58:44,27/08/2026 11:00:46,122 +Srv_Deploy_4,1053,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4053,27/08/2026 11:01:01,27/08/2026 11:11:38,637 +Srv_Deploy_5,1054,finished,FAILURE,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4054,27/08/2026 11:03:18,27/08/2026 11:16:49,811 +Srv_Deploy_6,1055,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4055,27/08/2026 11:05:35,27/08/2026 11:14:56,561 +Srv_Deploy_0,1056,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4056,27/08/2026 11:07:52,27/08/2026 11:18:35,643 +Srv_Deploy_1,1057,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4057,27/08/2026 11:10:09,27/08/2026 11:20:59,650 +Srv_Deploy_2,1058,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4058,27/08/2026 11:12:26,27/08/2026 11:20:40,494 +Srv_Deploy_3,1059,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4059,27/08/2026 11:14:43,27/08/2026 11:23:00,497 +Srv_Deploy_4,1060,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4060,27/08/2026 11:17:00,27/08/2026 11:18:36,96 +Srv_Deploy_5,1061,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4061,27/08/2026 11:19:17,27/08/2026 11:30:08,651 +Srv_Deploy_6,1062,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4062,27/08/2026 11:21:34,27/08/2026 11:27:38,364 +Srv_Deploy_0,1063,finished,FAILURE,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4063,27/08/2026 11:23:51,27/08/2026 11:34:10,619 +Srv_Deploy_1,1064,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4064,27/08/2026 11:26:08,27/08/2026 11:41:04,896 +Srv_Deploy_2,1065,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4065,27/08/2026 11:28:25,27/08/2026 11:36:40,495 +Srv_Deploy_3,1066,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4066,27/08/2026 11:30:42,27/08/2026 11:38:08,446 +Srv_Deploy_4,1067,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4067,27/08/2026 11:32:59,27/08/2026 11:34:15,76 +Srv_Deploy_5,1068,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4068,27/08/2026 11:35:16,27/08/2026 11:37:23,127 +Srv_Deploy_6,1069,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4069,27/08/2026 11:37:33,27/08/2026 11:43:35,362 +Srv_Deploy_0,1070,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4070,27/08/2026 11:39:50,27/08/2026 11:52:20,750 +Srv_Deploy_1,1071,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4071,27/08/2026 11:42:07,27/08/2026 11:54:11,724 +Srv_Deploy_2,1072,finished,FAILURE,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4072,27/08/2026 11:44:24,27/08/2026 11:58:51,867 +Srv_Deploy_3,1073,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4073,27/08/2026 11:46:41,27/08/2026 11:48:30,109 +Srv_Deploy_4,1074,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4074,27/08/2026 11:48:58,27/08/2026 11:59:03,605 +Srv_Deploy_5,1075,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4075,27/08/2026 11:51:15,27/08/2026 12:03:57,762 +Srv_Deploy_6,1076,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4076,27/08/2026 11:53:32,27/08/2026 11:59:44,372 +Srv_Deploy_0,1077,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4077,27/08/2026 11:55:49,27/08/2026 12:07:43,714 +Srv_Deploy_1,1078,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4078,27/08/2026 11:58:06,27/08/2026 12:04:18,372 +Srv_Deploy_2,1079,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4079,27/08/2026 12:00:23,27/08/2026 12:08:25,482 +Srv_Deploy_3,1080,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4080,27/08/2026 12:02:40,27/08/2026 12:03:48,68 +Srv_Deploy_4,1081,finished,FAILURE,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4081,27/08/2026 12:04:57,27/08/2026 12:19:25,868 +Srv_Deploy_5,1082,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4082,27/08/2026 12:07:14,27/08/2026 12:12:24,310 +Srv_Deploy_6,1083,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4083,27/08/2026 12:09:31,27/08/2026 12:16:36,425 +Srv_Deploy_0,1084,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4084,27/08/2026 12:11:48,27/08/2026 12:24:29,761 +Srv_Deploy_1,1085,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4085,27/08/2026 12:14:05,27/08/2026 12:26:12,727 +Srv_Deploy_2,1086,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4086,27/08/2026 12:16:22,27/08/2026 12:30:57,875 +Srv_Deploy_3,1087,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4087,27/08/2026 12:18:39,27/08/2026 12:29:22,643 +Srv_Deploy_4,1088,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4088,27/08/2026 12:20:56,27/08/2026 12:30:11,555 +Srv_Deploy_5,1089,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4089,27/08/2026 12:23:13,27/08/2026 12:31:57,524 +Srv_Deploy_6,1090,finished,FAILURE,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4090,27/08/2026 12:25:30,27/08/2026 12:39:36,846 +Srv_Deploy_0,1091,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4091,27/08/2026 12:27:47,27/08/2026 12:28:44,57 +Srv_Deploy_1,1092,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4092,27/08/2026 12:30:04,27/08/2026 12:39:19,555 +Srv_Deploy_2,1093,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4093,27/08/2026 12:32:21,27/08/2026 12:37:57,336 +Srv_Deploy_3,1094,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4094,27/08/2026 12:34:38,27/08/2026 12:45:00,622 +Srv_Deploy_4,1095,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4095,27/08/2026 12:36:55,27/08/2026 12:38:00,65 +Srv_Deploy_5,1096,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4096,27/08/2026 12:39:12,27/08/2026 12:51:27,735 +Srv_Deploy_6,1097,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4097,27/08/2026 12:41:29,27/08/2026 12:50:12,523 +Srv_Deploy_0,1098,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4098,27/08/2026 12:43:46,27/08/2026 12:49:15,329 +Srv_Deploy_1,1099,finished,FAILURE,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4099,27/08/2026 12:46:03,27/08/2026 13:00:52,889 +Srv_Deploy_2,1100,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4100,27/08/2026 12:48:20,27/08/2026 12:57:34,554 +Srv_Deploy_3,1101,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4101,27/08/2026 12:50:37,27/08/2026 13:05:15,878 +Srv_Deploy_4,1102,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4102,27/08/2026 12:52:54,27/08/2026 12:55:02,128 +Srv_Deploy_5,1103,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4103,27/08/2026 12:55:11,27/08/2026 13:06:18,667 +Srv_Deploy_6,1104,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4104,27/08/2026 12:57:28,27/08/2026 12:59:21,113 +Srv_Deploy_0,1105,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4105,27/08/2026 12:59:45,27/08/2026 13:12:48,783 +Srv_Deploy_1,1106,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4106,27/08/2026 13:02:02,27/08/2026 13:16:24,862 +Srv_Deploy_2,1107,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4107,27/08/2026 13:04:19,27/08/2026 13:15:54,695 +Srv_Deploy_3,1108,finished,FAILURE,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4108,27/08/2026 13:06:36,27/08/2026 13:13:19,403 +Srv_Deploy_4,1109,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4109,27/08/2026 13:08:53,27/08/2026 13:23:27,874 +Srv_Deploy_5,1110,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4110,27/08/2026 13:11:10,27/08/2026 13:24:19,789 +Srv_Deploy_6,1111,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4111,27/08/2026 13:13:27,27/08/2026 13:22:35,548 +Srv_Deploy_0,1112,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4112,27/08/2026 13:15:44,27/08/2026 13:28:22,758 +Srv_Deploy_1,1113,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4113,27/08/2026 13:18:01,27/08/2026 13:22:07,246 +Srv_Deploy_2,1114,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4114,27/08/2026 13:20:18,27/08/2026 13:25:08,290 +Srv_Deploy_3,1115,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4115,27/08/2026 13:22:35,27/08/2026 13:31:21,526 +Srv_Deploy_4,1116,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4116,27/08/2026 13:24:52,27/08/2026 13:37:50,778 +Srv_Deploy_5,1117,finished,FAILURE,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4117,27/08/2026 13:27:09,27/08/2026 13:34:48,459 +Srv_Deploy_6,1118,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4118,27/08/2026 13:29:26,27/08/2026 13:42:32,786 +Srv_Deploy_0,1119,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4119,27/08/2026 13:31:43,27/08/2026 13:42:41,658 +Srv_Deploy_1,1120,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4120,27/08/2026 13:34:00,27/08/2026 13:46:02,722 +Srv_Deploy_2,1121,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4121,27/08/2026 13:36:17,27/08/2026 13:51:15,898 +Srv_Deploy_3,1122,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4122,27/08/2026 13:38:34,27/08/2026 13:41:15,161 +Srv_Deploy_4,1123,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4123,27/08/2026 13:40:51,27/08/2026 13:50:45,594 +Srv_Deploy_5,1124,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4124,27/08/2026 13:43:08,27/08/2026 13:47:01,233 +Srv_Deploy_6,1125,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4125,27/08/2026 13:45:25,27/08/2026 13:52:17,412 +Srv_Deploy_0,1126,finished,FAILURE,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4126,27/08/2026 13:47:42,27/08/2026 13:53:14,332 +Srv_Deploy_1,1127,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4127,27/08/2026 13:49:59,27/08/2026 13:51:16,77 +Srv_Deploy_2,1128,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4128,27/08/2026 13:52:16,27/08/2026 13:57:41,325 +Srv_Deploy_3,1129,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4129,27/08/2026 13:54:33,27/08/2026 14:03:09,516 +Srv_Deploy_4,1130,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4130,27/08/2026 13:56:50,27/08/2026 14:10:20,810 +Srv_Deploy_5,1131,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4131,27/08/2026 13:59:07,27/08/2026 14:05:04,357 +Srv_Deploy_6,1132,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4132,27/08/2026 14:01:24,27/08/2026 14:11:10,586 +Srv_Deploy_0,1133,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4133,27/08/2026 14:03:41,27/08/2026 14:08:54,313 +Srv_Deploy_1,1134,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4134,27/08/2026 14:05:58,27/08/2026 14:19:44,826 +Srv_Deploy_2,1135,finished,FAILURE,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4135,27/08/2026 14:08:15,27/08/2026 14:22:24,849 +Srv_Deploy_3,1136,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4136,27/08/2026 14:10:32,27/08/2026 14:15:31,299 +Srv_Deploy_4,1137,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4137,27/08/2026 14:12:49,27/08/2026 14:21:39,530 +Srv_Deploy_5,1138,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4138,27/08/2026 14:15:06,27/08/2026 14:28:03,777 +Srv_Deploy_6,1139,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4139,27/08/2026 14:17:23,27/08/2026 14:32:21,898 +Srv_Deploy_0,1140,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4140,27/08/2026 14:19:40,27/08/2026 14:32:05,745 +Srv_Deploy_1,1141,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4141,27/08/2026 14:21:57,27/08/2026 14:35:31,814 +Srv_Deploy_2,1142,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4142,27/08/2026 14:24:14,27/08/2026 14:28:00,226 +Srv_Deploy_3,1143,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4143,27/08/2026 14:26:31,27/08/2026 14:33:34,423 +Srv_Deploy_4,1144,finished,FAILURE,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4144,27/08/2026 14:28:48,27/08/2026 14:30:40,112 +Srv_Deploy_5,1145,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4145,27/08/2026 14:31:05,27/08/2026 14:46:02,897 +Srv_Deploy_6,1146,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4146,27/08/2026 14:33:22,27/08/2026 14:43:11,589 +Srv_Deploy_0,1147,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4147,27/08/2026 14:35:39,27/08/2026 14:38:11,152 +Srv_Deploy_1,1148,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4148,27/08/2026 14:37:56,27/08/2026 14:50:00,724 +Srv_Deploy_2,1149,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4149,27/08/2026 14:40:13,27/08/2026 14:42:26,133 +Srv_Deploy_3,1150,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4150,27/08/2026 14:42:30,27/08/2026 14:54:00,690 +Srv_Deploy_4,1151,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4151,27/08/2026 14:44:47,27/08/2026 14:55:51,664 +Srv_Deploy_5,1152,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4152,27/08/2026 14:47:04,27/08/2026 14:54:15,431 +Srv_Deploy_6,1153,finished,FAILURE,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4153,27/08/2026 14:49:21,27/08/2026 14:52:42,201 +Srv_Deploy_0,1154,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4154,27/08/2026 14:51:38,27/08/2026 15:00:29,531 +Srv_Deploy_1,1155,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4155,27/08/2026 14:53:55,27/08/2026 15:04:49,654 +Srv_Deploy_2,1156,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4156,27/08/2026 14:56:12,27/08/2026 15:03:03,411 +Srv_Deploy_3,1157,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4157,27/08/2026 14:58:29,27/08/2026 15:05:50,441 +Srv_Deploy_4,1158,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4158,27/08/2026 15:00:46,27/08/2026 15:11:25,639 +Srv_Deploy_5,1159,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4159,27/08/2026 15:03:03,27/08/2026 15:12:11,548 +Srv_Deploy_6,1160,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4160,27/08/2026 15:05:20,27/08/2026 15:13:35,495 +Srv_Deploy_0,1161,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4161,27/08/2026 15:07:37,27/08/2026 15:12:34,297 +Srv_Deploy_1,1162,finished,FAILURE,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4162,27/08/2026 15:09:54,27/08/2026 15:17:24,450 +Srv_Deploy_2,1163,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4163,27/08/2026 15:12:11,27/08/2026 15:19:07,416 +Srv_Deploy_3,1164,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4164,27/08/2026 15:14:28,27/08/2026 15:23:40,552 +Srv_Deploy_4,1165,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4165,27/08/2026 15:16:45,27/08/2026 15:19:32,167 +Srv_Deploy_5,1166,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4166,27/08/2026 15:19:02,27/08/2026 15:20:53,111 +Srv_Deploy_6,1167,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4167,27/08/2026 15:21:19,27/08/2026 15:26:47,328 +Srv_Deploy_0,1168,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4168,27/08/2026 15:23:36,27/08/2026 15:34:14,638 +Srv_Deploy_1,1169,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4169,27/08/2026 15:25:53,27/08/2026 15:33:00,427 +Srv_Deploy_2,1170,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4170,27/08/2026 15:28:10,27/08/2026 15:31:36,206 +Srv_Deploy_3,1171,finished,FAILURE,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4171,27/08/2026 15:30:27,27/08/2026 15:36:56,389 +Srv_Deploy_4,1172,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4172,27/08/2026 15:32:44,27/08/2026 15:43:06,622 +Srv_Deploy_5,1173,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4173,27/08/2026 15:35:01,27/08/2026 15:42:04,423 +Srv_Deploy_6,1174,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4174,27/08/2026 15:37:18,27/08/2026 15:47:55,637 +Srv_Deploy_0,1175,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4175,27/08/2026 15:39:35,27/08/2026 15:42:05,150 +Srv_Deploy_1,1176,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4176,27/08/2026 15:41:52,27/08/2026 15:51:17,565 +Srv_Deploy_2,1177,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4177,27/08/2026 15:44:09,27/08/2026 15:46:09,120 +Srv_Deploy_3,1178,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4178,27/08/2026 15:46:26,27/08/2026 15:49:23,177 +Srv_Deploy_4,1179,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4179,27/08/2026 15:48:43,27/08/2026 16:00:20,697 +Srv_Deploy_5,1180,finished,FAILURE,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4180,27/08/2026 15:51:00,27/08/2026 15:59:29,509 +Srv_Deploy_6,1181,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4181,27/08/2026 15:53:17,27/08/2026 16:07:54,877 +Srv_Deploy_0,1182,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4182,27/08/2026 15:55:34,27/08/2026 16:03:43,489 +Srv_Deploy_1,1183,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4183,27/08/2026 15:57:51,27/08/2026 16:08:58,667 +Srv_Deploy_2,1184,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4184,27/08/2026 16:00:08,27/08/2026 16:00:57,49 +Srv_Deploy_3,1185,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4185,27/08/2026 16:02:25,27/08/2026 16:13:10,645 +Srv_Deploy_4,1186,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4186,27/08/2026 16:04:42,27/08/2026 16:08:37,235 +Srv_Deploy_5,1187,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4187,27/08/2026 16:06:59,27/08/2026 16:16:06,547 +Srv_Deploy_6,1188,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4188,27/08/2026 16:09:16,27/08/2026 16:16:10,414 +Srv_Deploy_0,1189,finished,FAILURE,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4189,27/08/2026 16:11:33,27/08/2026 16:18:47,434 +Srv_Deploy_1,1190,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4190,27/08/2026 16:13:50,27/08/2026 16:26:46,776 +Srv_Deploy_2,1191,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4191,27/08/2026 16:16:07,27/08/2026 16:22:19,372 +Srv_Deploy_3,1192,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4192,27/08/2026 16:18:24,27/08/2026 16:32:36,852 +Srv_Deploy_4,1193,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4193,27/08/2026 16:20:41,27/08/2026 16:28:34,473 +Srv_Deploy_5,1194,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4194,27/08/2026 16:22:58,27/08/2026 16:25:03,125 +Srv_Deploy_6,1195,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4195,27/08/2026 16:25:15,27/08/2026 16:29:44,269 +Srv_Deploy_0,1196,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4196,27/08/2026 16:27:32,27/08/2026 16:41:48,856 +Srv_Deploy_1,1197,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4197,27/08/2026 16:29:49,27/08/2026 16:31:10,81 +Srv_Deploy_2,1198,finished,FAILURE,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4198,27/08/2026 16:32:06,27/08/2026 16:39:28,442 +Srv_Deploy_3,1199,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4199,27/08/2026 16:34:23,27/08/2026 16:36:36,133 diff --git a/src/help_text.rs b/src/help_text.rs index b1923994..cc904329 100644 --- a/src/help_text.rs +++ b/src/help_text.rs @@ -146,6 +146,7 @@ impl HelpText { Line::from(" : - Jump to row"), Line::from(" Space - Toggle viewport lock"), Line::from(" Ctrl+Space - Toggle viewport lock (alternative)"), + Line::from(" Ctrl+L - Redraw the screen"), Line::from(" x/X - Toggle cursor lock"), Line::from(" p - Pin/unpin column"), Line::from(" P - Clear all pins"), diff --git a/src/ui/enhanced_tui.rs b/src/ui/enhanced_tui.rs index c7d272a7..b0122204 100644 --- a/src/ui/enhanced_tui.rs +++ b/src/ui/enhanced_tui.rs @@ -443,6 +443,9 @@ pub struct EnhancedTuiApp { // Debug system pub(crate) debug_registry: DebugRegistry, pub(crate) memory_tracker: MemoryTracker, + + // Set by Ctrl+L; consumed by the event loop to clear before the next draw + force_redraw: bool, } impl DebugContext for EnhancedTuiApp { @@ -1547,6 +1550,7 @@ impl EnhancedTuiApp { query_orchestrator: QueryOrchestrator::with_behavior_config(config.behavior.clone()), debug_registry: DebugRegistry::new(), memory_tracker: MemoryTracker::new(100), + force_redraw: false, }; // Set up state dispatcher @@ -1948,6 +1952,11 @@ impl EnhancedTuiApp { if self.table_widget_manager.borrow().needs_render() { info!("TableWidgetManager needs render after key event"); } + if std::mem::take(&mut self.force_redraw) { + // Drop ratatui's notion of what is on screen so the next draw + // repaints every cell rather than diffing against a stale buffer. + terminal.clear()?; + } terminal.draw(|f| self.ui(f))?; self.table_widget_manager.borrow_mut().rendered(); @@ -6854,32 +6863,31 @@ impl EnhancedTuiApp { )); } - // Find current column in display order - if let Some(display_idx) = display_columns - .iter() - .position(|&idx| idx == current_column) - { + // current_column is a VISUAL position, so it indexes display_columns + // directly - it is not a DataTable index to be searched for. + if let Some(&datatable_idx) = display_columns.get(current_column) { debug_info.push_str(&format!( - "Current column {} is at display index {}/{}\n", + "Current column: visual {}/{} -> DataTable [{}]\n", current_column, - display_idx, - display_columns.len() + display_columns.len(), + datatable_idx )); // Show what happens on next move - if display_idx + 1 < display_columns.len() { - let next_col = display_columns[display_idx + 1]; + if current_column + 1 < display_columns.len() { debug_info.push_str(&format!( - "Next 'l' press should move to column {} (display index {})\n", - next_col, - display_idx + 1 + "Next 'l' press should move to visual {} -> DataTable [{}]\n", + current_column + 1, + display_columns[current_column + 1] )); } else { debug_info.push_str("Next 'l' press should wrap to first column\n"); } } else { debug_info.push_str(&format!( - "WARNING: Current column {current_column} not found in display order!\n" + "WARNING: Visual column {} is out of range ({} columns)!\n", + current_column, + display_columns.len() )); } } @@ -7644,6 +7652,12 @@ impl ActionHandlerContext for EnhancedTuiApp { } } + fn request_force_redraw(&mut self) { + debug!("Force redraw requested (Ctrl+L)"); + self.force_redraw = true; + self.set_status_message("Screen refreshed"); + } + fn toggle_viewport_lock(&mut self) { // Toggle viewport lock in ViewportManager let is_locked = { diff --git a/src/ui/input/action_handlers.rs b/src/ui/input/action_handlers.rs index b70d1a41..65d1c5c4 100644 --- a/src/ui/input/action_handlers.rs +++ b/src/ui/input/action_handlers.rs @@ -132,6 +132,9 @@ pub trait ActionHandlerContext { // Viewport lock operations fn toggle_cursor_lock(&mut self); fn toggle_viewport_lock(&mut self); + + /// Request a full repaint on the next frame (Ctrl+L). + fn request_force_redraw(&mut self); } /// Handler for navigation actions (Up, Down, Left, Right, `PageUp`, etc.) @@ -669,6 +672,10 @@ impl ActionHandler for DebugViewportActionHandler { tui.toggle_viewport_lock(); Some(Ok(ActionResult::Handled)) } + Action::ForceRedraw => { + tui.request_force_redraw(); + Some(Ok(ActionResult::Handled)) + } _ => None, } } @@ -1072,6 +1079,9 @@ mod tests { fn toggle_viewport_lock(&mut self) { self.last_action = "toggle_viewport_lock".to_string(); } + fn request_force_redraw(&mut self) { + self.last_action = "request_force_redraw".to_string(); + } // Input and text editing fn move_input_cursor_left(&mut self) { diff --git a/src/ui/input/actions.rs b/src/ui/input/actions.rs index 2652fbf4..bfb8d235 100644 --- a/src/ui/input/actions.rs +++ b/src/ui/input/actions.rs @@ -116,6 +116,7 @@ pub enum Action { NavigateToViewportBottom, ToggleCursorLock, ToggleViewportLock, + ForceRedraw, // Ctrl+L - repaint the whole screen ToggleCaseInsensitive, ToggleKeyIndicator, ShowColumnStatistics, // For 'S' key diff --git a/src/ui/key_handling/mapper.rs b/src/ui/key_handling/mapper.rs index 56176aba..287c5c02 100644 --- a/src/ui/key_handling/mapper.rs +++ b/src/ui/key_handling/mapper.rs @@ -65,6 +65,10 @@ impl KeyMapper { // Force quit self.global_mappings .insert((Char('c'), Mod::CONTROL), Action::ForceQuit); + // Ctrl+L: repaint the screen, the universal terminal convention. An + // escape hatch for display corruption from anything outside our control. + self.global_mappings + .insert((Char('l'), Mod::CONTROL), Action::ForceRedraw); self.global_mappings .insert((Char('C'), Mod::CONTROL), Action::ForceQuit); } @@ -564,6 +568,44 @@ mod tests { assert_eq!(action, Some(Action::ShowHelp)); } + #[test] + fn ctrl_l_maps_to_force_redraw_in_every_mode() { + // Ctrl+L is the universal "repaint the screen" convention. It lives in the + // global map so it stays reachable regardless of mode, and must not collide + // with the readline bindings (Ctrl+W delete-word, Ctrl+P prev-history) or + // with viewport lock, which is Space / Ctrl+Space. + let ctrl_l = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL); + + for mode in [AppMode::Results, AppMode::Command] { + let mut mapper = KeyMapper::new(); + let context = ActionContext { + mode: mode.clone(), + selection_mode: SelectionMode::Row, + has_results: true, + has_filter: false, + has_search: false, + row_count: 10, + column_count: 10, + current_row: 0, + current_column: 0, + }; + + assert_eq!( + mapper.map_key(ctrl_l, &context), + Some(Action::ForceRedraw), + "Ctrl+L should force a redraw in {mode:?} mode" + ); + + // Plain 'l' must still navigate right, not redraw + let plain_l = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE); + assert_ne!( + mapper.map_key(plain_l, &context), + Some(Action::ForceRedraw), + "plain 'l' should not redraw in {mode:?} mode" + ); + } + } + #[test] fn test_command_mode_editing_actions() { let mut mapper = KeyMapper::new(); diff --git a/src/widgets/help_widget.rs b/src/widgets/help_widget.rs index 9329e1e4..8ff7aa89 100644 --- a/src/widgets/help_widget.rs +++ b/src/widgets/help_widget.rs @@ -469,7 +469,9 @@ Selection Modes: Ctrl+A - Select all Viewport Control: - Ctrl+L - Lock/unlock viewport + Space - Lock/unlock viewport + Ctrl+Space - Lock/unlock viewport (alternative) + Ctrl+L - Redraw the screen z - Center current row zt - Current row to top zb - Current row to bottom" From 755e18b8737de732c35e323a67a0f56d43500f6b Mon Sep 17 00:00:00 2001 From: TimelordUK Date: Sat, 29 Aug 2026 12:32:10 +0100 Subject: [PATCH 4/4] style: apply cargo fmt Import ordering in history.rs and a rustfmt-preferred list break in the projection width tests. Whitespace only; no behaviour change. Co-Authored-By: Claude Opus 5 --- src/history.rs | 2 +- tests/projection_column_width_tests.rs | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/history.rs b/src/history.rs index e092a44d..748c4f54 100644 --- a/src/history.rs +++ b/src/history.rs @@ -5,10 +5,10 @@ use chrono::{DateTime, Utc}; use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; use serde::{Deserialize, Serialize}; -use tracing::{debug, error, info, warn}; use std::collections::HashMap; use std::fs; use std::path::PathBuf; +use tracing::{debug, error, info, warn}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QueryMetadata { diff --git a/tests/projection_column_width_tests.rs b/tests/projection_column_width_tests.rs index f631f83e..ab8a94c4 100644 --- a/tests/projection_column_width_tests.rs +++ b/tests/projection_column_width_tests.rs @@ -111,7 +111,14 @@ fn projected_widths_match_the_equivalent_unprojected_view() { // The same six columns, but as the only columns in the source table, so that // visual and DataTable indices coincide. Widths must agree either way. let mut narrow = DataTable::new("tc_narrow"); - for header in ["Project", "Job", "JobId", "Started", "Finished", "DurationSec"] { + for header in [ + "Project", + "Job", + "JobId", + "Started", + "Finished", + "DurationSec", + ] { narrow.add_column(DataColumn::new(header)); } for i in 0..20 {