feat: expand connected databases in a single explorer sidebar
This commit is contained in:
+13
-44
@@ -4,7 +4,7 @@ use ratatui::{
|
|||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::Line,
|
text::Line,
|
||||||
widgets::{Block, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState},
|
widgets::{Block, Cell, Paragraph, Row, Table, TableState},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -15,8 +15,7 @@ use crate::{
|
|||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
db: Database,
|
db: Database,
|
||||||
relations: Vec<Relation>,
|
pub(crate) relations: Vec<Relation>,
|
||||||
selection: ListState,
|
|
||||||
active: Option<usize>,
|
active: Option<usize>,
|
||||||
columns: Vec<Column>,
|
columns: Vec<Column>,
|
||||||
page: Page,
|
page: Page,
|
||||||
@@ -24,7 +23,6 @@ pub struct App {
|
|||||||
rows_state: TableState,
|
rows_state: TableState,
|
||||||
schema_state: TableState,
|
schema_state: TableState,
|
||||||
column_offset: usize,
|
column_offset: usize,
|
||||||
pub(crate) sidebar_focus: bool,
|
|
||||||
show_schema: bool,
|
show_schema: bool,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -36,7 +34,6 @@ impl App {
|
|||||||
let mut app = Self {
|
let mut app = Self {
|
||||||
db,
|
db,
|
||||||
relations,
|
relations,
|
||||||
selection: ListState::default().with_selected(selected),
|
|
||||||
active: None,
|
active: None,
|
||||||
columns: Vec::new(),
|
columns: Vec::new(),
|
||||||
page: Page {
|
page: Page {
|
||||||
@@ -47,20 +44,15 @@ impl App {
|
|||||||
rows_state: TableState::default(),
|
rows_state: TableState::default(),
|
||||||
schema_state: TableState::default(),
|
schema_state: TableState::default(),
|
||||||
column_offset: 0,
|
column_offset: 0,
|
||||||
sidebar_focus: true,
|
|
||||||
show_schema: false,
|
show_schema: false,
|
||||||
error: None,
|
error: None,
|
||||||
};
|
};
|
||||||
app.open_selection();
|
app.open_relation(selected);
|
||||||
Ok(app)
|
Ok(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn handle_key(&mut self, key: KeyEvent) {
|
pub(crate) fn handle_key(&mut self, key: KeyEvent) {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Enter if self.sidebar_focus => {
|
|
||||||
self.open_selection();
|
|
||||||
self.sidebar_focus = false;
|
|
||||||
}
|
|
||||||
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
|
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
|
||||||
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
|
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
|
||||||
KeyCode::Home | KeyCode::Char('g') => self.move_selection(isize::MIN),
|
KeyCode::Home | KeyCode::Char('g') => self.move_selection(isize::MIN),
|
||||||
@@ -69,10 +61,10 @@ impl App {
|
|||||||
self.show_schema = !self.show_schema;
|
self.show_schema = !self.show_schema;
|
||||||
self.column_offset = 0;
|
self.column_offset = 0;
|
||||||
}
|
}
|
||||||
KeyCode::Left | KeyCode::Char('h') if !self.sidebar_focus => {
|
KeyCode::Left | KeyCode::Char('h') => {
|
||||||
self.column_offset = self.column_offset.saturating_sub(1);
|
self.column_offset = self.column_offset.saturating_sub(1);
|
||||||
}
|
}
|
||||||
KeyCode::Right | KeyCode::Char('l') if !self.sidebar_focus => {
|
KeyCode::Right | KeyCode::Char('l') => {
|
||||||
let count = if self.show_schema {
|
let count = if self.show_schema {
|
||||||
5
|
5
|
||||||
} else {
|
} else {
|
||||||
@@ -94,10 +86,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn move_selection(&mut self, delta: isize) {
|
fn move_selection(&mut self, delta: isize) {
|
||||||
if self.sidebar_focus {
|
if self.show_schema {
|
||||||
let next = moved_index(self.selection.selected(), self.relations.len(), delta);
|
|
||||||
self.selection.select(next);
|
|
||||||
} else if self.show_schema {
|
|
||||||
let next = moved_index(self.schema_state.selected(), self.columns.len(), delta);
|
let next = moved_index(self.schema_state.selected(), self.columns.len(), delta);
|
||||||
self.schema_state.select(next);
|
self.schema_state.select(next);
|
||||||
} else {
|
} else {
|
||||||
@@ -106,8 +95,8 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_selection(&mut self) {
|
pub(crate) fn open_relation(&mut self, index: Option<usize>) {
|
||||||
self.active = self.selection.selected();
|
self.active = index;
|
||||||
self.columns.clear();
|
self.columns.clear();
|
||||||
self.page = Page {
|
self.page = Page {
|
||||||
rows: Vec::new(),
|
rows: Vec::new(),
|
||||||
@@ -156,15 +145,14 @@ impl App {
|
|||||||
.position(|relation| Some(relation.id) == id)
|
.position(|relation| Some(relation.id) == id)
|
||||||
.or_else(|| (!relations.is_empty()).then_some(0));
|
.or_else(|| (!relations.is_empty()).then_some(0));
|
||||||
self.relations = relations;
|
self.relations = relations;
|
||||||
self.selection = ListState::default().with_selected(selected);
|
self.open_relation(selected);
|
||||||
self.open_selection();
|
|
||||||
}
|
}
|
||||||
Err(error) => self.error = Some(error_text(error.as_ref())),
|
Err(error) => self.error = Some(error_text(error.as_ref())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn draw(&mut self, frame: &mut Frame, area: Rect, focused: bool) {
|
pub(crate) fn draw(&mut self, frame: &mut Frame, area: Rect, focused: bool) {
|
||||||
if area.width < 46 || area.height < 12 {
|
if area.width < 38 || area.height < 12 {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new("Enlarge terminal to at least 70×12. q to quit."),
|
Paragraph::new("Enlarge terminal to at least 70×12. q to quit."),
|
||||||
area,
|
area,
|
||||||
@@ -187,32 +175,13 @@ impl App {
|
|||||||
.style(Style::default().fg(Color::Cyan)),
|
.style(Style::default().fg(Color::Cyan)),
|
||||||
header,
|
header,
|
||||||
);
|
);
|
||||||
let [sidebar, content] =
|
let content = body;
|
||||||
Layout::horizontal([Constraint::Length(28), Constraint::Min(1)]).areas(body);
|
|
||||||
let sidebar_title = format!(" Relations ({}) ", self.relations.len());
|
|
||||||
let sidebar_block = panel(&sidebar_title, focused && self.sidebar_focus);
|
|
||||||
if self.relations.is_empty() {
|
|
||||||
frame.render_widget(
|
|
||||||
Paragraph::new("No readable tables or views.\nr to refresh.").block(sidebar_block),
|
|
||||||
sidebar,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
let list = List::new(
|
|
||||||
self.relations
|
|
||||||
.iter()
|
|
||||||
.map(|relation| ListItem::new(relation.label.as_str())),
|
|
||||||
)
|
|
||||||
.block(sidebar_block)
|
|
||||||
.highlight_style(selected_style())
|
|
||||||
.highlight_symbol("› ");
|
|
||||||
frame.render_stateful_widget(list, sidebar, &mut self.selection);
|
|
||||||
}
|
|
||||||
let title = if self.show_schema {
|
let title = if self.show_schema {
|
||||||
" Schema · s for rows "
|
" Schema · s for rows "
|
||||||
} else {
|
} else {
|
||||||
" Rows · s for schema "
|
" Rows · s for schema "
|
||||||
};
|
};
|
||||||
let content_block = panel(title, focused && !self.sidebar_focus);
|
let content_block = panel(title, focused);
|
||||||
let count = if self.show_schema {
|
let count = if self.show_schema {
|
||||||
5
|
5
|
||||||
} else {
|
} else {
|
||||||
@@ -339,7 +308,7 @@ impl App {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
frame.render_widget(Paragraph::new(
|
frame.render_widget(Paragraph::new(
|
||||||
" Tab focus · Esc databases · ↑↓/jk move · Enter open · s schema/rows\n ←→/hl columns · n/p page · r refresh · c add database · q quit"
|
" Tab focus · Esc explorer · ↑↓/jk move · Enter open · s schema/rows\n ←→/hl columns · n/p page · r refresh · c add database · q quit"
|
||||||
).style(Style::default().fg(Color::DarkGray)), help);
|
).style(Style::default().fg(Color::DarkGray)), help);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-19
@@ -22,18 +22,35 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
|||||||
ListState::default().with_selected((!profiles.entries.is_empty()).then_some(0));
|
ListState::default().with_selected((!profiles.entries.is_empty()).then_some(0));
|
||||||
let mut databases_focus = true;
|
let mut databases_focus = true;
|
||||||
let mut active = None;
|
let mut active = None;
|
||||||
|
let mut expanded = true;
|
||||||
let mut error: Option<String> = None;
|
let mut error: Option<String> = None;
|
||||||
let mut form = Form::default();
|
let mut form = Form::default();
|
||||||
let mut show_form = false;
|
let mut show_form = false;
|
||||||
loop {
|
loop {
|
||||||
|
let entries = explorer_entries(
|
||||||
|
profiles.entries.len(),
|
||||||
|
active,
|
||||||
|
browser.as_ref().map(|app| app.relations.len()),
|
||||||
|
expanded,
|
||||||
|
);
|
||||||
terminal.draw(|frame| {
|
terminal.draw(|frame| {
|
||||||
let [sidebar, content] = Layout::horizontal([Constraint::Length(24), Constraint::Min(1)])
|
let [sidebar, content] = Layout::horizontal([Constraint::Length(32), Constraint::Min(1)])
|
||||||
.areas(frame.area());
|
.areas(frame.area());
|
||||||
let items = profiles.entries.iter().enumerate().map(|(index, entry)| {
|
let items = entries.iter().map(|entry| {
|
||||||
ListItem::new(format!("{}{}", if active == Some(index) { "● " } else { " " }, entry.label()))
|
ListItem::new(match *entry {
|
||||||
|
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 { "▶" })
|
||||||
|
}
|
||||||
|
ExplorerEntry::Relation(index) => {
|
||||||
|
let relation = &browser.as_ref().unwrap().relations[index];
|
||||||
|
format!(" ├ {} [{}]", relation.label, relation.kind)
|
||||||
|
}
|
||||||
|
})
|
||||||
});
|
});
|
||||||
let list = List::new(items)
|
let list = List::new(items)
|
||||||
.block(Block::bordered().title(" Databases · c add ").border_style(
|
.block(Block::bordered().title(" Explorer · c add ").border_style(
|
||||||
Style::default().fg(if databases_focus { Color::Cyan } else { Color::DarkGray })))
|
Style::default().fg(if databases_focus { Color::Cyan } else { Color::DarkGray })))
|
||||||
.highlight_style(Style::default().bg(Color::Blue).fg(Color::White));
|
.highlight_style(Style::default().bg(Color::Blue).fg(Color::White));
|
||||||
frame.render_stateful_widget(list, sidebar, &mut selection);
|
frame.render_stateful_widget(list, sidebar, &mut selection);
|
||||||
@@ -65,12 +82,30 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
|||||||
let result = if form.reconnect {
|
let result = if form.reconnect {
|
||||||
form.connect().and_then(App::new).map(|connected| {
|
form.connect().and_then(App::new).map(|connected| {
|
||||||
browser = Some(connected);
|
browser = Some(connected);
|
||||||
active = selection.selected();
|
active = selection.selected().and_then(|index| {
|
||||||
databases_focus = false;
|
match entries[index] {
|
||||||
|
ExplorerEntry::Database(index) => index,
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
selection.select(active);
|
||||||
|
expanded = true;
|
||||||
|
databases_focus = true;
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
profiles.save(&form).map(|()| {
|
profiles.save(&form).map(|()| {
|
||||||
selection.select(Some(profiles.entries.len() - 1));
|
let updated = explorer_entries(
|
||||||
|
profiles.entries.len(),
|
||||||
|
active,
|
||||||
|
browser.as_ref().map(|app| app.relations.len()),
|
||||||
|
expanded,
|
||||||
|
);
|
||||||
|
selection.select(updated.iter().position(|entry| {
|
||||||
|
*entry
|
||||||
|
== ExplorerEntry::Database(Some(
|
||||||
|
profiles.entries.len() - 1,
|
||||||
|
))
|
||||||
|
}));
|
||||||
databases_focus = true;
|
databases_focus = true;
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -94,20 +129,29 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
|||||||
}
|
}
|
||||||
KeyCode::Esc => databases_focus = true,
|
KeyCode::Esc => databases_focus = true,
|
||||||
KeyCode::Tab | KeyCode::BackTab => {
|
KeyCode::Tab | KeyCode::BackTab => {
|
||||||
if let Some(browser) = &mut browser {
|
if browser.is_some() {
|
||||||
let backwards = key.code == KeyCode::BackTab;
|
databases_focus = !databases_focus;
|
||||||
if databases_focus {
|
|
||||||
databases_focus = false;
|
|
||||||
browser.sidebar_focus = !backwards;
|
|
||||||
} else if browser.sidebar_focus != backwards {
|
|
||||||
browser.sidebar_focus = backwards;
|
|
||||||
} else {
|
|
||||||
databases_focus = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Enter if databases_focus => {
|
KeyCode::Enter if databases_focus => {
|
||||||
if let Some(index) = selection.selected() {
|
if let Some(entry) =
|
||||||
|
selection.selected().and_then(|index| entries.get(index))
|
||||||
|
{
|
||||||
|
let index = match *entry {
|
||||||
|
ExplorerEntry::Relation(index) => {
|
||||||
|
browser.as_mut().unwrap().open_relation(Some(index));
|
||||||
|
databases_focus = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ExplorerEntry::Database(index)
|
||||||
|
if browser.is_some() && index == active =>
|
||||||
|
{
|
||||||
|
expanded = !expanded;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ExplorerEntry::Database(Some(index)) => index,
|
||||||
|
ExplorerEntry::Database(None) => continue,
|
||||||
|
};
|
||||||
form = profiles.entries[index].clone();
|
form = profiles.entries[index].clone();
|
||||||
if form.provider == 0 {
|
if form.provider == 0 {
|
||||||
form.reconnect = true;
|
form.reconnect = true;
|
||||||
@@ -118,7 +162,9 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
|||||||
Ok(connected) => {
|
Ok(connected) => {
|
||||||
browser = Some(connected);
|
browser = Some(connected);
|
||||||
active = Some(index);
|
active = Some(index);
|
||||||
databases_focus = false;
|
selection.select(Some(index));
|
||||||
|
expanded = true;
|
||||||
|
databases_focus = true;
|
||||||
error = None;
|
error = None;
|
||||||
}
|
}
|
||||||
Err(failure) => error = Some(error_text(failure.as_ref())),
|
Err(failure) => error = Some(error_text(failure.as_ref())),
|
||||||
@@ -126,6 +172,33 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
KeyCode::Left | KeyCode::Char('h') if databases_focus => {
|
||||||
|
if matches!(
|
||||||
|
selection.selected().and_then(|index| entries.get(index)),
|
||||||
|
Some(ExplorerEntry::Relation(_))
|
||||||
|
) || selection.selected().and_then(|index| entries.get(index))
|
||||||
|
== Some(&ExplorerEntry::Database(active))
|
||||||
|
{
|
||||||
|
expanded = false;
|
||||||
|
selection.select(
|
||||||
|
entries.iter().position(|entry| {
|
||||||
|
*entry == ExplorerEntry::Database(active)
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Right | KeyCode::Char('l') if databases_focus => {
|
||||||
|
if selection.selected().and_then(|index| entries.get(index))
|
||||||
|
== Some(&ExplorerEntry::Database(active))
|
||||||
|
{
|
||||||
|
expanded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('r') if databases_focus => {
|
||||||
|
if let Some(browser) = &mut browser {
|
||||||
|
browser.handle_key(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
KeyCode::Down | KeyCode::Char('j') if databases_focus => {
|
KeyCode::Down | KeyCode::Char('j') if databases_focus => {
|
||||||
selection.select_next()
|
selection.select_next()
|
||||||
}
|
}
|
||||||
@@ -158,6 +231,33 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum ExplorerEntry {
|
||||||
|
Database(Option<usize>),
|
||||||
|
Relation(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn explorer_entries(
|
||||||
|
count: usize,
|
||||||
|
active: Option<usize>,
|
||||||
|
relations: Option<usize>,
|
||||||
|
expanded: bool,
|
||||||
|
) -> Vec<ExplorerEntry> {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
let session = active.is_none() && relations.is_some();
|
||||||
|
for database in session
|
||||||
|
.then_some(None)
|
||||||
|
.into_iter()
|
||||||
|
.chain((0..count).map(Some))
|
||||||
|
{
|
||||||
|
entries.push(ExplorerEntry::Database(database));
|
||||||
|
if database == active && expanded {
|
||||||
|
entries.extend((0..relations.unwrap_or(0)).map(ExplorerEntry::Relation));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
fn config_path() -> Result<PathBuf> {
|
fn config_path() -> Result<PathBuf> {
|
||||||
let root = std::env::var_os("XDG_CONFIG_HOME")
|
let root = std::env::var_os("XDG_CONFIG_HOME")
|
||||||
.filter(|path| Path::new(path).is_absolute())
|
.filter(|path| Path::new(path).is_absolute())
|
||||||
@@ -614,6 +714,28 @@ impl Input {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explorer_keeps_relations_under_the_connected_database() {
|
||||||
|
use ExplorerEntry::{Database as Db, Relation as Rel};
|
||||||
|
assert_eq!(
|
||||||
|
explorer_entries(3, Some(1), Some(2), true),
|
||||||
|
vec![Db(Some(0)), Db(Some(1)), Rel(0), Rel(1), Db(Some(2))]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
explorer_entries(3, Some(1), Some(2), false),
|
||||||
|
vec![Db(Some(0)), Db(Some(1)), Db(Some(2))]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
explorer_entries(1, None, Some(1), true),
|
||||||
|
vec![Db(None), Rel(0), Db(Some(0))]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
explorer_entries(1, Some(0), Some(0), true),
|
||||||
|
vec![Db(Some(0))]
|
||||||
|
);
|
||||||
|
assert_eq!(explorer_entries(1, None, None, true), vec![Db(Some(0))]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn editing_preserves_unicode_and_filters_terminal_controls() {
|
fn editing_preserves_unicode_and_filters_terminal_controls() {
|
||||||
let mut input = Input::default();
|
let mut input = Input::default();
|
||||||
|
|||||||
+5
-3
@@ -29,14 +29,16 @@ Use sslmode=disable only for trusted local connections.
|
|||||||
The inspector needs 70×12 terminal cells; the PostgreSQL window needs 74×20.
|
The inspector needs 70×12 terminal cells; the PostgreSQL window needs 74×20.
|
||||||
|
|
||||||
Navigation:
|
Navigation:
|
||||||
Tab/Shift-Tab Cycle focus: databases, relations, rows (Esc returns to databases)
|
Tab/Shift-Tab Switch between explorer and rows/schema (Esc returns to explorer)
|
||||||
Up/Down, j/k Move selection (Enter connects to a database or opens a relation)
|
Up/Down, j/k Move selection; Enter connects/expands a database or opens a relation
|
||||||
Home/End, g/G First / last item
|
Home/End, g/G First / last item
|
||||||
s Toggle rows and column schema
|
s Toggle rows and column schema
|
||||||
Left/Right Scroll columns (h/l also work)
|
Left/Right Collapse/expand in explorer; scroll columns in rows/schema (h/l too)
|
||||||
n/p, PgDn/Up Next / previous 100-row page
|
n/p, PgDn/Up Next / previous 100-row page
|
||||||
c Add a configured database in a floating window
|
c Add a configured database in a floating window
|
||||||
r Refresh relations and the active relation
|
r Refresh relations and the active relation
|
||||||
|
Connected databases show their tables and views beneath them in the explorer.
|
||||||
|
Enter on the connected database collapses/expands it without reconnecting.
|
||||||
q / Ctrl-C Quit
|
q / Ctrl-C Quit
|
||||||
New database window: Up/Down chooses a provider; Enter starts editing.
|
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.
|
Tab/Shift-Tab changes fields; Left/Right changes TLS when that field is focused.
|
||||||
|
|||||||
Reference in New Issue
Block a user