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
262 changes: 258 additions & 4 deletions src/ui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::db::{
};
use crate::editor::{HistoryEntry, QueryHistory, TextBuffer};
use crate::explain::{is_explain_query, parse_explain_output, QueryPlan};
use crate::ui::Theme;
use crate::ui::{Theme, SQL_KEYWORDS, SQL_TYPES};

pub const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];

Expand Down Expand Up @@ -140,6 +140,9 @@ pub struct App {
// Help
pub show_help: bool,

// Autocomplete
pub autocomplete: AutocompleteState,

// EXPLAIN plan
pub explain_plans: Vec<Option<QueryPlan>>,
pub show_visual_plan: bool,
Expand Down Expand Up @@ -235,6 +238,103 @@ impl Default for ConnectionDialogState {
}
}

#[derive(Debug, Clone)]
pub struct AutocompleteSuggestion {
pub text: String,
pub kind: SuggestionKind,
}

#[derive(Debug, Clone, Copy, PartialEq)]
#[allow(dead_code)]
pub enum SuggestionKind {
Keyword,
Type,
Table,
Column,
Function,
}

impl SuggestionKind {
pub fn label(self) -> &'static str {
match self {
SuggestionKind::Keyword => "KW",
SuggestionKind::Type => "TY",
SuggestionKind::Table => "TB",
SuggestionKind::Column => "CL",
SuggestionKind::Function => "FN",
}
}
}

#[derive(Debug, Clone, Default)]
pub struct AutocompleteState {
pub active: bool,
pub suggestions: Vec<AutocompleteSuggestion>,
pub selected: usize,
pub prefix: String,
}

pub const SQL_FUNCTIONS: &[&str] = &[
"COUNT",
"SUM",
"AVG",
"MIN",
"MAX",
"COALESCE",
"NULLIF",
"CAST",
"NOW",
"CURRENT_DATE",
"CURRENT_TIMESTAMP",
"EXTRACT",
"DATE_TRUNC",
"TO_CHAR",
"TO_DATE",
"TO_NUMBER",
"TO_TIMESTAMP",
"CONCAT",
"LENGTH",
"LOWER",
"UPPER",
"TRIM",
"SUBSTRING",
"REPLACE",
"POSITION",
"LEFT",
"RIGHT",
"LPAD",
"RPAD",
"SPLIT_PART",
"STRING_AGG",
"ARRAY_AGG",
"JSON_AGG",
"JSONB_AGG",
"JSON_BUILD_OBJECT",
"JSONB_BUILD_OBJECT",
"ROW_NUMBER",
"RANK",
"DENSE_RANK",
"LAG",
"LEAD",
"FIRST_VALUE",
"LAST_VALUE",
"NTILE",
"GREATEST",
"LEAST",
"ABS",
"CEIL",
"FLOOR",
"ROUND",
"MOD",
"POWER",
"SQRT",
"RANDOM",
"GEN_RANDOM_UUID",
"PG_SIZE_PRETTY",
"PG_TOTAL_RELATION_SIZE",
"PG_RELATION_SIZE",
];

impl App {
pub fn new() -> Self {
let query_history = QueryHistory::load().unwrap_or_default();
Expand Down Expand Up @@ -310,6 +410,7 @@ impl App {
loading_message: String::new(),
spinner_frame: 0,
show_help: false,
autocomplete: AutocompleteState::default(),

explain_plans: Vec::new(),
show_visual_plan: true,
Expand Down Expand Up @@ -723,6 +824,46 @@ impl App {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);

// Handle autocomplete navigation when active
if self.autocomplete.active {
match key.code {
KeyCode::Tab | KeyCode::Enter => {
self.accept_autocomplete();
return Ok(());
}
KeyCode::Esc => {
self.autocomplete.active = false;
return Ok(());
}
KeyCode::Up => {
if self.autocomplete.selected > 0 {
self.autocomplete.selected -= 1;
}
return Ok(());
}
KeyCode::Down => {
if self.autocomplete.selected
< self.autocomplete.suggestions.len().saturating_sub(1)
{
self.autocomplete.selected += 1;
}
return Ok(());
}
_ => {
// Fall through to normal handling, but dismiss autocomplete for non-text keys
if !matches!(key.code, KeyCode::Char(_) | KeyCode::Backspace) {
self.autocomplete.active = false;
}
}
}
}

// Ctrl+Space triggers autocomplete
if ctrl && key.code == KeyCode::Char(' ') {
self.update_autocomplete();
return Ok(());
}

match key.code {
KeyCode::Tab if !ctrl => {
if shift {
Expand All @@ -735,17 +876,18 @@ impl App {
self.focus = Focus::Sidebar;
}
KeyCode::Enter if ctrl => {
// Execute query at cursor
self.autocomplete.active = false;
self.execute_query().await?;
self.focus = Focus::Results;
}
KeyCode::F(5) => {
// F5 also executes query (works in all terminals)
self.autocomplete.active = false;
self.execute_query().await?;
self.focus = Focus::Results;
}
KeyCode::Enter => {
self.editor.insert_newline();
self.autocomplete.active = false;
}
KeyCode::Char('c') if ctrl => {
self.editor.copy();
Expand All @@ -769,8 +911,8 @@ impl App {
self.editor.redo();
}
KeyCode::Char('l') if ctrl => {
// Clear editor
self.editor.clear();
self.autocomplete.active = false;
}
// Pane resizing: Ctrl+Shift+Up/Down
KeyCode::Up if ctrl && shift => {
Expand Down Expand Up @@ -798,30 +940,38 @@ impl App {
}
KeyCode::Char(c) => {
self.editor.insert_char(c);
self.update_autocomplete();
}
KeyCode::Backspace => {
self.editor.backspace();
self.update_autocomplete();
}
KeyCode::Delete => {
self.editor.delete();
}
KeyCode::Left if ctrl => {
self.editor.move_word_left();
self.autocomplete.active = false;
}
KeyCode::Right if ctrl => {
self.editor.move_word_right();
self.autocomplete.active = false;
}
KeyCode::Left => {
self.editor.move_left();
self.autocomplete.active = false;
}
KeyCode::Right => {
self.editor.move_right();
self.autocomplete.active = false;
}
KeyCode::Up => {
self.editor.move_up();
self.autocomplete.active = false;
}
KeyCode::Down => {
self.editor.move_down();
self.autocomplete.active = false;
}
KeyCode::Home if ctrl => {
self.editor.move_to_start();
Expand Down Expand Up @@ -1554,6 +1704,110 @@ impl App {
Ok(())
}

fn update_autocomplete(&mut self) {
let line = self.editor.current_line().to_string();
let cursor_x = self.editor.cursor_x;

// Extract the word being typed (prefix), including dots for schema.table
let before_cursor = &line[..cursor_x.min(line.len())];
let prefix_start = before_cursor
.rfind(|c: char| !c.is_alphanumeric() && c != '_' && c != '.')
.map(|i| i + 1)
.unwrap_or(0);
let prefix = &before_cursor[prefix_start..];

if prefix.len() < 2 {
self.autocomplete.active = false;
return;
}

let prefix_upper = prefix.to_uppercase();
let prefix_lower = prefix.to_lowercase();

let mut suggestions: Vec<AutocompleteSuggestion> = Vec::new();

// Table names from loaded schema (schema-qualified)
let mut seen_tables = std::collections::HashSet::new();
for table in &self.tables {
let qualified = format!("{}.{}", table.schema, table.name);
if seen_tables.contains(&qualified) {
continue;
}
// Match on bare table name OR schema.table qualified name
if table.name.to_lowercase().starts_with(&prefix_lower)
|| qualified.to_lowercase().starts_with(&prefix_lower)
{
suggestions.push(AutocompleteSuggestion {
text: qualified.clone(),
kind: SuggestionKind::Table,
});
seen_tables.insert(qualified);
}
}

// SQL keywords
for &kw in SQL_KEYWORDS {
if kw.starts_with(&prefix_upper) {
suggestions.push(AutocompleteSuggestion {
text: kw.to_string(),
kind: SuggestionKind::Keyword,
});
}
}

// SQL types
for &ty in SQL_TYPES {
if ty.starts_with(&prefix_upper) {
suggestions.push(AutocompleteSuggestion {
text: ty.to_string(),
kind: SuggestionKind::Type,
});
}
}

// SQL functions
for &func in SQL_FUNCTIONS {
if func.starts_with(&prefix_upper) {
suggestions.push(AutocompleteSuggestion {
text: format!("{}()", func),
kind: SuggestionKind::Function,
});
}
}

// Limit to 10 suggestions
suggestions.truncate(10);

if suggestions.is_empty() {
self.autocomplete.active = false;
} else {
self.autocomplete.active = true;
self.autocomplete.suggestions = suggestions;
self.autocomplete.selected = 0;
self.autocomplete.prefix = prefix.to_string();
}
}

fn accept_autocomplete(&mut self) {
if let Some(suggestion) = self
.autocomplete
.suggestions
.get(self.autocomplete.selected)
{
let text = suggestion.text.clone();
let prefix_len = self.autocomplete.prefix.len();

// Delete the prefix
for _ in 0..prefix_len {
self.editor.backspace();
}

// Insert the suggestion
self.editor.insert_text(&text);
}
self.autocomplete.active = false;
}

fn copy_selected_cell(&mut self) {
if let Some(result) = self.results.get(self.current_result) {
if let Some(row) = result.rows.get(self.result_selected_row) {
Expand Down
Loading