feat: add query editor above result rows

This commit is contained in:
Valdemar Lorenzen
2026-10-19 14:04:36 +02:00
parent b15460d5f8
commit 49a3dbddcf
5 changed files with 460 additions and 66 deletions
+124 -35
View File
@@ -13,14 +13,19 @@ use ratatui::{
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use crate::{Result, app::App, db::Database, error_text};
use crate::{
Result,
app::{App, Focus},
db::Database,
error_text,
};
pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<()> {
let mut profiles = Profiles::open(&config_path()?)?;
let mut browser = initial.map(App::new).transpose()?;
let mut selection =
ListState::default().with_selected((!profiles.entries.is_empty()).then_some(0));
let mut databases_focus = true;
let mut focus = Focus::Explorer;
let mut active = None;
let mut expanded = true;
let mut error: Option<String> = None;
@@ -34,9 +39,11 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
expanded,
);
terminal.draw(|frame| {
let navigation = Paragraph::new(
" Tab focus · Esc explorer · ↑↓/jk move · Enter open · s schema/rows · ←→/hl columns · n/p page · r refresh · c add database · q quit"
).wrap(Wrap { trim: true }).style(Style::default().fg(Color::DarkGray));
let navigation = Paragraph::new(if focus == Focus::Query && !show_form {
" Tab/Shift-Tab focus · Esc explorer · Enter newline · F5/Ctrl-Enter execute · Ctrl-U clear · Ctrl-C quit"
} else {
" Tab/Shift-Tab focus · Esc explorer · ↑↓/jk move · Enter open · s schema/rows · ←→/hl columns · n/p page · r refresh · c add database · q quit"
}).wrap(Wrap { trim: true }).style(Style::default().fg(Color::DarkGray));
let help_height = navigation.line_count(frame.area().width) as u16;
let [main, help] =
Layout::vertical([Constraint::Min(1), Constraint::Length(help_height)]).areas(frame.area());
@@ -57,11 +64,11 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
});
let list = List::new(items)
.block(Block::bordered().title(" Explorer · c add ").border_style(
Style::default().fg(if databases_focus { Color::Cyan } else { Color::DarkGray })))
Style::default().fg(if focus == Focus::Explorer { Color::Cyan } else { Color::DarkGray })))
.highlight_style(Style::default().bg(Color::Blue).fg(Color::White));
frame.render_stateful_widget(list, sidebar, &mut selection);
if let Some(browser) = &mut browser {
browser.draw(frame, content, !databases_focus);
browser.draw(frame, content, focus);
} else {
frame.render_widget(Paragraph::new("Select a database and press Enter to connect.\n\nc: add a database · Tab: focus · q: quit").block(Block::bordered().title(" dbdash · READ ONLY ")), content);
}
@@ -97,7 +104,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
});
selection.select(active);
expanded = true;
databases_focus = true;
focus = Focus::Explorer;
})
} else {
profiles.save(&form).map(|()| {
@@ -113,7 +120,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
profiles.entries.len() - 1,
))
}));
databases_focus = true;
focus = Focus::Explorer;
})
};
match result {
@@ -129,25 +136,32 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
}
} else {
match key.code {
KeyCode::Esc => focus = Focus::Explorer,
KeyCode::Tab | KeyCode::BackTab if browser.is_some() => {
focus = match (focus, key.code == KeyCode::BackTab) {
(Focus::Explorer, false) | (Focus::Rows, true) => Focus::Query,
(Focus::Query, false) | (Focus::Explorer, true) => Focus::Rows,
_ => Focus::Explorer,
};
}
_ if focus == Focus::Query => {
if let Some(browser) = &mut browser {
browser.handle_query_key(key);
}
}
KeyCode::Char('q') => return Ok(()),
KeyCode::Char('c') => {
form = Form::default();
show_form = true;
}
KeyCode::Esc => databases_focus = true,
KeyCode::Tab | KeyCode::BackTab => {
if browser.is_some() {
databases_focus = !databases_focus;
}
}
KeyCode::Enter if databases_focus => {
KeyCode::Enter if focus == Focus::Explorer => {
if let Some(entry) =
selection.selected().and_then(|index| entries.get(index))
{
let index = match *entry {
ExplorerEntry::Relation(index) => {
browser.as_mut().unwrap().open_relation(Some(index));
databases_focus = false;
focus = Focus::Rows;
continue;
}
ExplorerEntry::Database(index)
@@ -171,7 +185,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
active = Some(index);
selection.select(Some(index));
expanded = true;
databases_focus = true;
focus = Focus::Explorer;
error = None;
}
Err(failure) => error = Some(error_text(failure.as_ref())),
@@ -179,7 +193,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
}
}
}
KeyCode::Left | KeyCode::Char('h') if databases_focus => {
KeyCode::Left | KeyCode::Char('h') if focus == Focus::Explorer => {
if matches!(
selection.selected().and_then(|index| entries.get(index)),
Some(ExplorerEntry::Relation(_))
@@ -194,31 +208,31 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
);
}
}
KeyCode::Right | KeyCode::Char('l') if databases_focus => {
KeyCode::Right | KeyCode::Char('l') if focus == Focus::Explorer => {
if selection.selected().and_then(|index| entries.get(index))
== Some(&ExplorerEntry::Database(active))
{
expanded = true;
}
}
KeyCode::Char('r') if databases_focus => {
KeyCode::Char('r') if focus == Focus::Explorer => {
if let Some(browser) = &mut browser {
browser.handle_key(key);
}
}
KeyCode::Down | KeyCode::Char('j') if databases_focus => {
KeyCode::Down | KeyCode::Char('j') if focus == Focus::Explorer => {
selection.select_next()
}
KeyCode::Up | KeyCode::Char('k') if databases_focus => {
KeyCode::Up | KeyCode::Char('k') if focus == Focus::Explorer => {
selection.select_previous()
}
KeyCode::Home | KeyCode::Char('g') if databases_focus => {
KeyCode::Home | KeyCode::Char('g') if focus == Focus::Explorer => {
selection.select_first()
}
KeyCode::End | KeyCode::Char('G') if databases_focus => {
KeyCode::End | KeyCode::Char('G') if focus == Focus::Explorer => {
selection.select_last()
}
_ if !databases_focus => {
_ if focus == Focus::Rows => {
if let Some(browser) = &mut browser {
browser.handle_key(key);
}
@@ -233,6 +247,11 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
input.paste(&text);
}
}
Event::Paste(text) if focus == Focus::Query => {
if let Some(browser) = &mut browser {
browser.paste_query(&text);
}
}
_ => {}
}
}
@@ -617,8 +636,8 @@ impl Form {
}
#[derive(Clone, Default)]
struct Input {
value: String,
pub(crate) struct Input {
pub(crate) value: String,
cursor: usize,
}
@@ -632,7 +651,7 @@ impl From<&str> for Input {
}
impl Input {
fn edit(&mut self, key: KeyEvent) {
pub(crate) fn edit(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Left => {
self.cursor = self.value[..self.cursor]
@@ -646,8 +665,39 @@ impl Input {
.next()
.map_or(0, char::len_utf8)
}
KeyCode::Home => self.cursor = 0,
KeyCode::End => self.cursor = self.value.len(),
KeyCode::Up | KeyCode::Down => {
let start = self.value[..self.cursor].rfind('\n').map_or(0, |i| i + 1);
let column = self.value[start..self.cursor].chars().count();
let target = if key.code == KeyCode::Up {
start
.checked_sub(1)
.map(|end| (self.value[..end].rfind('\n').map_or(0, |i| i + 1), end))
} else {
self.value[self.cursor..].find('\n').map(|i| {
let start = self.cursor + i + 1;
let end = self.value[start..]
.find('\n')
.map_or(self.value.len(), |i| start + i);
(start, end)
})
};
if let Some((start, end)) = target {
self.cursor = start
+ self.value[start..end]
.chars()
.take(column)
.map(char::len_utf8)
.sum::<usize>();
}
}
KeyCode::Home => {
self.cursor = self.value[..self.cursor].rfind('\n').map_or(0, |i| i + 1);
}
KeyCode::End => {
self.cursor += self.value[self.cursor..]
.find('\n')
.unwrap_or(self.value.len() - self.cursor);
}
KeyCode::Backspace if self.cursor > 0 => {
let previous = self.value[..self.cursor]
.char_indices()
@@ -686,21 +736,41 @@ impl Input {
self.cursor += text.len();
}
fn draw(&self, frame: &mut Frame, area: Rect, label: &str, secret: bool, focused: bool) {
pub(crate) fn paste_multiline(&mut self, value: &str) {
let text: String = value
.chars()
.map(|character| if character == '\t' { ' ' } else { character })
.filter(|character| *character == '\n' || !character.is_control())
.collect();
self.value.insert_str(self.cursor, &text);
self.cursor += text.len();
}
pub(crate) fn draw(
&self,
frame: &mut Frame,
area: Rect,
label: &str,
secret: bool,
focused: bool,
) {
let text: Cow<'_, str> = if secret {
Cow::Owned("•".repeat(self.value.chars().count()))
} else {
Cow::Borrowed(&self.value)
};
let prefix = &self.value[..self.cursor];
let row = u16::try_from(prefix.matches('\n').count()).unwrap_or(u16::MAX);
let row_offset = row.saturating_sub(area.height.saturating_sub(3));
let before_cursor = if secret {
self.value[..self.cursor].chars().count()
} else {
Line::raw(&self.value[..self.cursor]).width()
Line::raw(prefix.rsplit('\n').next().unwrap_or("")).width()
};
let cursor = u16::try_from(before_cursor).unwrap_or(u16::MAX);
let offset = cursor.saturating_sub(area.width.saturating_sub(3));
frame.render_widget(
Paragraph::new(text).scroll((0, offset)).block(
Paragraph::new(text).scroll((row_offset, offset)).block(
Block::bordered()
.title(label)
.border_style(Style::default().fg(if focused {
@@ -712,7 +782,8 @@ impl Input {
area,
);
if focused && area.width > 2 && area.height > 2 {
frame.set_cursor_position((area.x + 1 + cursor - offset, area.y + 1));
frame
.set_cursor_position((area.x + 1 + cursor - offset, area.y + 1 + row - row_offset));
}
}
}
@@ -765,6 +836,24 @@ mod tests {
assert_eq!(input.cursor, 0);
}
#[test]
fn multiline_editing_preserves_lines_and_unicode() {
let mut input = Input::default();
input.paste_multiline("aé界\nxy\nz\r\n\t\u{1b}");
assert_eq!(input.value, "aé界\nxy\nz\n ");
for code in [KeyCode::Up, KeyCode::Up, KeyCode::Up] {
input.edit(KeyEvent::new(code, KeyModifiers::NONE));
}
assert_eq!(&input.value[..input.cursor], "a");
input.edit(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
input.edit(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(&input.value[..input.cursor], "aé界\nxy");
input.edit(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
input.edit(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(input.value, "aé界xy\nz\n ");
assert_eq!(input.cursor, "aé界".len());
}
#[test]
fn password_never_appears_in_the_connection_screen() -> Result<()> {
let mut form = Form {