Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions sql-cli/integration_tests/test_scripts/test_pin_columns.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/bin/bash

# Test script for pin columns feature
# Tests that pinned columns stay fixed when scrolling horizontally

set -e

echo "======================================"
echo "Testing Pin Columns Feature"
echo "======================================"

# Create test CSV with many columns to force horizontal scrolling
cat > /tmp/test_pin_columns.csv << 'EOF'
id,name,category,price,quantity,status,date,location,vendor,sku,warehouse,region,country,notes
1,Apple,Fruit,2.50,100,Available,2024-01-01,Store A,FreshCo,SKU001,W1,North,USA,Fresh
2,Banana,Fruit,1.25,150,Available,2024-01-02,Store B,FruitMart,SKU002,W2,South,USA,Ripe
3,Carrot,Vegetable,0.75,200,Available,2024-01-03,Store C,VeggieCo,SKU003,W1,East,USA,Organic
4,Desk,Furniture,150.00,10,Available,2024-01-04,Store D,OfficePro,SKU004,W3,West,USA,Wooden
5,Eggs,Dairy,3.99,50,Low Stock,2024-01-05,Store A,DairyFarm,SKU005,W2,North,USA,Dozen
EOF

echo "Test 1: Basic pin column functionality"
echo "--------------------------------------"
# The test would normally be interactive, but we can verify the feature is working
# by checking that the ViewportManager correctly handles pinned columns

# Run with debug logging to verify pin functionality
RUST_LOG=sql_cli::ui::viewport_manager=debug timeout 2 ./target/release/sql-cli /tmp/test_pin_columns.csv -e "select * from data" 2>&1 | grep -E "pinned|calculate_visible_column" | head -5 || true

echo ""
echo "Test 2: Verify pinned columns in debug output"
echo "--------------------------------------------"
# Test that F5 debug mode shows pinned columns correctly
echo -e "llp\n" | timeout 2 ./target/release/sql-cli /tmp/test_pin_columns.csv -e "select * from data" 2>&1 | grep -i "pinned" | head -3 || true

echo ""
echo "✅ Pin columns feature tests completed"
echo ""
echo "Manual verification steps:"
echo "1. Run: ./target/release/sql-cli /tmp/test_pin_columns.csv"
echo "2. Navigate to 'name' column (press 'l')"
echo "3. Pin the column (press 'p')"
echo "4. Scroll right (press 'l' multiple times)"
echo "5. Verify 'name' column stays visible on the left with 📌 indicator"
echo "6. Press 'P' to clear all pins"
echo ""

# Clean up
rm -f /tmp/test_pin_columns.csv

echo "Test script completed successfully!"
25 changes: 22 additions & 3 deletions sql-cli/src/ui/enhanced_tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5734,13 +5734,28 @@ impl EnhancedTuiApp {
}

// Get structured column information from ViewportManager
let (pinned_indices, crosshair_column_position, _) = {
let (pinned_visual_positions, crosshair_column_position, _) = {
let mut viewport_manager_borrow = self.viewport_manager.borrow_mut();
let viewport_manager = viewport_manager_borrow
.as_mut()
.expect("ViewportManager must exist for rendering");
let info = viewport_manager.get_visible_columns_info(available_width);

// info.0 = visible_indices (all visible column source indices)
// info.1 = pinned_visible (pinned column source indices)
// info.2 = scrollable_visible (scrollable column source indices)
let visible_indices = &info.0;
let pinned_source_indices = &info.1;

// Convert pinned source indices to visual positions
// The TableRenderContext expects visual positions (0-based positions in the visible array)
let mut pinned_visual_positions = Vec::new();
for &source_idx in pinned_source_indices {
if let Some(visual_pos) = visible_indices.iter().position(|&x| x == source_idx) {
pinned_visual_positions.push(visual_pos);
}
}

// Get the crosshair's viewport-relative position for rendering
// The viewport manager stores crosshair in absolute coordinates
// but we need viewport-relative for rendering
Expand All @@ -5754,7 +5769,11 @@ impl EnhancedTuiApp {

let crosshair_visual = viewport_manager.get_crosshair_col();

(info.1, crosshair_column_position, crosshair_visual)
(
pinned_visual_positions,
crosshair_column_position,
crosshair_visual,
)
};

// Calculate row viewport
Expand Down Expand Up @@ -5811,7 +5830,7 @@ impl EnhancedTuiApp {
.row_count(row_count)
.visible_rows(visible_row_indices.clone(), data_to_display)
.columns(column_headers, column_widths_visual)
.pinned_columns(pinned_indices)
.pinned_columns(pinned_visual_positions)
.selection(
selected_row,
selected_col,
Expand Down
128 changes: 81 additions & 47 deletions sql-cli/src/ui/table_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,49 +64,62 @@ fn build_header_row(ctx: &TableRenderContext) -> Row<'static> {
);
}

// Add data headers
header_cells.extend(
ctx.column_headers
.iter()
.enumerate()
.map(|(visual_pos, header)| {
// Get sort indicator
let sort_indicator = ctx.get_sort_indicator(visual_pos);

// Check if this is the current column
let is_crosshair = ctx.is_selected_column(visual_pos);
let column_indicator = if is_crosshair { " [*]" } else { "" };

// Check if this column is pinned
let is_pinned = ctx.is_pinned_column(visual_pos);

// Determine styling
let mut style = if is_pinned {
// Pinned columns get blue background
// Add data headers with separator between pinned and scrollable columns
let mut last_was_pinned = false;
for (visual_pos, header) in ctx.column_headers.iter().enumerate() {
let is_pinned = ctx.is_pinned_column(visual_pos);

// Add separator if transitioning from pinned to non-pinned
if last_was_pinned && !is_pinned && ctx.pinned_count > 0 {
// Add a visual separator column
header_cells.push(
Cell::from("│").style(
Style::default()
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
// Regular columns
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
};
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
),
);
}

if is_crosshair {
// Current column gets yellow text
style = if is_pinned {
style.fg(Color::Yellow).add_modifier(Modifier::UNDERLINED)
} else {
style.fg(Color::Yellow).add_modifier(Modifier::UNDERLINED)
};
}
// Get sort indicator
let sort_indicator = ctx.get_sort_indicator(visual_pos);

// Check if this is the current column
let is_crosshair = ctx.is_selected_column(visual_pos);
let column_indicator = if is_crosshair { " [*]" } else { "" };

// Add pin indicator for pinned columns
let pin_indicator = if is_pinned { "📌 " } else { "" };

// Determine styling
let mut style = if is_pinned {
// Pinned columns get special styling
Style::default()
.bg(Color::Rgb(40, 40, 80)) // Darker blue background
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
// Regular columns
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
};

if is_crosshair {
// Current column gets yellow text
style = style.fg(Color::Yellow).add_modifier(Modifier::UNDERLINED);
}

header_cells.push(
Cell::from(format!(
"{}{}{}{}",
pin_indicator, header, sort_indicator, column_indicator
))
.style(style),
);

Cell::from(format!("{}{}{}", header, sort_indicator, column_indicator)).style(style)
})
.collect::<Vec<Cell>>(),
);
last_was_pinned = is_pinned;
}

Row::new(header_cells)
}
Expand All @@ -130,18 +143,29 @@ fn build_data_rows(ctx: &TableRenderContext) -> Vec<Row<'static>> {
// Check if this is the current row
let is_current_row = ctx.is_selected_row(row_idx);

// Add data cells with appropriate styling
cells.extend(row_data.iter().enumerate().map(|(col_idx, val)| {
let is_selected_column = ctx.is_selected_column(col_idx);
// Add data cells with separator between pinned and scrollable
let mut last_was_pinned = false;
for (col_idx, val) in row_data.iter().enumerate() {
let is_pinned = ctx.is_pinned_column(col_idx);

// Add separator if transitioning from pinned to non-pinned
if last_was_pinned && !is_pinned && ctx.pinned_count > 0 {
cells.push(Cell::from("│").style(Style::default().fg(Color::DarkGray)));
}

let is_selected_column = ctx.is_selected_column(col_idx);
let mut cell = Cell::from(val.clone());

// Apply fuzzy filter highlighting
if !is_current_row && ctx.cell_matches_filter(val) {
cell = cell.style(Style::default().fg(Color::Magenta));
}

// Apply background for pinned columns
if is_pinned && !is_current_row {
cell = cell.style(Style::default().bg(Color::Rgb(20, 20, 40)));
}

// Apply selection styling based on mode
cell = match ctx.selection_mode {
SelectionMode::Cell if is_current_row && is_selected_column => {
Expand Down Expand Up @@ -183,8 +207,9 @@ fn build_data_rows(ctx: &TableRenderContext) -> Vec<Row<'static>> {
_ => cell,
};

cell
}));
cells.push(cell);
last_was_pinned = is_pinned;
}

// Apply row highlighting
let row_style = if is_current_row {
Expand All @@ -209,9 +234,18 @@ fn calculate_column_widths(ctx: &TableRenderContext) -> Vec<Constraint> {
widths.push(Constraint::Length(8)); // Fixed width for row numbers
}

// Add widths for visible data columns
for &width in &ctx.column_widths {
// Add widths for visible data columns with separator
let mut last_was_pinned = false;
for (idx, &width) in ctx.column_widths.iter().enumerate() {
let is_pinned = ctx.is_pinned_column(idx);

// Add separator width if transitioning from pinned to non-pinned
if last_was_pinned && !is_pinned && ctx.pinned_count > 0 {
widths.push(Constraint::Length(1)); // Separator column
}

widths.push(Constraint::Length(width));
last_was_pinned = is_pinned;
}

widths
Expand Down
40 changes: 31 additions & 9 deletions sql-cli/src/ui/traits/column_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,29 @@ pub trait ColumnBehavior {

// Helper method to apply column navigation results
fn apply_column_navigation_result(&mut self, result: NavigationResult, direction: &str) {
// Get the visual position from ViewportManager after navigation
let visual_position = {
let viewport_borrow = self.viewport_manager().borrow();
viewport_borrow
.as_ref()
.map(|vm| vm.get_crosshair_col())
.unwrap_or(0)
};
// Use the column position from the navigation result - this is the visual/display index
// IMPORTANT: Don't re-query ViewportManager as that may have stale state
let visual_position = result.column_position;

tracing::debug!(
"[COLUMN_OPS] apply_column_navigation_result: direction={}, visual_position={}",
direction,
visual_position
);

// Update Buffer's current column
self.buffer_mut().set_current_column(visual_position);
tracing::debug!(
"[COLUMN_OPS] apply_column_navigation_result: set buffer column to {}",
visual_position
);

// Update navigation state
self.state_container().navigation_mut().selected_column = visual_position;
tracing::debug!(
"[COLUMN_OPS] apply_column_navigation_result: set navigation column to {}",
visual_position
);

// Update scroll offset if viewport changed
if result.viewport_changed {
Expand Down Expand Up @@ -179,9 +188,22 @@ pub trait ColumnBehavior {
.as_mut()
.expect("ViewportManager must exist for navigation");
let current_visual = vm.get_crosshair_col();
vm.navigate_column_right(current_visual)
tracing::debug!(
"[COLUMN_OPS] move_column_right: current_visual from VM = {}",
current_visual
);
let result = vm.navigate_column_right(current_visual);
tracing::debug!(
"[COLUMN_OPS] move_column_right: navigation result column_position = {}",
result.column_position
);
result
};

tracing::debug!(
"[COLUMN_OPS] move_column_right: applying result with column_position = {}",
nav_result.column_position
);
self.apply_column_navigation_result(nav_result, "right");
}

Expand Down
Loading
Loading