feat: add modal query editing and vamp-style status bars
This commit is contained in:
@@ -1,17 +1,17 @@
|
||||
- [ ] adopt vamp-style top and bottom bars with a fixed layout, not vamp's configurable segment/tab system.
|
||||
- [ ] render full-width bars in the outer shell (`src/connection.rs`) before splitting explorer/content; remove the old content-only header (`src/app.rs`, see above).
|
||||
- [ ] top: `dbdash` badge with vamp-style `▓░` trailing edge; retain provider and READ ONLY information without tab numbering.
|
||||
- [ ] bottom-left: current mode badge with `▓░` trailing edge, followed by the connected database name; bottom-right: focused input's one-based `line:column` with `░▓` leading edge.
|
||||
- [ ] capture a safe database display name on successful saved-profile and CLI connections, including SQLite; use the connected database, not explorer selection or `Database::name()` (which returns the provider). Never expose raw connection strings/passwords; show a clear disconnected state and retain the previous identity after a failed switch.
|
||||
- [ ] add Normal/Insert query editing with mode-aware key routing; define entry/navigation keys and make Esc leave Insert before returning to the explorer. Preserve query execution, multiline editing, paste, and focus cycling.
|
||||
- [ ] keep connection/password forms direct-entry for now; make the mode indicator reflect the focused surface rather than a stale query mode.
|
||||
- [ ] expose position from the existing `Input` cursor instead of duplicating state; follow the focused query/form field, hide position outside inputs, and define Unicode column semantics consistently.
|
||||
- [ ] keep relation/page/column status in pane borders and accommodate the separate help pane; do not add tabs, title templates, custom segments, or editor-specific status.
|
||||
- [ ] handle narrow/short terminals without overlapping content: truncate the database label before mode/position, and let bars yield to content when height is insufficient.
|
||||
- [ ] verify mode transitions/key precedence, focused-input Unicode positions, connection identity on successful/failed switches, and constrained layouts; smoke-run the TUI and ask for a visual check.
|
||||
- [ ] pressing `i` on a configured database connection should bring up a floating window with details/info on that connection.
|
||||
- [ ] show a `*` to the right of the connected db in the sidebar.
|
||||
- [ ] use the proper ending character in the family of `├` instead of that character all the way down.
|
||||
- [ ] remove this top-most line above the panels on the right-hand side: `dbdash │ SQLite │ READ ONLY`.
|
||||
- [ ] highlight the current cell isntead of just the current row in the results pane.
|
||||
- [ ] put the help text inside it's own pane so it fits the design more cleanly.
|
||||
- [x] adopt vamp-style top and bottom bars with a fixed layout, not vamp's configurable segment/tab system.
|
||||
- [x] render full-width bars in the outer shell (`src/connection.rs`) before splitting explorer/content; remove the old content-only header (`src/app.rs`, see above).
|
||||
- [x] top: `dbdash` badge with vamp-style `▓░` trailing edge; retain provider and READ ONLY information without tab numbering.
|
||||
- [x] bottom-left: current mode badge with `▓░` trailing edge, followed by the connected database name; bottom-right: focused input's one-based `line:column` with `░▓` leading edge.
|
||||
- [x] capture a safe database display name on successful saved-profile and CLI connections, including SQLite; use the connected database, not explorer selection or `Database::name()` (which returns the provider). Never expose raw connection strings/passwords; show a clear disconnected state and retain the previous identity after a failed switch.
|
||||
- [x] add Normal/Insert query editing with mode-aware key routing; define entry/navigation keys and make Esc leave Insert before returning to the explorer. Preserve query execution, multiline editing, paste, and focus cycling.
|
||||
- [x] keep connection/password forms direct-entry for now; make the mode indicator reflect the focused surface rather than a stale query mode.
|
||||
- [x] expose position from the existing `Input` cursor instead of duplicating state; follow the focused query/form field, hide position outside inputs, and define Unicode column semantics consistently.
|
||||
- [x] keep relation/page/column status in pane borders and accommodate the separate help pane; do not add tabs, title templates, custom segments, or editor-specific status.
|
||||
- [x] handle narrow/short terminals without overlapping content: truncate the database label before mode/position, and let bars yield to content when height is insufficient.
|
||||
- [x] verify mode transitions/key precedence, focused-input Unicode positions, connection identity on successful/failed switches, and constrained layouts; smoke-run the TUI and ask for a visual check.
|
||||
- [x] pressing `i` on a configured database connection should bring up a floating window with details/info on that connection.
|
||||
- [x] show a `*` to the right of the connected db in the sidebar.
|
||||
- [x] use the proper ending character in the family of `├` instead of that character all the way down.
|
||||
- [x] remove this top-most line above the panels on the right-hand side: `dbdash │ SQLite │ READ ONLY`.
|
||||
- [x] highlight the current cell isntead of just the current row in the results pane.
|
||||
- [x] put the help text inside it's own pane so it fits the design more cleanly.
|
||||
+248
-49
@@ -33,6 +33,7 @@ pub struct App {
|
||||
column_offset: usize,
|
||||
show_schema: bool,
|
||||
query: Input,
|
||||
query_insert: bool,
|
||||
query_highlights: sql::Highlights,
|
||||
query_result: Option<QueryResult>,
|
||||
error: Option<String>,
|
||||
@@ -57,6 +58,7 @@ impl App {
|
||||
column_offset: 0,
|
||||
show_schema: false,
|
||||
query: Input::default(),
|
||||
query_insert: false,
|
||||
query_highlights: Vec::new(),
|
||||
query_result: None,
|
||||
error: None,
|
||||
@@ -65,6 +67,26 @@ impl App {
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
pub(crate) fn provider(&self) -> &'static str {
|
||||
self.db.name()
|
||||
}
|
||||
|
||||
pub(crate) fn database_name(&self) -> &str {
|
||||
self.db.display_name()
|
||||
}
|
||||
|
||||
pub(crate) fn query_insert_mode(&self) -> bool {
|
||||
self.query_insert
|
||||
}
|
||||
|
||||
pub(crate) fn leave_query_insert(&mut self) -> bool {
|
||||
std::mem::replace(&mut self.query_insert, false)
|
||||
}
|
||||
|
||||
pub(crate) fn query_position(&self) -> (usize, usize) {
|
||||
self.query.position()
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key(&mut self, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
|
||||
@@ -75,21 +97,8 @@ impl App {
|
||||
self.show_schema = !self.show_schema;
|
||||
self.column_offset = 0;
|
||||
}
|
||||
KeyCode::Left | KeyCode::Char('h') => {
|
||||
self.column_offset = self.column_offset.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Right | KeyCode::Char('l') => {
|
||||
let count = if self.show_schema {
|
||||
5
|
||||
} else {
|
||||
self.query_result
|
||||
.as_ref()
|
||||
.map_or(self.columns.len(), |result| result.headers.len())
|
||||
};
|
||||
if self.column_offset + 1 < count {
|
||||
self.column_offset += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Left | KeyCode::Char('h') => self.move_column(-1),
|
||||
KeyCode::Right | KeyCode::Char('l') => self.move_column(1),
|
||||
KeyCode::Char('n') | KeyCode::PageDown
|
||||
if self.query_result.is_none() && !self.show_schema && self.page.has_next =>
|
||||
{
|
||||
@@ -110,13 +119,54 @@ impl App {
|
||||
match key.code {
|
||||
KeyCode::F(5) => self.execute_query(),
|
||||
KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => self.execute_query(),
|
||||
KeyCode::Enter => self.query.paste_multiline("\n"),
|
||||
_ => self.query.edit(key),
|
||||
KeyCode::Esc => {
|
||||
self.leave_query_insert();
|
||||
}
|
||||
KeyCode::Enter if self.query_insert => self.query.paste_multiline("\n"),
|
||||
_ if self.query_insert => self.query.edit(key),
|
||||
KeyCode::Left
|
||||
| KeyCode::Right
|
||||
| KeyCode::Up
|
||||
| KeyCode::Down
|
||||
| KeyCode::Home
|
||||
| KeyCode::End => self.query.edit(key),
|
||||
KeyCode::Char(character)
|
||||
if !key
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
|
||||
{
|
||||
let movement = match character {
|
||||
'h' => Some(KeyCode::Left),
|
||||
'j' => Some(KeyCode::Down),
|
||||
'k' => Some(KeyCode::Up),
|
||||
'l' => Some(KeyCode::Right),
|
||||
'a' => {
|
||||
let (line, column) = self.query_position();
|
||||
self.query
|
||||
.value
|
||||
.split('\n')
|
||||
.nth(line - 1)
|
||||
.filter(|text| column <= text.chars().count())
|
||||
.map(|_| KeyCode::Right)
|
||||
}
|
||||
'I' => Some(KeyCode::Home),
|
||||
'A' => Some(KeyCode::End),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(code) = movement {
|
||||
self.query.edit(KeyEvent::new(code, KeyModifiers::NONE));
|
||||
}
|
||||
self.query_insert = matches!(character, 'i' | 'a' | 'I' | 'A');
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.highlight_query(previous_len);
|
||||
}
|
||||
|
||||
pub(crate) fn paste_query(&mut self, text: &str) {
|
||||
if !self.query_insert {
|
||||
return;
|
||||
}
|
||||
let previous_len = self.query.value.len();
|
||||
self.query.paste_multiline(text);
|
||||
self.highlight_query(previous_len);
|
||||
@@ -133,7 +183,8 @@ impl App {
|
||||
match self.db.query(&self.query.value) {
|
||||
Ok(result) => {
|
||||
self.rows_state = TableState::default()
|
||||
.with_selected((!result.page.rows.is_empty()).then_some(0));
|
||||
.with_selected((!result.page.rows.is_empty()).then_some(0))
|
||||
.with_selected_column((!result.headers.is_empty()).then_some(0));
|
||||
self.column_offset = 0;
|
||||
self.show_schema = false;
|
||||
self.query_result = Some(result);
|
||||
@@ -157,6 +208,26 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn column_count(&self) -> usize {
|
||||
if self.show_schema {
|
||||
5
|
||||
} else {
|
||||
self.query_result
|
||||
.as_ref()
|
||||
.map_or(self.columns.len(), |result| result.headers.len())
|
||||
}
|
||||
}
|
||||
|
||||
fn move_column(&mut self, delta: isize) {
|
||||
let count = self.column_count();
|
||||
let state = if self.show_schema {
|
||||
&mut self.schema_state
|
||||
} else {
|
||||
&mut self.rows_state
|
||||
};
|
||||
state.select_column(moved_index(state.selected_column(), count, delta));
|
||||
}
|
||||
|
||||
pub(crate) fn open_relation(&mut self, index: Option<usize>) {
|
||||
self.active = index;
|
||||
self.query_result = None;
|
||||
@@ -176,6 +247,8 @@ impl App {
|
||||
self.columns = columns;
|
||||
self.schema_state
|
||||
.select((!self.columns.is_empty()).then_some(0));
|
||||
self.schema_state
|
||||
.select_column((!self.columns.is_empty()).then_some(0));
|
||||
self.load_page(0);
|
||||
}
|
||||
Err(error) => self.error = Some(error_text(error.as_ref())),
|
||||
@@ -188,8 +261,13 @@ impl App {
|
||||
// ponytail: synchronous reads block input; use a worker if responsiveness matters.
|
||||
match self.db.page(&self.relations[active], &self.columns, index) {
|
||||
Ok(page) => {
|
||||
self.rows_state =
|
||||
TableState::default().with_selected((!page.rows.is_empty()).then_some(0));
|
||||
self.rows_state = TableState::default()
|
||||
.with_selected((!page.rows.is_empty()).then_some(0))
|
||||
.with_selected_column(
|
||||
self.rows_state
|
||||
.selected_column()
|
||||
.or_else(|| (!self.columns.is_empty()).then_some(0)),
|
||||
);
|
||||
self.page = page;
|
||||
self.page_index = index;
|
||||
self.error = None;
|
||||
@@ -217,26 +295,16 @@ impl App {
|
||||
pub(crate) fn draw(&mut self, frame: &mut Frame, area: Rect, focus: Focus) {
|
||||
if area.width < 38 || area.height < 10 {
|
||||
frame.render_widget(
|
||||
Paragraph::new("Enlarge terminal to at least 70×14. Ctrl-C to quit."),
|
||||
Paragraph::new("Enlarge terminal to at least 70×17. Ctrl-C to quit."),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let [header, error_area, body] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
let [error_area, body] = Layout::vertical([
|
||||
Constraint::Length(u16::from(self.error.is_some())),
|
||||
Constraint::Min(4),
|
||||
])
|
||||
.areas(area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from_iter([
|
||||
" dbdash │ ",
|
||||
self.db.name(),
|
||||
" │ READ ONLY",
|
||||
]))
|
||||
.style(Style::default().fg(Color::Cyan)),
|
||||
header,
|
||||
);
|
||||
let [query, content] =
|
||||
Layout::vertical([Constraint::Ratio(1, 2), Constraint::Min(4)]).areas(body);
|
||||
self.query.draw(
|
||||
@@ -254,16 +322,20 @@ impl App {
|
||||
} else {
|
||||
" Rows · s for schema "
|
||||
};
|
||||
let count = if self.show_schema {
|
||||
5
|
||||
let count = self.column_count();
|
||||
let capacity = usize::from(content.width.saturating_sub(2) / 21).max(1);
|
||||
let selected = if self.show_schema {
|
||||
self.schema_state.selected_column()
|
||||
} else {
|
||||
self.query_result
|
||||
.as_ref()
|
||||
.map_or(self.columns.len(), |result| result.headers.len())
|
||||
self.rows_state.selected_column()
|
||||
};
|
||||
let visible = usize::from(content.width.saturating_sub(4) / 21)
|
||||
.max(1)
|
||||
.min(count.saturating_sub(self.column_offset));
|
||||
if let Some(selected) = selected {
|
||||
self.column_offset = self.column_offset.min(selected);
|
||||
if selected >= self.column_offset + capacity {
|
||||
self.column_offset = selected + 1 - capacity;
|
||||
}
|
||||
}
|
||||
let visible = capacity.min(count.saturating_sub(self.column_offset));
|
||||
let widths = (0..visible).map(|index| {
|
||||
if index + 1 == visible {
|
||||
Constraint::Min(1)
|
||||
@@ -349,9 +421,11 @@ impl App {
|
||||
.style(header_style()),
|
||||
)
|
||||
.block(content_block)
|
||||
.row_highlight_style(selected_style())
|
||||
.highlight_symbol("› ");
|
||||
.cell_highlight_style(selected_style());
|
||||
self.schema_state
|
||||
.select_column(selected.map(|column| column - self.column_offset));
|
||||
frame.render_stateful_widget(table, content, &mut self.schema_state);
|
||||
self.schema_state.select_column(selected);
|
||||
} else if page.rows.is_empty() {
|
||||
frame.render_widget(
|
||||
Paragraph::new(if self.error.is_some() {
|
||||
@@ -387,9 +461,11 @@ impl App {
|
||||
let table = Table::new(rows, widths)
|
||||
.header(Row::new(headers).style(header_style()))
|
||||
.block(content_block)
|
||||
.row_highlight_style(selected_style())
|
||||
.highlight_symbol("› ");
|
||||
.cell_highlight_style(selected_style());
|
||||
self.rows_state
|
||||
.select_column(selected.map(|column| column - self.column_offset));
|
||||
frame.render_stateful_widget(table, content, &mut self.rows_state);
|
||||
self.rows_state.select_column(selected);
|
||||
}
|
||||
if let Some(error) = &self.error {
|
||||
frame.render_widget(
|
||||
@@ -446,6 +522,124 @@ mod tests {
|
||||
assert_eq!(moved_index(Some(1), 3, isize::MAX), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_modes_preserve_literal_text_and_navigation() -> crate::Result<()> {
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
let file = tempfile::NamedTempFile::new()?;
|
||||
let mut app = super::App::new(crate::db::Database::open_sqlite(file.path())?)?;
|
||||
let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE);
|
||||
assert!(!app.query_insert_mode());
|
||||
app.paste_query("ignored");
|
||||
app.handle_query_key(key('q'));
|
||||
assert!(app.query.value.is_empty());
|
||||
assert!(!app.leave_query_insert());
|
||||
app.handle_query_key(key('i'));
|
||||
for character in ['q', 'i', 'h'] {
|
||||
app.handle_query_key(key(character));
|
||||
}
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
app.paste_query("界é");
|
||||
assert_eq!(app.query.value, "qih\n界é");
|
||||
assert_eq!(app.query_position(), (2, 3));
|
||||
assert!(app.leave_query_insert());
|
||||
app.paste_query("ignored");
|
||||
app.handle_query_key(key('h'));
|
||||
assert_eq!(app.query_position(), (2, 2));
|
||||
app.handle_query_key(key('k'));
|
||||
assert_eq!(app.query_position(), (1, 2));
|
||||
app.handle_query_key(key('a'));
|
||||
assert!(app.query_insert_mode());
|
||||
assert_eq!(app.query_position(), (1, 3));
|
||||
app.leave_query_insert();
|
||||
app.handle_query_key(key('I'));
|
||||
assert_eq!(app.query_position(), (1, 1));
|
||||
assert!(app.query_insert_mode());
|
||||
app.leave_query_insert();
|
||||
app.handle_query_key(key('A'));
|
||||
assert_eq!(app.query_position(), (1, 4));
|
||||
assert!(app.query_insert_mode());
|
||||
app.leave_query_insert();
|
||||
app.handle_query_key(key('a'));
|
||||
assert_eq!(app.query_position(), (1, 4));
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(!app.query_insert_mode());
|
||||
assert_eq!(app.query.value, "qih\n界é");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_shortcuts_work_in_both_query_modes() -> crate::Result<()> {
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
let file = tempfile::NamedTempFile::new()?;
|
||||
let mut app = super::App::new(crate::db::Database::open_sqlite(file.path())?)?;
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
app.paste_query("SELECT\n42");
|
||||
for insert in [true, false] {
|
||||
if !insert {
|
||||
app.leave_query_insert();
|
||||
}
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL),
|
||||
] {
|
||||
app.query_result = None;
|
||||
app.handle_query_key(key);
|
||||
assert_eq!(app.query_insert_mode(), insert);
|
||||
assert_eq!(
|
||||
app.query_result.as_ref().unwrap().page.rows[0][0].as_deref(),
|
||||
Some("42")
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_selection_scrolls_and_survives_paging_and_schema_switches() -> crate::Result<()> {
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::{Terminal, backend::TestBackend, style::Color};
|
||||
let file = tempfile::NamedTempFile::new()?;
|
||||
rusqlite::Connection::open(file.path())?.execute_batch(
|
||||
"CREATE TABLE cells (a, b, c, d, e, f);
|
||||
WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x<101)
|
||||
INSERT INTO cells SELECT x, x, x, x, x, x FROM n;",
|
||||
)?;
|
||||
let mut app = super::App::new(crate::db::Database::open_sqlite(file.path())?)?;
|
||||
let mut terminal = Terminal::new(TestBackend::new(60, 20))?;
|
||||
let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE);
|
||||
assert_eq!(app.rows_state.selected_cell(), Some((0, 0)));
|
||||
terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?;
|
||||
assert_eq!(terminal.backend().buffer()[(1, 12)].bg, Color::Blue);
|
||||
assert_ne!(terminal.backend().buffer()[(22, 12)].bg, Color::Blue);
|
||||
app.handle_key(key('l'));
|
||||
app.handle_key(key('l'));
|
||||
app.handle_key(key('j'));
|
||||
terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?;
|
||||
assert_eq!(app.rows_state.selected_cell(), Some((1, 2)));
|
||||
assert_eq!(app.column_offset, 1);
|
||||
assert_eq!(terminal.backend().buffer()[(22, 13)].bg, Color::Blue);
|
||||
assert_ne!(terminal.backend().buffer()[(1, 13)].bg, Color::Blue);
|
||||
assert_ne!(terminal.backend().buffer()[(22, 12)].bg, Color::Blue);
|
||||
app.handle_key(key('n'));
|
||||
assert_eq!(app.page_index, 1);
|
||||
assert_eq!(app.rows_state.selected_cell(), Some((0, 2)));
|
||||
app.handle_key(key('s'));
|
||||
app.handle_key(key('l'));
|
||||
app.handle_key(key('j'));
|
||||
terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?;
|
||||
assert_eq!(app.schema_state.selected_cell(), Some((1, 1)));
|
||||
assert_eq!(app.column_offset, 0);
|
||||
assert_eq!(terminal.backend().buffer()[(22, 13)].bg, Color::Blue);
|
||||
assert_ne!(terminal.backend().buffer()[(1, 13)].bg, Color::Blue);
|
||||
app.handle_key(key('s'));
|
||||
terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?;
|
||||
assert_eq!(app.rows_state.selected_cell(), Some((0, 2)));
|
||||
assert_eq!(app.column_offset, 1);
|
||||
app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
|
||||
assert_eq!(app.rows_state.selected_column(), Some(1));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_colors_survive_editing_and_scrolling() -> crate::Result<()> {
|
||||
use super::{App, Focus};
|
||||
@@ -454,17 +648,18 @@ mod tests {
|
||||
let file = tempfile::NamedTempFile::new()?;
|
||||
let mut app = App::new(crate::db::Database::open_sqlite(file.path())?)?;
|
||||
let mut terminal = Terminal::new(TestBackend::new(60, 14))?;
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
app.paste_query("SELECT '界', 42;\n-- comment");
|
||||
terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?;
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer[(1, 2)].fg, Color::Cyan);
|
||||
assert_eq!(buffer[(8, 2)].fg, Color::Green);
|
||||
assert_eq!(buffer[(1, 3)].fg, Color::DarkGray);
|
||||
assert_eq!(buffer[(1, 1)].fg, Color::Cyan);
|
||||
assert_eq!(buffer[(8, 1)].fg, Color::Green);
|
||||
assert_eq!(buffer[(1, 2)].fg, Color::DarkGray);
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE));
|
||||
app.paste_query("unfinished\nstring");
|
||||
terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?;
|
||||
assert_eq!(terminal.backend().buffer()[(1, 3)].fg, Color::Green);
|
||||
assert_eq!(terminal.backend().buffer()[(1, 2)].fg, Color::Green);
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
||||
app.paste_query(&format!("{}--{}", "-- line\n".repeat(12), "x".repeat(90)));
|
||||
terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?;
|
||||
@@ -484,11 +679,15 @@ mod tests {
|
||||
"CREATE TABLE comments (id INTEGER); INSERT INTO comments VALUES (9);",
|
||||
)?;
|
||||
let mut app = super::App::new(crate::db::Database::open_sqlite(file.path())?)?;
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
app.paste_query("SELECT '界é' AS note,\nNULL AS missing UNION ALL SELECT 'next', 2;");
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE));
|
||||
let result = app.query_result.as_ref().unwrap();
|
||||
assert_eq!(result.headers, ["note", "missing"]);
|
||||
assert_eq!(result.page.rows[0], [Some("界é".into()), None]);
|
||||
assert_eq!(app.rows_state.selected_cell(), Some((0, 0)));
|
||||
app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
|
||||
assert_eq!(app.rows_state.selected_cell(), Some((0, 1)));
|
||||
app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
assert_eq!(app.rows_state.selected(), Some(1));
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
||||
@@ -529,7 +728,7 @@ mod tests {
|
||||
"rows 1–1"
|
||||
}));
|
||||
if let Some(error) = error {
|
||||
let message: String = (0..100).map(|x| buffer[(x, 1)].symbol()).collect();
|
||||
let message: String = (0..100).map(|x| buffer[(x, 0)].symbol()).collect();
|
||||
assert!(message.starts_with(error), "{message}");
|
||||
}
|
||||
}
|
||||
|
||||
+306
-15
@@ -6,7 +6,7 @@ use ratatui::{
|
||||
DefaultTerminal, Frame,
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Text},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
let mut error: Option<String> = None;
|
||||
let mut form = Form::default();
|
||||
let mut show_form = false;
|
||||
let mut info: Option<usize> = None;
|
||||
loop {
|
||||
let entries = explorer_entries(
|
||||
profiles.entries.len(),
|
||||
@@ -36,14 +37,38 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
expanded,
|
||||
);
|
||||
terminal.draw(|frame| {
|
||||
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"
|
||||
let [top, workspace, bottom] = shell_areas(frame.area());
|
||||
let position = if info.is_some() {
|
||||
None
|
||||
} else if show_form {
|
||||
form.input().map(Input::position)
|
||||
} 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"
|
||||
browser.as_ref().filter(|_| focus == Focus::Query).map(App::query_position)
|
||||
};
|
||||
let insert = if show_form {
|
||||
form.input().is_some()
|
||||
} else {
|
||||
info.is_none() && focus == Focus::Query
|
||||
&& browser.as_ref().is_some_and(App::query_insert_mode)
|
||||
};
|
||||
let name = browser.as_ref().map_or("Not connected", App::database_name);
|
||||
draw_bars(frame, top, bottom, browser.as_ref().map_or("—", App::provider), name, insert, position);
|
||||
let navigation = Paragraph::new(if show_form {
|
||||
"Tab/Shift-Tab fields · Enter save/connect · Esc cancel · Ctrl-C quit"
|
||||
} else if focus == Focus::Query && insert {
|
||||
"Esc normal · Tab/Shift-Tab focus · Enter newline · F5/Ctrl-Enter execute · Ctrl-U clear · Ctrl-C quit"
|
||||
} else if focus == Focus::Query {
|
||||
"i/a/I/A insert · hjkl/↑↓←→ move · Esc explorer · Tab/Shift-Tab focus · F5/Ctrl-Enter execute · Ctrl-C quit"
|
||||
} else {
|
||||
"Tab/Shift-Tab focus · Esc explorer · ↑↓/jk move · Enter open · i database info · 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());
|
||||
let help_height = (navigation.line_count(workspace.width.saturating_sub(2)) as u16)
|
||||
.saturating_add(2).min(workspace.height.saturating_sub(3));
|
||||
let help_height = if help_height >= 3 { help_height } else { 0 };
|
||||
let navigation = navigation.block(Block::bordered().title(" Navigation "));
|
||||
let [main, help] = Layout::vertical([
|
||||
Constraint::Min(1), Constraint::Length(help_height),
|
||||
]).areas(workspace);
|
||||
let [sidebar, content] = Layout::horizontal([Constraint::Length(32), Constraint::Min(1)])
|
||||
.areas(main);
|
||||
let items = entries.iter().map(|entry| {
|
||||
@@ -51,11 +76,12 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
ExplorerEntry::Database(index) => {
|
||||
let connected = browser.is_some() && index == active;
|
||||
let label = index.map_or_else(|| "Session database".into(), |index| profiles.entries[index].label());
|
||||
format!("{} {label}", if connected && expanded { "▼" } else { "▶" })
|
||||
database_label(&label, connected, expanded, sidebar.width.saturating_sub(2))
|
||||
}
|
||||
ExplorerEntry::Relation(index) => {
|
||||
let relation = &browser.as_ref().unwrap().relations[index];
|
||||
format!(" ├ {} [{}]", relation.label, relation.kind)
|
||||
let last = index + 1 == browser.as_ref().unwrap().relations.len();
|
||||
format!(" {} {} [{}]", if last { "└" } else { "├" }, relation.label, relation.kind)
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -74,7 +100,10 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
}
|
||||
frame.render_widget(navigation, help);
|
||||
if show_form {
|
||||
form.draw(frame);
|
||||
form.draw(frame, workspace);
|
||||
}
|
||||
if let Some(index) = info {
|
||||
profiles.entries[index].draw_info(frame, workspace);
|
||||
}
|
||||
})?;
|
||||
match event::read()? {
|
||||
@@ -82,6 +111,12 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return Ok(());
|
||||
}
|
||||
if info.is_some() {
|
||||
if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
|
||||
info = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if show_form {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
@@ -133,7 +168,13 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
}
|
||||
} else {
|
||||
match key.code {
|
||||
KeyCode::Esc => focus = Focus::Explorer,
|
||||
KeyCode::Esc => {
|
||||
if focus != Focus::Query
|
||||
|| !browser.as_mut().is_some_and(App::leave_query_insert)
|
||||
{
|
||||
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,
|
||||
@@ -151,6 +192,15 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
form = Form::default();
|
||||
show_form = true;
|
||||
}
|
||||
KeyCode::Char('i') if focus == Focus::Explorer => {
|
||||
info =
|
||||
selection
|
||||
.selected()
|
||||
.and_then(|index| match entries.get(index) {
|
||||
Some(ExplorerEntry::Database(Some(index))) => Some(*index),
|
||||
_ => None,
|
||||
});
|
||||
}
|
||||
KeyCode::Enter if focus == Focus::Explorer => {
|
||||
if let Some(entry) =
|
||||
selection.selected().and_then(|index| entries.get(index))
|
||||
@@ -238,6 +288,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Paste(_) if info.is_some() => {}
|
||||
Event::Paste(text) if show_form => {
|
||||
form.error = None;
|
||||
if let Some(input) = form.input_mut() {
|
||||
@@ -254,6 +305,101 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
}
|
||||
}
|
||||
|
||||
fn database_label(label: &str, connected: bool, expanded: bool, width: u16) -> String {
|
||||
if width < 4 {
|
||||
return if connected && width > 0 {
|
||||
"*".into()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
}
|
||||
let mut result = String::from(if connected && expanded {
|
||||
"▼ "
|
||||
} else {
|
||||
"▶ "
|
||||
});
|
||||
let available = usize::from(width) - 2 - if connected { 2 } else { 0 };
|
||||
let span = Span::raw(label);
|
||||
let mut used = 0;
|
||||
for grapheme in span.styled_graphemes(Style::default()) {
|
||||
let width = Span::raw(grapheme.symbol).width();
|
||||
if used + width > available {
|
||||
break;
|
||||
}
|
||||
used += width;
|
||||
result.push_str(grapheme.symbol);
|
||||
}
|
||||
if connected {
|
||||
result.push_str(" *");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn shell_areas(area: Rect) -> [Rect; 3] {
|
||||
let bar = u16::from(area.height >= 6);
|
||||
Layout::vertical([
|
||||
Constraint::Length(bar),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(bar),
|
||||
])
|
||||
.areas(area)
|
||||
}
|
||||
|
||||
fn draw_bars(
|
||||
frame: &mut Frame,
|
||||
top: Rect,
|
||||
bottom: Rect,
|
||||
provider: &str,
|
||||
name: &str,
|
||||
insert: bool,
|
||||
position: Option<(usize, usize)>,
|
||||
) {
|
||||
let base = Style::default().bg(Color::DarkGray).fg(Color::White);
|
||||
let badge = Style::default().bg(Color::Cyan).fg(Color::Black);
|
||||
let edge = base.fg(Color::Cyan);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(" dbdash ", badge),
|
||||
Span::styled("▓░", edge),
|
||||
Span::raw(format!(" {provider} · READ ONLY")),
|
||||
]))
|
||||
.style(base),
|
||||
top,
|
||||
);
|
||||
frame.render_widget(Paragraph::new("").style(base), bottom);
|
||||
let position = position.map(|(line, column)| format!(" {line}:{column} "));
|
||||
let mode_width = bottom.width.min(10);
|
||||
let position_width = position
|
||||
.as_ref()
|
||||
.map_or(0, |text| text.len() as u16 + 2)
|
||||
.min(bottom.width.saturating_sub(mode_width));
|
||||
let [mode, name_area, cursor] = Layout::horizontal([
|
||||
Constraint::Length(mode_width),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(position_width),
|
||||
])
|
||||
.areas(bottom);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(if insert { " INSERT " } else { " NORMAL " }, badge),
|
||||
Span::styled("▓░", edge),
|
||||
]))
|
||||
.style(base),
|
||||
mode,
|
||||
);
|
||||
frame.render_widget(Paragraph::new(format!(" {name}")).style(base), name_area);
|
||||
if let Some(position) = position {
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled("░▓", edge),
|
||||
Span::styled(position, badge),
|
||||
]))
|
||||
.style(base),
|
||||
cursor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ExplorerEntry {
|
||||
Database(Option<usize>),
|
||||
@@ -414,6 +560,18 @@ impl Form {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn input(&self) -> Option<&Input> {
|
||||
if self.reconnect {
|
||||
Some(&self.postgres[4])
|
||||
} else if self.provider == 1 {
|
||||
(self.focus == 1).then_some(&self.path)
|
||||
} else {
|
||||
self.postgres
|
||||
.get(self.focus.checked_sub(1)?)
|
||||
.filter(|_| self.focus < 5)
|
||||
}
|
||||
}
|
||||
|
||||
fn input_mut(&mut self) -> Option<&mut Input> {
|
||||
if self.reconnect {
|
||||
return Some(&mut self.postgres[4]);
|
||||
@@ -477,14 +635,59 @@ impl Form {
|
||||
})
|
||||
}
|
||||
|
||||
fn draw(&self, frame: &mut Frame) {
|
||||
let screen = frame.area();
|
||||
fn info_text(&self) -> String {
|
||||
if self.provider == 1 {
|
||||
format!(
|
||||
"Provider: SQLite\nPath: {}\nAccess: READ ONLY",
|
||||
crate::db::display_text(&self.path.value)
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Provider: PostgreSQL\nHost: {}\nPort: {}\nDatabase: {}\nUser: {}\nTLS: {}\nAccess: READ ONLY",
|
||||
crate::db::display_text(&self.postgres[0].value),
|
||||
crate::db::display_text(&self.postgres[1].value),
|
||||
crate::db::display_text(&self.postgres[2].value),
|
||||
crate::db::display_text(&self.postgres[3].value),
|
||||
["Require", "Prefer", "Disable"][self.tls],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_info(&self, frame: &mut Frame, screen: Rect) {
|
||||
let width = screen
|
||||
.width
|
||||
.saturating_sub(4)
|
||||
.min(76)
|
||||
.max(screen.width.min(4));
|
||||
let text = Paragraph::new(self.info_text()).wrap(Wrap { trim: false });
|
||||
let height = (text.line_count(width.saturating_sub(2)) as u16)
|
||||
.saturating_add(4)
|
||||
.min(screen.height);
|
||||
let area = Rect::new(
|
||||
screen.x + (screen.width - width) / 2,
|
||||
screen.y + (screen.height - height) / 2,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(
|
||||
text.block(
|
||||
Block::bordered()
|
||||
.title(" Database info ")
|
||||
.title_bottom(" Esc/Enter close "),
|
||||
),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw(&self, frame: &mut Frame, screen: Rect) {
|
||||
let minimum_height = if self.provider == 0 { 20 } else { 16 };
|
||||
if screen.width < 74 || screen.height < minimum_height {
|
||||
frame.render_widget(Clear, screen);
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"Enlarge terminal to at least 74×{minimum_height}. Ctrl-C to quit."
|
||||
"Enlarge terminal to at least 74×{}. Ctrl-C to quit.",
|
||||
minimum_height + frame.area().height.saturating_sub(screen.height)
|
||||
)),
|
||||
screen,
|
||||
);
|
||||
@@ -655,6 +858,14 @@ impl From<&str> for Input {
|
||||
}
|
||||
|
||||
impl Input {
|
||||
pub(crate) fn position(&self) -> (usize, usize) {
|
||||
let prefix = &self.value[..self.cursor];
|
||||
(
|
||||
prefix.bytes().filter(|byte| *byte == b'\n').count() + 1,
|
||||
prefix.rsplit('\n').next().unwrap_or("").chars().count() + 1,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn edit(&mut self, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Left => {
|
||||
@@ -797,6 +1008,83 @@ impl Input {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn connected_marker_survives_long_wide_profile_labels() -> Result<()> {
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(32, 3))?;
|
||||
terminal.draw(|frame| {
|
||||
let label = database_label("界".repeat(40).as_str(), true, true, 30);
|
||||
assert_eq!(Line::raw(&label).width(), 30);
|
||||
frame.render_widget(
|
||||
List::new([ListItem::new(label)]).block(Block::bordered()),
|
||||
frame.area(),
|
||||
);
|
||||
})?;
|
||||
assert_eq!(terminal.backend().buffer()[(30, 1)].symbol(), "*");
|
||||
assert_eq!(database_label("long", true, true, 1), "*");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_count_logical_lines_and_unicode_scalars() {
|
||||
let mut input = Input::from("é界\n🙂x");
|
||||
assert_eq!(input.position(), (2, 3));
|
||||
input.edit(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
|
||||
assert_eq!(input.position(), (2, 2));
|
||||
input.edit(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
|
||||
assert_eq!(input.position(), (2, 1));
|
||||
input.edit(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
assert_eq!(input.position(), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forms_report_insert_only_for_text_fields_and_info_excludes_secrets() {
|
||||
let mut form = Form::default();
|
||||
assert!(form.input().is_none());
|
||||
form.focus = 1;
|
||||
assert!(form.input().is_some());
|
||||
form.focus = 5;
|
||||
assert!(form.input().is_none());
|
||||
form.reconnect = true;
|
||||
form.postgres[4] = Input::from("secret-password");
|
||||
form.postgres[0] = Input::from("host\u{1b}[31m");
|
||||
assert!(form.input().is_some());
|
||||
let info = form.info_text();
|
||||
assert!(!info.contains("secret-password"));
|
||||
assert!(!info.contains('\u{1b}'));
|
||||
assert!(info.contains("\nHost:"));
|
||||
form.provider = 1;
|
||||
form.reconnect = false;
|
||||
form.focus = 0;
|
||||
assert!(form.input().is_none());
|
||||
form.focus = 1;
|
||||
assert!(form.input().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_keeps_position_when_name_is_long_and_yields_on_short_screens() -> Result<()> {
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(24, 8))?;
|
||||
terminal.draw(|frame| {
|
||||
let [top, content, bottom] = shell_areas(frame.area());
|
||||
assert_eq!(content.height, 6);
|
||||
draw_bars(
|
||||
frame,
|
||||
top,
|
||||
bottom,
|
||||
"SQLite",
|
||||
"a very long database name",
|
||||
true,
|
||||
Some((12, 34)),
|
||||
);
|
||||
})?;
|
||||
let buffer = terminal.backend().buffer();
|
||||
let footer: String = (0..24).map(|x| buffer[(x, 7)].symbol()).collect();
|
||||
assert!(footer.starts_with(" INSERT ▓░"));
|
||||
assert!(footer.ends_with("░▓ 12:34 "));
|
||||
let [top, content, bottom] = shell_areas(Rect::new(0, 0, 10, 3));
|
||||
assert_eq!((top.height, content.height, bottom.height), (0, 3, 0));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explorer_keeps_relations_under_the_connected_database() {
|
||||
use ExplorerEntry::{Database as Db, Relation as Rel};
|
||||
@@ -868,7 +1156,10 @@ mod tests {
|
||||
};
|
||||
form.postgres[4] = Input::from("test-secret-é");
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 24))?;
|
||||
terminal.draw(|frame| form.draw(frame))?;
|
||||
terminal.draw(|frame| {
|
||||
let area = frame.area();
|
||||
form.draw(frame, area);
|
||||
})?;
|
||||
let rendered: String = terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
|
||||
@@ -12,6 +12,7 @@ pub const PAGE_SIZE: usize = 100;
|
||||
|
||||
pub struct Database {
|
||||
backend: Backend,
|
||||
display_name: String,
|
||||
}
|
||||
|
||||
#[allow(
|
||||
@@ -66,17 +67,29 @@ impl Database {
|
||||
client.batch_execute(
|
||||
"SET default_transaction_read_only = on; SET statement_timeout = '5s';",
|
||||
)?;
|
||||
let display_name: String = client.query_one("SELECT current_database()", &[])?.get(0);
|
||||
Ok(Self {
|
||||
backend: Backend::Postgres(client),
|
||||
display_name: display_text(&display_name),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open_sqlite(path: &Path) -> Result<Self> {
|
||||
Ok(Self {
|
||||
backend: Backend::Sqlite(sqlite::open(path)?),
|
||||
display_name: display_text(
|
||||
&path
|
||||
.file_name()
|
||||
.unwrap_or(path.as_os_str())
|
||||
.to_string_lossy(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &str {
|
||||
&self.display_name
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self.backend {
|
||||
Backend::Postgres(_) => "PostgreSQL",
|
||||
@@ -290,6 +303,16 @@ pub fn display_text(value: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sqlite_identity_is_filename_without_terminal_controls() -> Result<()> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let path = directory.path().join("accounts\nprivate.sqlite");
|
||||
drop(rusqlite::Connection::open(&path)?);
|
||||
let db = Database::open_sqlite(&path)?;
|
||||
assert_eq!(db.display_name(), "accounts\\nprivate.sqlite");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifiers_and_terminal_controls_are_safe() {
|
||||
assert_eq!(
|
||||
|
||||
+14
-8
@@ -27,32 +27,38 @@ Expansion never runs shell commands. Quote CLI paths to let dbdash expand them.
|
||||
PostgreSQL URLs and libpq-style key=value strings are accepted.
|
||||
Use sslmode=require for mandatory TLS; certificates and hostnames are verified.
|
||||
Use sslmode=disable only for trusted local connections.
|
||||
The inspector needs 70×14 terminal cells; the PostgreSQL window needs 74×20.
|
||||
The inspector needs 70×17 terminal cells; PostgreSQL/SQLite windows need 74×22/74×18.
|
||||
|
||||
Navigation:
|
||||
Tab/Shift-Tab Cycle explorer, query editor, and rows/schema (Esc returns to explorer)
|
||||
Tab/Shift-Tab Cycle explorer, query editor, and rows/schema
|
||||
Up/Down, j/k Move selection; Enter connects/expands a database or opens a relation
|
||||
Home/End, g/G First / last item
|
||||
s Toggle rows and column schema
|
||||
Left/Right Collapse/expand in explorer; scroll columns in rows/schema (h/l too)
|
||||
Left/Right Collapse/expand in explorer; select cells in rows/schema (h/l too)
|
||||
n/p, PgDn/Up Next / previous 100-row relation page
|
||||
c Add a configured database in a floating window
|
||||
i Show configured database info (Esc/Enter closes; passwords omitted)
|
||||
r Refresh relations and the active relation
|
||||
Connected databases show their tables and views beneath them in the explorer.
|
||||
Connected databases have a trailing * and show their tables/views beneath them.
|
||||
Enter on the connected database collapses/expands it without reconnecting.
|
||||
Navigation help spans the bottom as one line, wrapping only when needed.
|
||||
Navigation help has its own bordered pane and wraps when needed.
|
||||
Full-width bars show dbdash/provider/READ ONLY above and mode/database below.
|
||||
Focused inputs show one-based line:column (Unicode characters, not screen cells).
|
||||
The query editor sits above rows/results; the explorer spans both panes.
|
||||
Explorer and rows/schema bottom borders align; relation/page/column status
|
||||
appears in the rows/schema border. Read errors appear above the query editor.
|
||||
In the query editor, type SQL directly; Enter inserts a newline. Arrow keys,
|
||||
Home/End, Backspace/Delete, Ctrl-U (clear), and multiline pasting are supported.
|
||||
Query editor starts in NORMAL: i/a/I/A enters INSERT; hjkl/arrows move.
|
||||
In INSERT, type SQL; Enter inserts a newline. Arrow keys, Home/End,
|
||||
Backspace/Delete, Ctrl-U (clear), and multiline pasting are supported.
|
||||
Esc leaves INSERT first; Esc in NORMAL returns to the explorer.
|
||||
Connection/password fields remain direct-entry; query paste requires INSERT.
|
||||
SQL syntax colors follow the connected database's PostgreSQL/SQLite dialect,
|
||||
including quoted identifiers, literals, and multiline comments/strings as you type.
|
||||
F5 / Ctrl-Enter executes one SELECT/CTE on the current connection, showing up
|
||||
to 100 rows below. Results report truncation; use SQL LIMIT/OFFSET for more.
|
||||
Failed queries keep the previous results. Opening a relation restores browsing
|
||||
without clearing the query text. Query text is not saved across connections.
|
||||
q / Ctrl-C Quit (q is literal text in the query editor)
|
||||
q / Ctrl-C Quit outside the query editor (q inserts text only in INSERT)
|
||||
New database window: Up/Down chooses a provider; Enter starts editing.
|
||||
Tab/Shift-Tab changes fields; Left/Right changes TLS when that field is focused.
|
||||
Enter saves without connecting, including when the database is offline.
|
||||
|
||||
Reference in New Issue
Block a user