feat: show SQL editor line numbers

This commit is contained in:
Valdemar Lorenzen
2026-10-20 11:22:47 +02:00
parent e64ad2106a
commit e7790fb8e8
4 changed files with 202 additions and 58 deletions
+4 -3
View File
@@ -1,3 +1,4 @@
- [ ] feature: ability to give the connection configs a custom name (control how they're displayed in the sidebar) - [x] feature: ability to give the connection configs a custom name (control how they're displayed in the sidebar)
- [ ] feature: ability to remove a database connection - [x] feature: ability to remove a database connection
- [ ] feature: line numbers in gutter of sql editor - [x] feature: line numbers in gutter of sql editor
- [ ] bug: database type should display nothing instead of `-` when no database is connected. (it should only show the type when it's available)
+5 -5
View File
@@ -723,14 +723,14 @@ mod tests {
app.paste_query("SELECT '界', 42;\n-- comment"); app.paste_query("SELECT '界', 42;\n-- comment");
terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?; terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?;
let buffer = terminal.backend().buffer(); let buffer = terminal.backend().buffer();
assert_eq!(buffer[(1, 1)].fg, Color::Cyan); assert_eq!(buffer[(3, 1)].fg, Color::Cyan);
assert_eq!(buffer[(8, 1)].fg, Color::Green); assert_eq!(buffer[(10, 1)].fg, Color::Green);
assert_eq!(buffer[(1, 2)].fg, Color::DarkGray); assert_eq!(buffer[(3, 2)].fg, Color::DarkGray);
app.handle_query_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); app.handle_query_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
app.handle_query_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)); app.handle_query_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE));
app.paste_query("unfinished\nstring"); app.paste_query("unfinished\nstring");
terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?; terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?;
assert_eq!(terminal.backend().buffer()[(1, 2)].fg, Color::Green); assert_eq!(terminal.backend().buffer()[(3, 2)].fg, Color::Green);
app.handle_query_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); app.handle_query_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
app.paste_query(&format!("{}--{}", "-- line\n".repeat(12), "x".repeat(90))); app.paste_query(&format!("{}--{}", "-- line\n".repeat(12), "x".repeat(90)));
terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?; terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?;
@@ -738,7 +738,7 @@ mod tests {
let buffer = terminal.backend().buffer(); let buffer = terminal.backend().buffer();
assert_eq!(cursor.x, 58); assert_eq!(cursor.x, 58);
assert_eq!(buffer[(cursor.x - 1, cursor.y)].symbol(), "x"); assert_eq!(buffer[(cursor.x - 1, cursor.y)].symbol(), "x");
assert_eq!(buffer[(1, cursor.y)].fg, Color::DarkGray); assert_eq!(buffer[(4, cursor.y)].fg, Color::DarkGray);
Ok(()) Ok(())
} }
+189 -48
View File
@@ -101,9 +101,9 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
workspace.y + (workspace.height - height) / 2, width, height); workspace.y + (workspace.height - height) / 2, width, height);
frame.render_widget(Clear, area); frame.render_widget(Clear, area);
frame.render_widget(Paragraph::new(format!( frame.render_widget(Paragraph::new(format!(
"Remove saved connection \"{}\"?\nOnly the configuration is removed. The database and active session are kept.\n\ny: remove · n/Esc: cancel", "Remove saved connection \"{}\"?\nOnly the configuration is removed. The database and active session are kept.",
profiles.entries[index].label())) profiles.entries[index].label()))
.wrap(Wrap { trim: false }).block(Block::bordered().title(" Remove connection ")), area); .wrap(Wrap { trim: false }).block(Block::bordered().title(" Remove connection ").title_bottom(" y: remove · n/Esc: cancel ")), area);
} }
})?; })?;
match event::read()? { match event::read()? {
@@ -117,11 +117,21 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
match profiles.remove(index) { match profiles.remove(index) {
Ok(()) => { Ok(()) => {
active = active.and_then(|active| { active = active.and_then(|active| {
if active == index { None } else { Some(active - usize::from(active > index)) } if active == index {
None
} else {
Some(active - usize::from(active > index))
}
}); });
let updated = explorer_entries(profiles.entries.len(), active, let updated = explorer_entries(
browser.as_ref().map(|app| app.relations.len()), expanded); profiles.entries.len(),
selection.select((!updated.is_empty()).then(|| selection.selected().unwrap_or(0).min(updated.len() - 1))); active,
browser.as_ref().map(|app| app.relations.len()),
expanded,
);
selection.select((!updated.is_empty()).then(|| {
selection.selected().unwrap_or(0).min(updated.len() - 1)
}));
error = None; error = None;
} }
Err(failure) => error = Some(error_text(failure.as_ref())), Err(failure) => error = Some(error_text(failure.as_ref())),
@@ -181,7 +191,10 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
}) })
} else { } else {
profiles.save(&form).map(|()| { profiles.save(&form).map(|()| {
let saved_index = profiles.entries.iter().position(|entry| entry.id == form.id) let saved_index = profiles
.entries
.iter()
.position(|entry| entry.id == form.id)
.unwrap_or(profiles.entries.len() - 1); .unwrap_or(profiles.entries.len() - 1);
let updated = explorer_entries( let updated = explorer_entries(
profiles.entries.len(), profiles.entries.len(),
@@ -190,10 +203,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
expanded, expanded,
); );
selection.select(updated.iter().position(|entry| { selection.select(updated.iter().position(|entry| {
*entry *entry == ExplorerEntry::Database(Some(saved_index))
== ExplorerEntry::Database(Some(
saved_index,
))
})); }));
focus = Focus::Explorer; focus = Focus::Explorer;
}) })
@@ -242,17 +252,23 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
show_form = true; show_form = true;
} }
KeyCode::Char('d') | KeyCode::Delete if focus == Focus::Explorer => { KeyCode::Char('d') | KeyCode::Delete if focus == Focus::Explorer => {
remove = selection.selected().and_then(|index| match entries.get(index) { remove =
Some(ExplorerEntry::Database(Some(index))) => Some(*index), selection
_ => None, .selected()
}); .and_then(|index| match entries.get(index) {
Some(ExplorerEntry::Database(Some(index))) => Some(*index),
_ => None,
});
} }
KeyCode::Char('e') if focus == Focus::Explorer => { KeyCode::Char('e') if focus == Focus::Explorer => {
let index = selection.selected().and_then(|index| match entries.get(index) { let index =
Some(ExplorerEntry::Database(Some(index))) => Some(*index), selection
Some(ExplorerEntry::Relation(_)) => active, .selected()
_ => None, .and_then(|index| match entries.get(index) {
}); Some(ExplorerEntry::Database(Some(index))) => Some(*index),
Some(ExplorerEntry::Relation(_)) => active,
_ => None,
});
if let Some(index) = index { if let Some(index) = index {
form = profiles.entries[index].clone(); form = profiles.entries[index].clone();
form.focus = form.name_focus(); form.focus = form.name_focus();
@@ -394,6 +410,8 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) {
Left/Right, h/l Collapse/expand explorer or select cells\n\ Left/Right, h/l Collapse/expand explorer or select cells\n\
Enter Connect/expand database or open relation\n\ Enter Connect/expand database or open relation\n\
i Saved connection info on database or child\n\ i Saved connection info on database or child\n\
e Edit saved configuration/name on database or child\n\
d / Delete Remove saved database (y confirms, n/Esc cancels)\n\
s Toggle rows / column schema\n\ s Toggle rows / column schema\n\
n/p, PgDn/PgUp Next / previous 100-row relation page\n\ n/p, PgDn/PgUp Next / previous 100-row relation page\n\
r Refresh relations and active relation\n\n\ r Refresh relations and active relation\n\n\
@@ -414,10 +432,11 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) {
Connection / password windows\n\ Connection / password windows\n\
Tab / Shift-Tab Change fields\n\ Tab / Shift-Tab Change fields\n\
Arrows Choose provider; Left/Right changes TLS\n\ Arrows Choose provider; Left/Right changes TLS\n\
Enter Edit provider / save new / connect saved\n\ Enter Edit provider / save settings / connect\n\
Ctrl-U / paste Clear / enter text in a focused field\n\ Ctrl-U / paste Clear / enter text in a focused field\n\
Esc Cancel and discard edits\n\ Esc Cancel and discard edits\n\
Saved settings update only after a successful connection.\n\ Optional Name sets the sidebar label; blank uses the default.\n\
Connect saves settings only on success; e edits save without connecting.\n\
Passwords are never saved.", Passwords are never saved.",
) )
.wrap(Wrap { trim: false }); .wrap(Wrap { trim: false });
@@ -596,8 +615,13 @@ impl Profiles {
path TEXT NOT NULL path TEXT NOT NULL
)", )",
)?; )?;
if !storage.prepare("SELECT name FROM pragma_table_info('connections') WHERE name = 'name'")?.exists([])? { if !storage
storage.execute_batch("ALTER TABLE connections ADD COLUMN name TEXT NOT NULL DEFAULT ''")?; .prepare("SELECT name FROM pragma_table_info('connections') WHERE name = 'name'")?
.exists([])?
{
storage.execute_batch(
"ALTER TABLE connections ADD COLUMN name TEXT NOT NULL DEFAULT ''",
)?;
} }
let entries = storage.prepare("SELECT provider, host, port, database_name, username, tls, path, id, name FROM connections ORDER BY id")? let entries = storage.prepare("SELECT provider, host, port, database_name, username, tls, path, id, name FROM connections ORDER BY id")?
.query_map([], |row| { .query_map([], |row| {
@@ -666,7 +690,10 @@ impl Profiles {
} }
fn remove(&mut self, index: usize) -> Result<()> { fn remove(&mut self, index: usize) -> Result<()> {
self.storage.execute("DELETE FROM connections WHERE id = ?1", [self.entries[index].id])?; self.storage.execute(
"DELETE FROM connections WHERE id = ?1",
[self.entries[index].id],
)?;
self.entries.remove(index); self.entries.remove(index);
Ok(()) Ok(())
} }
@@ -703,7 +730,11 @@ impl Default for Form {
impl Form { impl Form {
fn name_focus(&self) -> usize { fn name_focus(&self) -> usize {
if self.provider == 1 { 2 } else { 6 + usize::from(self.reconnect) } if self.provider == 1 {
2
} else {
6 + usize::from(self.reconnect)
}
} }
fn connect(&self) -> Result<Database> { fn connect(&self) -> Result<Database> {
@@ -910,8 +941,16 @@ impl Form {
.style(Style::default().fg(Color::Cyan)), .style(Style::default().fg(Color::Cyan)),
header, header,
); );
let [name_area, body] = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(body); let [name_area, body] =
self.name.draw(frame, name_area, " Name (optional) ", false, self.focus == self.name_focus(), None); Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(body);
self.name.draw(
frame,
name_area,
" Name (optional) ",
false,
self.focus == self.name_focus(),
None,
);
let [providers, details] = let [providers, details] =
Layout::horizontal([Constraint::Length(24), Constraint::Min(1)]).areas(body); Layout::horizontal([Constraint::Length(24), Constraint::Min(1)]).areas(body);
frame.render_widget( frame.render_widget(
@@ -1245,36 +1284,62 @@ impl Input {
focused: bool, focused: bool,
highlighted: Option<Text<'_>>, highlighted: Option<Text<'_>>,
) { ) {
let numbered = highlighted.is_some();
let text = if secret { let text = if secret {
Text::raw("•".repeat(self.value.chars().count())) Text::raw("•".repeat(self.value.chars().count()))
} else { } else {
highlighted.unwrap_or_else(|| Text::raw(&self.value)) highlighted.unwrap_or_else(|| Text::raw(&self.value))
}; };
let line_count = text.lines.len().max(1);
let block = Block::bordered()
.title(label)
.border_style(Style::default().fg(if focused {
Color::Cyan
} else {
Color::DarkGray
}));
let inner = block.inner(area);
frame.render_widget(block, area);
let gutter_width = if numbered {
(line_count.ilog10() as u16 + 2).min(inner.width.saturating_sub(1))
} else {
0
};
let content = Rect::new(
inner.x + gutter_width,
inner.y,
inner.width - gutter_width,
inner.height,
);
let prefix = &self.value[..self.cursor]; let prefix = &self.value[..self.cursor];
let row = u16::try_from(prefix.matches('\n').count()).unwrap_or(u16::MAX); 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 row_offset = row.saturating_sub(content.height.saturating_sub(1));
let before_cursor = if secret { let before_cursor = if secret {
self.value[..self.cursor].chars().count() self.value[..self.cursor].chars().count()
} else { } else {
Line::raw(prefix.rsplit('\n').next().unwrap_or("")).width() Line::raw(prefix.rsplit('\n').next().unwrap_or("")).width()
}; };
let cursor = u16::try_from(before_cursor).unwrap_or(u16::MAX); let cursor = u16::try_from(before_cursor).unwrap_or(u16::MAX);
let offset = cursor.saturating_sub(area.width.saturating_sub(3)); let offset = cursor.saturating_sub(content.width.saturating_sub(1));
frame.render_widget( frame.render_widget(Paragraph::new(text).scroll((row_offset, offset)), content);
Paragraph::new(text).scroll((row_offset, offset)).block( if gutter_width > 0 {
Block::bordered() let lines = (usize::from(row_offset)..line_count)
.title(label) .take(usize::from(inner.height))
.border_style(Style::default().fg(if focused { .map(|line| {
Color::Cyan Line::raw(format!(
} else { "{:>width$} ",
Color::DarkGray line + 1,
})), width = usize::from(gutter_width - 1)
), ))
area, })
); .collect::<Vec<_>>();
if focused && area.width > 2 && area.height > 2 { frame.render_widget(
frame Paragraph::new(lines).style(Style::default().fg(Color::DarkGray)),
.set_cursor_position((area.x + 1 + cursor - offset, area.y + 1 + row - row_offset)); Rect::new(inner.x, inner.y, gutter_width, inner.height),
);
}
if focused && content.width > 0 && content.height > 0 {
frame.set_cursor_position((content.x + cursor - offset, content.y + row - row_offset));
} }
} }
} }
@@ -1282,6 +1347,71 @@ impl Input {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn sql_gutter_tracks_scrolling_digits_and_cursor_without_entering_sql() -> Result<()> {
use ratatui::{Terminal, backend::TestBackend};
let source = (1..=12)
.map(|line| format!("SELECT {line}, '界abcdefghijklmnop';"))
.collect::<Vec<_>>()
.join("\n");
let mut input = Input::from(source.as_str());
let mut terminal = Terminal::new(TestBackend::new(22, 5))?;
terminal.draw(|frame| {
input.draw(
frame,
frame.area(),
"Query",
false,
true,
Some(crate::sql::text(
&input.value,
&crate::sql::highlight(&input.value, true),
)),
)
})?;
let buffer = terminal.backend().buffer();
for (row, number) in [(1, "10"), (2, "11"), (3, "12")] {
let gutter: String = (1..=2).map(|x| buffer[(x, row)].symbol()).collect();
assert_eq!(gutter, number);
}
assert_eq!(terminal.get_cursor_position()?.x, 20);
input.paste_multiline("\n");
terminal.draw(|frame| {
input.draw(
frame,
frame.area(),
"Query",
false,
true,
Some(crate::sql::text(
&input.value,
&crate::sql::highlight(&input.value, true),
)),
)
})?;
assert_eq!(terminal.backend().buffer()[(1, 3)].symbol(), "1");
assert_eq!(terminal.backend().buffer()[(2, 3)].symbol(), "3");
assert_eq!(terminal.get_cursor_position()?, (4, 3).into());
input.vim_motion('g');
terminal.draw(|frame| {
input.draw(
frame,
frame.area(),
"Query",
false,
true,
Some(crate::sql::text(
&input.value,
&crate::sql::highlight(&input.value, true),
)),
)
})?;
assert_eq!(terminal.backend().buffer()[(2, 1)].symbol(), "1");
assert_eq!(terminal.backend().buffer()[(4, 1)].symbol(), "S");
assert_eq!(terminal.get_cursor_position()?, (4, 1).into());
assert_eq!(input.value, format!("{source}\n"));
Ok(())
}
#[test] #[test]
fn keybindings_scroll_to_the_end_and_clamp_after_resize() -> Result<()> { fn keybindings_scroll_to_the_end_and_clamp_after_resize() -> Result<()> {
@@ -1629,7 +1759,10 @@ mod tests {
let restored = &profiles.entries[0]; let restored = &profiles.entries[0];
assert_eq!(restored.tls, 2); assert_eq!(restored.tls, 2);
assert_eq!(restored.label(), "Warehouse"); assert_eq!(restored.label(), "Warehouse");
assert_eq!(restored.connection_label(), "warehouse · inspector@db.example:5440"); assert_eq!(
restored.connection_label(),
"warehouse · inspector@db.example:5440"
);
for (input, expected) in for (input, expected) in
restored restored
.postgres .postgres
@@ -1651,7 +1784,11 @@ mod tests {
let directory = tempfile::tempdir()?; let directory = tempfile::tempdir()?;
let path = directory.path().join("connections.sqlite3"); let path = directory.path().join("connections.sqlite3");
let mut profiles = Profiles::open(&path)?; let mut profiles = Profiles::open(&path)?;
let mut form = Form { provider: 1, path: Input::from("/tmp/example.sqlite"), ..Form::default() }; let mut form = Form {
provider: 1,
path: Input::from("/tmp/example.sqlite"),
..Form::default()
};
form.focus = form.name_focus(); form.focus = form.name_focus();
form.input_mut().unwrap().paste(" Local data "); form.input_mut().unwrap().paste(" Local data ");
profiles.save(&form)?; profiles.save(&form)?;
@@ -1676,7 +1813,11 @@ mod tests {
std::fs::write(&database, contents)?; std::fs::write(&database, contents)?;
let path = directory.path().join("profiles.sqlite"); let path = directory.path().join("profiles.sqlite");
let mut profiles = Profiles::open(&path)?; let mut profiles = Profiles::open(&path)?;
let form = Form { provider: 1, path: Input::from(database.to_str().unwrap()), ..Form::default() }; let form = Form {
provider: 1,
path: Input::from(database.to_str().unwrap()),
..Form::default()
};
profiles.save(&form)?; profiles.save(&form)?;
profiles.save(&form)?; profiles.save(&form)?;
let survivor = profiles.entries[1].id; let survivor = profiles.entries[1].id;
+4 -2
View File
@@ -47,18 +47,20 @@ Navigation:
Works on a saved database entry or any of its table/view children. Works on a saved database entry or any of its table/view children.
r Refresh relations and the active relation r Refresh relations and the active relation
Connected databases have a trailing * and show their tables/views beneath them. Connected databases have a trailing * and show their tables/views beneath them.
SQLite sidebar entries show only the filename; the info window shows the full path. Unnamed SQLite entries show only the filename; the info window shows the full path.
Enter on the connected database collapses/expands it without reconnecting. Enter on the connected database collapses/expands it without reconnecting.
Keybindings float above the workspace; Up/Down/jk scroll, Home/End/g/G jump. Keybindings float above the workspace; Up/Down/jk scroll, Home/End/g/G jump.
Query Insert mode and connection/password fields keep ? as literal text. Query Insert mode and connection/password fields keep ? as literal text.
Full-width bars show dbdash/READ ONLY above and mode/database below. Full-width bars show dbdash/READ ONLY above and mode/database below.
Saved PostgreSQL connections show database · user@host:port beside the mode, Saved PostgreSQL connections show database · user@host:port beside the mode,
matching the sidebar; passwords are never included. regardless of custom sidebar names; passwords are never included.
The database provider sits immediately left of the bottom-right position badge. The database provider sits immediately left of the bottom-right position badge.
Only badges have colored backgrounds; the remaining bar area uses terminal defaults. Only badges have colored backgrounds; the remaining bar area uses terminal defaults.
Focused inputs show one-based line:column (Unicode characters, not screen cells). Focused inputs show one-based line:column (Unicode characters, not screen cells).
The position badge shows N/A outside focused inputs, including in info/help popups. The position badge shows N/A outside focused inputs, including in info/help popups.
The query editor sits above rows/results; the explorer spans both panes. The query editor sits above rows/results; the explorer spans both panes.
The SQL editor has a one-based line-number gutter that follows vertical scrolling;
horizontal scrolling moves only SQL, not the numbers or logical cursor position.
Explorer and rows/schema bottom borders align; table names appear in the Explorer and rows/schema bottom borders align; table names appear in the
top-right border, with page/column status below. Read errors appear above the query editor. top-right border, with page/column status below. Read errors appear above the query editor.
Query editor starts in NORMAL: i/a/I/A enters INSERT; hjkl/arrows move. Query editor starts in NORMAL: i/a/I/A enters INSERT; hjkl/arrows move.