feat: show SQL editor line numbers
This commit is contained in:
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
+180
-39
@@ -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,13 +252,19 @@ 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) {
|
||||
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) {
|
||||
let index =
|
||||
selection
|
||||
.selected()
|
||||
.and_then(|index| match entries.get(index) {
|
||||
Some(ExplorerEntry::Database(Some(index))) => Some(*index),
|
||||
Some(ExplorerEntry::Relation(_)) => active,
|
||||
_ => None,
|
||||
@@ -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));
|
||||
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(text).scroll((row_offset, offset)).block(
|
||||
Block::bordered()
|
||||
.title(label)
|
||||
.border_style(Style::default().fg(if focused {
|
||||
Color::Cyan
|
||||
} else {
|
||||
Color::DarkGray
|
||||
})),
|
||||
),
|
||||
area,
|
||||
Paragraph::new(lines).style(Style::default().fg(Color::DarkGray)),
|
||||
Rect::new(inner.x, inner.y, gutter_width, inner.height),
|
||||
);
|
||||
if focused && area.width > 2 && area.height > 2 {
|
||||
frame
|
||||
.set_cursor_position((area.x + 1 + cursor - offset, area.y + 1 + row - row_offset));
|
||||
}
|
||||
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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user