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
197 changes: 190 additions & 7 deletions src/ui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ pub struct App {
pub editor: TextBuffer,
pub query_history: QueryHistory,

// Layout
pub editor_height_percent: u16,

// Results
pub results: Vec<QueryResult>,
pub current_result: usize,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand All @@ -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();
}
}
}
Expand All @@ -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) => {
Expand All @@ -808,20 +845,36 @@ 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) => {
if self.current_result < self.results.len().saturating_sub(1) {
self.current_result += 1;
self.result_selected_row = 0;
self.result_selected_col = 0;
self.result_scroll_y = 0;
}
}
_ => {}
}
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') => {
Expand Down Expand Up @@ -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<char> = 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(());
}
Expand Down
Loading