feat: add query editor above result rows
This commit is contained in:
+124
-35
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user