From 16fd07307bb6a589109b3ea1d48c7ed8e4b21bfd Mon Sep 17 00:00:00 2001 From: muk Date: Tue, 17 Feb 2026 21:29:50 +0000 Subject: [PATCH] feat: UX improvements - resizable panes, multi-query, better results nav - Add resizable editor/results split pane (Ctrl+Shift+Up/Down) - Multi-query support: execute query at cursor position with `;` boundary detection that respects strings, comments, and quoted identifiers - Active query gutter indicator in editor (vertical bar on active block) - Tab/Shift+Tab column navigation in results (Snowflake-style) - Esc to leave results and return to editor - Row/column position indicator in results title bar - Show column count alongside row count in results header - Auto-scroll results to keep selected row visible - Reset scroll position when switching between result sets - Updated help overlay with new keybindings Closes #3 Co-Authored-By: Claude Opus 4.6 --- src/ui/app.rs | 197 +++++++++++++++++++++++++++++++++++++++++-- src/ui/components.rs | 55 ++++++++++-- 2 files changed, 236 insertions(+), 16 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index 2ccc163..be3ddb8 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -64,6 +64,9 @@ pub struct App { pub editor: TextBuffer, pub query_history: QueryHistory, + // Layout + pub editor_height_percent: u16, + // Results pub results: Vec, pub current_result: usize, @@ -228,6 +231,8 @@ impl App { editor: TextBuffer::new(), query_history, + editor_height_percent: 40, + results: Vec::new(), current_result: 0, result_scroll_x: 0, @@ -653,7 +658,7 @@ impl App { self.focus = Focus::Sidebar; } KeyCode::Enter if ctrl => { - // Execute query + // Execute query at cursor self.execute_query().await?; self.focus = Focus::Results; } @@ -684,14 +689,26 @@ impl App { // Clear editor self.editor.clear(); } + // Pane resizing: Ctrl+Shift+Up/Down + KeyCode::Up if ctrl && shift => { + // Make editor smaller / results bigger + if self.editor_height_percent > 15 { + self.editor_height_percent -= 5; + } + } + KeyCode::Down if ctrl && shift => { + // Make editor bigger / results smaller + if self.editor_height_percent < 85 { + self.editor_height_percent += 5; + } + } + // History navigation: Ctrl+Up/Down KeyCode::Up if ctrl => { - // Previous in history if let Some(entry) = self.query_history.previous() { self.editor.set_text(&entry.query); } } KeyCode::Down if ctrl => { - // Next in history if let Some(entry) = self.query_history.next() { self.editor.set_text(&entry.query); } @@ -747,14 +764,30 @@ impl App { async fn handle_results_input(&mut self, key: KeyEvent) -> Result<()> { match key.code { + // Tab/Shift+Tab for column navigation (Snowflake-style) KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => { - self.focus = Focus::Editor; + // Shift+Tab: move to previous column + if self.result_selected_col > 0 { + self.result_selected_col -= 1; + } } KeyCode::BackTab => { - self.focus = Focus::Editor; + // BackTab: move to previous column + if self.result_selected_col > 0 { + self.result_selected_col -= 1; + } } KeyCode::Tab => { - self.focus = Focus::Sidebar; + // Tab: move to next column + if let Some(result) = self.results.get(self.current_result) { + if self.result_selected_col < result.columns.len().saturating_sub(1) { + self.result_selected_col += 1; + } + } + } + KeyCode::Esc => { + // Esc to leave results and go back to editor + self.focus = Focus::Editor; } KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => { self.focus = Focus::Editor; @@ -774,12 +807,14 @@ impl App { KeyCode::Up => { if self.result_selected_row > 0 { self.result_selected_row -= 1; + self.auto_scroll_results(); } } KeyCode::Down => { if let Some(result) = self.results.get(self.current_result) { if self.result_selected_row < result.rows.len().saturating_sub(1) { self.result_selected_row += 1; + self.auto_scroll_results(); } } } @@ -793,11 +828,13 @@ impl App { } KeyCode::PageUp => { self.result_selected_row = self.result_selected_row.saturating_sub(20); + self.auto_scroll_results(); } KeyCode::PageDown => { if let Some(result) = self.results.get(self.current_result) { self.result_selected_row = (self.result_selected_row + 20).min(result.rows.len().saturating_sub(1)); + self.auto_scroll_results(); } } KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -808,6 +845,7 @@ impl App { self.current_result -= 1; self.result_selected_row = 0; self.result_selected_col = 0; + self.result_scroll_y = 0; } } KeyCode::Char(']') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -815,6 +853,7 @@ impl App { self.current_result += 1; self.result_selected_row = 0; self.result_selected_col = 0; + self.result_scroll_y = 0; } } _ => {} @@ -822,6 +861,20 @@ impl App { Ok(()) } + /// Keep the selected result row visible by adjusting scroll position. + fn auto_scroll_results(&mut self) { + if self.result_selected_row < self.result_scroll_y { + self.result_scroll_y = self.result_selected_row; + } + // Use a conservative visible-height estimate; rendering will clamp if needed + let estimated_visible = 20_usize; + if self.result_selected_row >= self.result_scroll_y + estimated_visible { + self.result_scroll_y = self + .result_selected_row + .saturating_sub(estimated_visible - 1); + } + } + async fn handle_help_input(&mut self, key: KeyEvent) -> Result<()> { match key.code { KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => { @@ -1002,8 +1055,138 @@ impl App { Ok(()) } + /// Get the byte offset of the cursor in the full editor text. + fn get_cursor_offset(&self) -> usize { + let mut offset = 0; + for (i, line) in self.editor.lines.iter().enumerate() { + if i == self.editor.cursor_y { + offset += self.editor.cursor_x; + break; + } + offset += line.len() + 1; // +1 for newline + } + offset + } + + /// Find the query at the current cursor position. + /// Splits on `;` while respecting string literals and comments. + fn get_query_at_cursor(&self) -> String { + let full_text = self.editor.text(); + let cursor_offset = self.get_cursor_offset(); + + let boundaries = Self::find_query_boundaries(&full_text); + for (start, end) in &boundaries { + if cursor_offset >= *start && cursor_offset <= *end { + return full_text[*start..*end].trim().to_string(); + } + } + + // Fallback to full text + full_text.trim().to_string() + } + + /// Returns (start_line, end_line) of the query block at the cursor, + /// for visual highlighting in the editor. + pub fn get_current_query_line_range(&self) -> Option<(usize, usize)> { + let full_text = self.editor.text(); + let cursor_offset = self.get_cursor_offset(); + + let boundaries = Self::find_query_boundaries(&full_text); + for (start, end) in &boundaries { + if cursor_offset >= *start && cursor_offset <= *end { + // Convert byte offsets to line numbers + let start_line = full_text[..*start].matches('\n').count(); + let end_line = full_text[..*end].matches('\n').count(); + return Some((start_line, end_line)); + } + } + None + } + + /// Find all query boundaries in the text, returning (start, end) byte offsets. + /// Respects single-quoted strings, double-quoted identifiers, and `--` line comments. + fn find_query_boundaries(text: &str) -> Vec<(usize, usize)> { + let mut boundaries = Vec::new(); + let mut start = 0; + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut in_line_comment = false; + let mut in_block_comment = false; + let chars: Vec = text.chars().collect(); + let len = chars.len(); + let mut byte_pos = 0; + let mut i = 0; + + while i < len { + let c = chars[i]; + let c_len = c.len_utf8(); + + if in_line_comment { + if c == '\n' { + in_line_comment = false; + } + } else if in_block_comment { + if c == '*' && i + 1 < len && chars[i + 1] == '/' { + in_block_comment = false; + i += 1; + byte_pos += chars[i].len_utf8(); + } + } else if in_single_quote { + if c == '\'' { + // Handle escaped quotes ('') + if i + 1 < len && chars[i + 1] == '\'' { + i += 1; + byte_pos += chars[i].len_utf8(); + } else { + in_single_quote = false; + } + } + } else if in_double_quote { + if c == '"' { + in_double_quote = false; + } + } else { + match c { + '\'' => in_single_quote = true, + '"' => in_double_quote = true, + '-' if i + 1 < len && chars[i + 1] == '-' => { + in_line_comment = true; + } + '/' if i + 1 < len && chars[i + 1] == '*' => { + in_block_comment = true; + i += 1; + byte_pos += chars[i].len_utf8(); + } + ';' => { + let end = byte_pos; + if !text[start..end].trim().is_empty() { + boundaries.push((start, end)); + } + start = byte_pos + c_len; + } + _ => {} + } + } + + byte_pos += c_len; + i += 1; + } + + // Last query (after final `;` or if no `;` at all) + if start < text.len() && !text[start..].trim().is_empty() { + boundaries.push((start, text.len())); + } + + // If empty, treat entire text as one query + if boundaries.is_empty() && !text.trim().is_empty() { + boundaries.push((0, text.len())); + } + + boundaries + } + async fn execute_query(&mut self) -> Result<()> { - let query = self.editor.text(); + let query = self.get_query_at_cursor(); if query.trim().is_empty() { return Ok(()); } diff --git a/src/ui/components.rs b/src/ui/components.rs index da943dd..54465d7 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -267,8 +267,8 @@ fn draw_main_panel(frame: &mut Frame, app: &App, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Percentage(40), // Editor - Constraint::Min(0), // Results + Constraint::Percentage(app.editor_height_percent), // Editor (resizable) + Constraint::Min(0), // Results ]) .split(area); @@ -305,6 +305,9 @@ fn draw_editor(frame: &mut Frame, app: &App, area: Rect) { area, ); + // Determine active query range for visual highlighting + let query_range = app.get_current_query_line_range(); + // Syntax highlight and render editor content let visible_height = inner_area.height as usize; let lines: Vec = app @@ -316,16 +319,19 @@ fn draw_editor(frame: &mut Frame, app: &App, area: Rect) { .enumerate() .map(|(line_idx, line_text)| { let actual_line = line_idx + app.editor.scroll_offset; - highlight_sql_line(line_text, theme, actual_line, &app.editor) + let in_active_query = query_range + .map(|(start, end)| actual_line >= start && actual_line <= end) + .unwrap_or(false); + highlight_sql_line(line_text, theme, actual_line, &app.editor, in_active_query) }) .collect(); let paragraph = Paragraph::new(lines); frame.render_widget(paragraph, inner_area); - // Show cursor + // Show cursor (offset by 2 for gutter prefix) if focused { - let cursor_x = inner_area.x + app.editor.cursor_x as u16; + let cursor_x = inner_area.x + 2 + app.editor.cursor_x as u16; let cursor_y = inner_area.y + (app.editor.cursor_y - app.editor.scroll_offset) as u16; if cursor_y < inner_area.y + inner_area.height { frame.set_cursor_position((cursor_x, cursor_y)); @@ -338,6 +344,7 @@ fn highlight_sql_line<'a>( theme: &Theme, line_number: usize, editor: &crate::editor::TextBuffer, + in_active_query: bool, ) -> Line<'a> { let mut spans: Vec = Vec::new(); let mut current_word = String::new(); @@ -345,6 +352,19 @@ fn highlight_sql_line<'a>( let mut string_char = '"'; let in_comment = false; + // Show a gutter marker for the active query block + if in_active_query { + spans.push(Span::styled( + "\u{2502} ".to_string(), // "│ " vertical bar + Style::default().fg(theme.text_accent), + )); + } else { + spans.push(Span::styled( + " ".to_string(), + Style::default().fg(theme.text_muted), + )); + } + for (i, c) in line.char_indices() { // Check for selection let is_selected = if let Some(((start_x, start_y), (end_x, end_y))) = editor.get_selection() @@ -471,9 +491,18 @@ fn draw_results(frame: &mut Frame, app: &App, area: Rect) { }; let result_total = app.results.len(); - // Build title with execution time and row count + // Build title with execution time, row count, and cell position let title = if let Some(result) = app.results.get(app.current_result) { let time_ms = result.execution_time.as_secs_f64() * 1000.0; + let position = if !result.columns.is_empty() && !result.rows.is_empty() { + format!( + " [R{}/C{}]", + app.result_selected_row + 1, + app.result_selected_col + 1 + ) + } else { + String::new() + }; if result.error.is_some() { format!( " Results ({}/{}) - ERROR ({:.2}ms) ", @@ -486,8 +515,13 @@ fn draw_results(frame: &mut Frame, app: &App, area: Rect) { ) } else { format!( - " Results ({}/{}) - {} rows ({:.2}ms) ", - result_index, result_total, result.row_count, time_ms + " Results ({}/{}) - {} rows x {} cols ({:.2}ms){} ", + result_index, + result_total, + result.row_count, + result.columns.len(), + time_ms, + position ) } } else { @@ -953,9 +987,10 @@ fn draw_help_overlay(frame: &mut Frame, app: &App) { " (Sidebar → Editor → Results → ...)", "", " EDITOR", - " F5/Ctrl+Enter Execute query", + " F5/Ctrl+Enter Execute query at cursor", " Ctrl+L Clear editor", " Ctrl+↑/↓ Navigate history", + " Ctrl+Shift+↑/↓ Resize editor/results", " Ctrl+C/X/V Copy/Cut/Paste", " Ctrl+A Select all", " Tab Insert spaces", @@ -966,7 +1001,9 @@ fn draw_help_overlay(frame: &mut Frame, app: &App) { " ↑/↓ Navigate", "", " RESULTS", + " Tab/Shift+Tab Next/Prev column", " Arrow keys Navigate cells", + " Esc Back to editor", " Ctrl+C Copy cell value", " Ctrl+[/] Prev/Next result set", " PageUp/Down Scroll results",