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)
- [ ] feature: ability to remove a database connection
- [ ] feature: line numbers in gutter of sql editor
- [x] feature: ability to give the connection configs a custom name (control how they're displayed in the sidebar)
- [x] feature: ability to remove a database connection
- [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");
terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?;
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(1, 1)].fg, Color::Cyan);
assert_eq!(buffer[(8, 1)].fg, Color::Green);
assert_eq!(buffer[(1, 2)].fg, Color::DarkGray);
assert_eq!(buffer[(3, 1)].fg, Color::Cyan);
assert_eq!(buffer[(10, 1)].fg, Color::Green);
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('\''), KeyModifiers::NONE));
app.paste_query("unfinished\nstring");
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.paste_query(&format!("{}--{}", "-- line\n".repeat(12), "x".repeat(90)));
terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?;
@@ -738,7 +738,7 @@ mod tests {
let buffer = terminal.backend().buffer();
assert_eq!(cursor.x, 58);
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(())
}
+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);
frame.render_widget(Clear, area);
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()))
.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()? {
@@ -117,11 +117,21 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
match profiles.remove(index) {
Ok(()) => {
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,
browser.as_ref().map(|app| app.relations.len()), expanded);
selection.select((!updated.is_empty()).then(|| selection.selected().unwrap_or(0).min(updated.len() - 1)));
let updated = explorer_entries(
profiles.entries.len(),
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;
}
Err(failure) => error = Some(error_text(failure.as_ref())),
@@ -181,7 +191,10 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
})
} else {
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);
let updated = explorer_entries(
profiles.entries.len(),
@@ -190,10 +203,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
expanded,
);
selection.select(updated.iter().position(|entry| {
*entry
== ExplorerEntry::Database(Some(
saved_index,
))
*entry == ExplorerEntry::Database(Some(saved_index))
}));
focus = Focus::Explorer;
})
@@ -242,17 +252,23 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
show_form = true;
}
KeyCode::Char('d') | KeyCode::Delete if focus == Focus::Explorer => {
remove = selection.selected().and_then(|index| match entries.get(index) {
Some(ExplorerEntry::Database(Some(index))) => Some(*index),
_ => None,
});
remove =
selection
.selected()
.and_then(|index| match entries.get(index) {
Some(ExplorerEntry::Database(Some(index))) => Some(*index),
_ => None,
});
}
KeyCode::Char('e') if focus == Focus::Explorer => {
let index = selection.selected().and_then(|index| match entries.get(index) {
Some(ExplorerEntry::Database(Some(index))) => Some(*index),
Some(ExplorerEntry::Relation(_)) => active,
_ => None,
});
let index =
selection
.selected()
.and_then(|index| match entries.get(index) {
Some(ExplorerEntry::Database(Some(index))) => Some(*index),
Some(ExplorerEntry::Relation(_)) => active,
_ => None,
});
if let Some(index) = index {
form = profiles.entries[index].clone();
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\
Enter Connect/expand database or open relation\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\
n/p, PgDn/PgUp Next / previous 100-row relation page\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\
Tab / Shift-Tab Change fields\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\
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.",
)
.wrap(Wrap { trim: false });
@@ -596,8 +615,13 @@ impl Profiles {
path TEXT NOT NULL
)",
)?;
if !storage.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 ''")?;
if !storage
.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")?
.query_map([], |row| {
@@ -666,7 +690,10 @@ impl Profiles {
}
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);
Ok(())
}
@@ -703,7 +730,11 @@ impl Default for Form {
impl Form {
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> {
@@ -910,8 +941,16 @@ impl Form {
.style(Style::default().fg(Color::Cyan)),
header,
);
let [name_area, body] = 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 [name_area, body] =
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] =
Layout::horizontal([Constraint::Length(24), Constraint::Min(1)]).areas(body);
frame.render_widget(
@@ -1245,36 +1284,62 @@ impl Input {
focused: bool,
highlighted: Option<Text<'_>>,
) {
let numbered = highlighted.is_some();
let text = if secret {
Text::raw("•".repeat(self.value.chars().count()))
} else {
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 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 {
self.value[..self.cursor].chars().count()
} else {
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((row_offset, offset)).block(
Block::bordered()
.title(label)
.border_style(Style::default().fg(if focused {
Color::Cyan
} else {
Color::DarkGray
})),
),
area,
);
if focused && area.width > 2 && area.height > 2 {
frame
.set_cursor_position((area.x + 1 + cursor - offset, area.y + 1 + row - row_offset));
let offset = cursor.saturating_sub(content.width.saturating_sub(1));
frame.render_widget(Paragraph::new(text).scroll((row_offset, offset)), content);
if gutter_width > 0 {
let lines = (usize::from(row_offset)..line_count)
.take(usize::from(inner.height))
.map(|line| {
Line::raw(format!(
"{:>width$} ",
line + 1,
width = usize::from(gutter_width - 1)
))
})
.collect::<Vec<_>>();
frame.render_widget(
Paragraph::new(lines).style(Style::default().fg(Color::DarkGray)),
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)]
mod tests {
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]
fn keybindings_scroll_to_the_end_and_clamp_after_resize() -> Result<()> {
@@ -1629,7 +1759,10 @@ mod tests {
let restored = &profiles.entries[0];
assert_eq!(restored.tls, 2);
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
restored
.postgres
@@ -1651,7 +1784,11 @@ mod tests {
let directory = tempfile::tempdir()?;
let path = directory.path().join("connections.sqlite3");
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.input_mut().unwrap().paste(" Local data ");
profiles.save(&form)?;
@@ -1676,7 +1813,11 @@ mod tests {
std::fs::write(&database, contents)?;
let path = directory.path().join("profiles.sqlite");
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)?;
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.
r Refresh relations and the active relation
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.
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.
Full-width bars show dbdash/READ ONLY above and mode/database below.
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.
Only badges have colored backgrounds; the remaining bar area uses terminal defaults.
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 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
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.