From 8ca65cafa4f3365e567376da9b80a2371d10185a Mon Sep 17 00:00:00 2001 From: Takayuki Maeda <41065217+TaKO8Ki@users.noreply.github.com> Date: Sun, 4 Jul 2021 18:36:47 +0900 Subject: [PATCH] Implement structure tab (#9) * implement structure tab * fix border colors * remove margin --- Cargo.lock | 20 ++++++ Cargo.toml | 2 + src/app.rs | 68 ++++++++++++++++++++ src/handlers/mod.rs | 2 + src/handlers/table_list.rs | 28 ++++++++- src/ui/mod.rs | 124 +++++++++++++++++++++++++++++-------- src/utils.rs | 30 ++++++++- 7 files changed, 243 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ece13d7..7183651 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -476,6 +476,8 @@ dependencies = [ "serde", "serde_json", "sqlx", + "strum", + "strum_macros", "tokio", "toml", "tui", @@ -1345,6 +1347,24 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "strum" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf86bbcfd1fa9670b7a129f64fc0c9fcbbfe4f1bc4210e9e98fe71ffc12cde2" + +[[package]] +name = "strum_macros" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d06aaeeee809dbc59eb4556183dd927df67db1540de5be8d3ec0b6636358a5ec" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.4.0" diff --git a/Cargo.toml b/Cargo.toml index 74f4a4d..301cb86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,5 @@ serde_json = "1.0" serde = "1.0" toml = "0.4" regex = "1" +strum = "0.21" +strum_macros = "0.21" diff --git a/src/app.rs b/src/app.rs index d561512..be944bc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,9 +3,29 @@ use crate::{ utils::get_tables, }; use sqlx::mysql::MySqlPool; +use strum::IntoEnumIterator; +use strum_macros::EnumIter; use tui::widgets::{ListState, TableState}; use unicode_width::UnicodeWidthStr; +#[derive(Debug, Clone, Copy, EnumIter)] +pub enum Tab { + Records, + Structure, +} + +impl std::fmt::Display for Tab { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +impl Tab { + pub fn names() -> Vec { + Self::iter().map(|tab| tab.to_string()).collect() + } +} + pub enum FocusBlock { DabataseList(bool), TableList(bool), @@ -32,6 +52,18 @@ pub struct Table { pub engine: Option, } +#[derive(sqlx::FromRow, Debug, Clone)] +pub struct Column { + #[sqlx(rename = "Field")] + pub field: String, + #[sqlx(rename = "Type")] + pub r#type: String, + #[sqlx(rename = "Collation")] + pub collation: String, + #[sqlx(rename = "Null")] + pub null: String, +} + pub struct RecordTable { pub state: TableState, pub headers: Vec, @@ -90,6 +122,24 @@ impl RecordTable { self.column_index -= 1 } } + + pub fn headers(&self) -> Vec { + let mut headers = self.headers[self.column_index..].to_vec(); + headers.insert(0, "".to_string()); + headers + } + + pub fn rows(&self) -> Vec> { + let mut rows = self + .rows + .iter() + .map(|row| row[self.column_index..].to_vec()) + .collect::>>(); + for (index, row) in rows.iter_mut().enumerate() { + row.insert(0, (index + 1).to_string()) + } + rows + } } impl Database { @@ -107,7 +157,9 @@ pub struct App { pub query: String, pub databases: Vec, pub record_table: RecordTable, + pub structure_table: RecordTable, pub focus_block: FocusBlock, + pub selected_tab: Tab, pub user_config: Option, pub selected_connection: ListState, pub selected_database: ListState, @@ -124,7 +176,9 @@ impl Default for App { query: String::new(), databases: Vec::new(), record_table: RecordTable::default(), + structure_table: RecordTable::default(), focus_block: FocusBlock::DabataseList(false), + selected_tab: Tab::Records, user_config: None, selected_connection: ListState::default(), selected_database: ListState::default(), @@ -136,6 +190,20 @@ impl Default for App { } impl App { + pub fn next_tab(&mut self) { + self.selected_tab = match self.selected_tab { + Tab::Records => Tab::Structure, + Tab::Structure => Tab::Records, + } + } + + pub fn previous_tab(&mut self) { + self.selected_tab = match self.selected_tab { + Tab::Records => Tab::Structure, + Tab::Structure => Tab::Records, + } + } + pub fn next_table(&mut self) { let i = match self.selected_table.selected() { Some(i) => { diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index e69d34d..b2d43bc 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -29,6 +29,8 @@ pub async fn handle_app(key: Key, app: &mut App) -> anyhow::Result<()> { _ => app.focus_block = FocusBlock::RecordTable(true), }, Key::Char('e') => app.focus_block = FocusBlock::Query(true), + Key::Right => app.next_tab(), + Key::Left => app.previous_tab(), Key::Esc => app.error = None, _ => (), } diff --git a/src/handlers/table_list.rs b/src/handlers/table_list.rs index d75d453..18376bc 100644 --- a/src/handlers/table_list.rs +++ b/src/handlers/table_list.rs @@ -1,14 +1,14 @@ use crate::app::{App, FocusBlock}; use crate::event::Key; -use crate::utils::get_records; +use crate::utils::{get_columns, get_records}; pub async fn handler(key: Key, app: &mut App, focused: bool) -> anyhow::Result<()> { if focused { match key { Key::Char('j') => { if app.selected_database.selected().is_some() { - app.record_table.column_index = 0; app.next_table(); + app.record_table.column_index = 0; if let Some(database) = app.selected_database() { if let Some(table) = app.selected_table() { let (headers, records) = @@ -18,12 +18,23 @@ pub async fn handler(key: Key, app: &mut App, focused: bool) -> anyhow::Result<( app.record_table.rows = records; } } + + app.structure_table.column_index = 0; + if let Some(database) = app.selected_database() { + if let Some(table) = app.selected_table() { + let (headers, records) = + get_columns(database, table, app.pool.as_ref().unwrap()).await?; + app.structure_table.state.select(Some(0)); + app.structure_table.headers = headers; + app.structure_table.rows = records; + } + } } } Key::Char('k') => { if app.selected_database.selected().is_some() { - app.record_table.column_index = 0; app.previous_table(); + app.record_table.column_index = 0; if let Some(database) = app.selected_database() { if let Some(table) = app.selected_table() { let (headers, records) = @@ -33,6 +44,17 @@ pub async fn handler(key: Key, app: &mut App, focused: bool) -> anyhow::Result<( app.record_table.rows = records; } } + + app.structure_table.column_index = 0; + if let Some(database) = app.selected_database() { + if let Some(table) = app.selected_table() { + let (headers, records) = + get_columns(database, table, app.pool.as_ref().unwrap()).await?; + app.structure_table.state.select(Some(0)); + app.structure_table.headers = headers; + app.structure_table.rows = records; + } + } } } Key::Esc => app.focus_block = FocusBlock::TableList(false), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 70fdecb..0dc0255 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,10 +1,10 @@ -use crate::app::{App, FocusBlock}; +use crate::app::{App, FocusBlock, Tab}; use tui::{ backend::Backend, - layout::{Constraint, Direction, Layout}, - style::{Color, Style}, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, text::{Span, Spans}, - widgets::{Block, Borders, Cell, Clear, List, ListItem, Paragraph, Row, Table}, + widgets::{Block, Borders, Cell, Clear, List, ListItem, Paragraph, Row, Table, Tabs}, Frame, }; use unicode_width::UnicodeWidthStr; @@ -26,7 +26,7 @@ pub fn draw(f: &mut Frame<'_, B>, app: &mut App) -> anyhow::Result<( .highlight_style(Style::default().fg(Color::Green)) .style(match app.focus_block { FocusBlock::ConnectionList => Style::default().fg(Color::Green), - _ => Style::default(), + _ => Style::default().fg(Color::DarkGray), }); let popup_layout = Layout::default() .direction(Direction::Vertical) @@ -57,7 +57,6 @@ pub fn draw(f: &mut Frame<'_, B>, app: &mut App) -> anyhow::Result<( } let main_chunks = Layout::default() - .margin(2) .direction(Direction::Horizontal) .constraints([Constraint::Percentage(15), Constraint::Percentage(85)]) .split(f.size()); @@ -84,9 +83,9 @@ pub fn draw(f: &mut Frame<'_, B>, app: &mut App) -> anyhow::Result<( .block(Block::default().borders(Borders::ALL).title("Databases")) .highlight_style(Style::default().fg(Color::Green)) .style(match app.focus_block { - FocusBlock::DabataseList(false) => Style::default().fg(Color::Magenta), + FocusBlock::DabataseList(false) => Style::default(), FocusBlock::DabataseList(true) => Style::default().fg(Color::Green), - _ => Style::default(), + _ => Style::default().fg(Color::DarkGray), }); f.render_stateful_widget(tasks, left_chunks[0], &mut app.selected_database); @@ -103,9 +102,9 @@ pub fn draw(f: &mut Frame<'_, B>, app: &mut App) -> anyhow::Result<( .block(Block::default().borders(Borders::ALL).title("Tables")) .highlight_style(Style::default().fg(Color::Green)) .style(match app.focus_block { - FocusBlock::TableList(false) => Style::default().fg(Color::Magenta), + FocusBlock::TableList(false) => Style::default(), FocusBlock::TableList(true) => Style::default().fg(Color::Green), - _ => Style::default(), + _ => Style::default().fg(Color::DarkGray), }); f.render_stateful_widget(tasks, left_chunks[1], &mut app.selected_table); @@ -124,36 +123,71 @@ pub fn draw(f: &mut Frame<'_, B>, app: &mut App) -> anyhow::Result<( let right_chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Length(5)].as_ref()) + .constraints( + [ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(5), + ] + .as_ref(), + ) .split(main_chunks[1]); + let titles = Tab::names().iter().cloned().map(Spans::from).collect(); + let tabs = Tabs::new(titles) + .block(Block::default().borders(Borders::ALL)) + .select(app.selected_tab as usize) + .style(Style::default().fg(Color::DarkGray)) + .highlight_style( + Style::default() + .fg(Color::Reset) + .add_modifier(Modifier::UNDERLINED), + ); + f.render_widget(tabs, right_chunks[0]); + let query = Paragraph::new(app.input.as_ref()) .style(match app.focus_block { FocusBlock::Query(true) => Style::default().fg(Color::Green), - FocusBlock::Query(false) => Style::default().fg(Color::Magenta), - _ => Style::default(), + FocusBlock::Query(false) => Style::default(), + _ => Style::default().fg(Color::DarkGray), }) .block(Block::default().borders(Borders::ALL).title("Query")); - f.render_widget(query, right_chunks[0]); + f.render_widget(query, right_chunks[1]); if let FocusBlock::Query(true) = app.focus_block { f.set_cursor( - right_chunks[0].x + app.input.width() as u16 + 1 - app.input_cursor_x, - right_chunks[0].y + 1, + right_chunks[1].x + app.input.width() as u16 + 1 - app.input_cursor_x, + right_chunks[1].y + 1, ) } + match app.selected_tab { + Tab::Records => draw_records_table(f, app, right_chunks[2])?, + Tab::Structure => draw_structure_table(f, app, right_chunks[2])?, + } + if let Some(err) = app.error.clone() { + draw_error_popup(f, err)?; + } + Ok(()) +} - let header_cells = app.record_table.headers[app.record_table.column_index..] +fn draw_structure_table( + f: &mut Frame<'_, B>, + app: &mut App, + layout_chunk: Rect, +) -> anyhow::Result<()> { + let headers = app.structure_table.headers(); + let header_cells = headers .iter() .map(|h| Cell::from(h.to_string()).style(Style::default().fg(Color::White))); let header = Row::new(header_cells).height(1).bottom_margin(1); - let rows = app.record_table.rows.iter().map(|item| { - let height = item[app.record_table.column_index..] + let rows = app.structure_table.rows(); + let rows = rows.iter().map(|item| { + let height = item .iter() .map(|content| content.chars().filter(|c| *c == '\n').count()) .max() .unwrap_or(0) + 1; - let cells = item[app.record_table.column_index..] + let cells = item .iter() .map(|c| Cell::from(c.to_string()).style(Style::default().fg(Color::White))); Row::new(cells).height(height as u16).bottom_margin(1) @@ -163,19 +197,55 @@ pub fn draw(f: &mut Frame<'_, B>, app: &mut App) -> anyhow::Result<( .collect::>(); let t = Table::new(rows) .header(header) - .block(Block::default().borders(Borders::ALL).title("Records")) + .block(Block::default().borders(Borders::ALL).title("Structure")) .highlight_style(Style::default().fg(Color::Green)) .style(match app.focus_block { - FocusBlock::RecordTable(false) => Style::default().fg(Color::Magenta), + FocusBlock::RecordTable(false) => Style::default(), FocusBlock::RecordTable(true) => Style::default().fg(Color::Green), - _ => Style::default(), + _ => Style::default().fg(Color::DarkGray), }) .widths(&widths); - f.render_stateful_widget(t, right_chunks[1], &mut app.record_table.state); + f.render_stateful_widget(t, layout_chunk, &mut app.structure_table.state); + Ok(()) +} - if let Some(err) = app.error.clone() { - draw_error_popup(f, err)?; - } +fn draw_records_table( + f: &mut Frame<'_, B>, + app: &mut App, + layout_chunk: Rect, +) -> anyhow::Result<()> { + let headers = app.record_table.headers(); + let header_cells = headers + .iter() + .map(|h| Cell::from(h.to_string()).style(Style::default().fg(Color::White))); + let header = Row::new(header_cells).height(1).bottom_margin(1); + let rows = app.record_table.rows(); + let rows = rows.iter().map(|item| { + let height = item + .iter() + .map(|content| content.chars().filter(|c| *c == '\n').count()) + .max() + .unwrap_or(0) + + 1; + let cells = item + .iter() + .map(|c| Cell::from(c.to_string()).style(Style::default().fg(Color::White))); + Row::new(cells).height(height as u16).bottom_margin(1) + }); + let widths = (0..10) + .map(|_| Constraint::Percentage(10)) + .collect::>(); + let t = Table::new(rows) + .header(header) + .block(Block::default().borders(Borders::ALL).title("Records")) + .highlight_style(Style::default().fg(Color::Green)) + .style(match app.focus_block { + FocusBlock::RecordTable(false) => Style::default(), + FocusBlock::RecordTable(true) => Style::default().fg(Color::Green), + _ => Style::default().fg(Color::DarkGray), + }) + .widths(&widths); + f.render_stateful_widget(t, layout_chunk, &mut app.record_table.state); Ok(()) } diff --git a/src/utils.rs b/src/utils.rs index 05916fa..e21036a 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -2,7 +2,7 @@ use crate::app::{Database, Table}; use chrono::NaiveDate; use futures::TryStreamExt; use sqlx::mysql::{MySqlColumn, MySqlPool, MySqlRow}; -use sqlx::{Column, Row, TypeInfo}; +use sqlx::{Column as _, Row, TypeInfo}; pub async fn get_databases(pool: &MySqlPool) -> anyhow::Result> { let databases = sqlx::query("SHOW DATABASES") @@ -52,6 +52,34 @@ pub async fn get_records( Ok((headers, records)) } +pub async fn get_columns( + database: &Database, + table: &Table, + pool: &MySqlPool, +) -> anyhow::Result<(Vec, Vec>)> { + let query = format!( + "SHOW FULL COLUMNS FROM `{}`.`{}`", + database.name, table.name + ); + let mut rows = sqlx::query(query.as_str()).fetch(pool); + let mut headers = vec![]; + let mut records = vec![]; + while let Some(row) = rows.try_next().await? { + headers = row + .columns() + .iter() + .map(|column| column.name().to_string()) + .collect(); + records.push( + row.columns() + .iter() + .map(|col| convert_column_value_to_string(&row, col)) + .collect::>(), + ) + } + Ok((headers, records)) +} + pub fn convert_column_value_to_string(row: &MySqlRow, column: &MySqlColumn) -> String { let column_name = column.name(); match column.type_info().clone().name() {