Merge branch 'main' of github.com:TaKO8Ki/gobang

pull/8/head
Takayuki Maeda 3 years ago
commit 1daec68bb2

@ -67,6 +67,7 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Cache cargo registry
if: runner.os != 'macOS'
uses: actions/cache@v1
with:
path: ~/.cargo/registry
@ -77,6 +78,7 @@ jobs:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
if: runner.os != 'macOS'
uses: actions/cache@v1
with:
path: target

2
.gitignore vendored

@ -1 +1,3 @@
/target
gobang
gobang.yml

32
Cargo.lock generated

@ -183,9 +183,9 @@ dependencies = [
[[package]]
name = "cpufeatures"
version = "0.1.4"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed00c67cb5d0a7d64a44f6ad2668db7e7530311dd53ea79bcd4fb022c64911c8"
checksum = "66c99696f6c9dd7f35d486b9d04d7e6e202aa3e8c40d553f2fdf5e7e0c6a71ef"
dependencies = [
"libc",
]
@ -239,7 +239,7 @@ dependencies = [
"crossterm_winapi 0.6.2",
"lazy_static",
"libc",
"mio 0.7.11",
"mio 0.7.13",
"parking_lot",
"signal-hook",
"winapi 0.3.9",
@ -255,7 +255,7 @@ dependencies = [
"crossterm_winapi 0.7.0",
"lazy_static",
"libc",
"mio 0.7.11",
"mio 0.7.13",
"parking_lot",
"signal-hook",
"winapi 0.3.9",
@ -469,10 +469,14 @@ name = "gobang"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"crossterm 0.19.0",
"futures",
"serde",
"serde_json",
"sqlx",
"tokio",
"toml",
"tui",
"unicode-width",
]
@ -664,9 +668,9 @@ dependencies = [
[[package]]
name = "mio"
version = "0.7.11"
version = "0.7.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf80d3e903b34e0bd7282b218398aec54e082c840d9baf8339e0080a0c542956"
checksum = "8c2bdb6314ec10835cd3293dd268473a835c02b7b352e788be788b3c6ca6bb16"
dependencies = [
"libc",
"log",
@ -999,9 +1003,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
version = "0.2.8"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "742739e41cd49414de871ea5e549afb7e2a3ac77b589bcbebe8c82fab37147fc"
checksum = "5ab49abadf3f9e1c4bc499e8845e152ad87d2ad2d30371841171169e9d75feee"
dependencies = [
"bitflags",
]
@ -1178,7 +1182,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e31d442c16f047a671b5a71e2161d6e68814012b7f5379d269ebd915fac2729"
dependencies = [
"libc",
"mio 0.7.11",
"mio 0.7.13",
"signal-hook-registry",
]
@ -1255,6 +1259,7 @@ dependencies = [
"bitflags",
"byteorder",
"bytes",
"chrono",
"crc",
"crossbeam-channel",
"crossbeam-queue",
@ -1466,6 +1471,15 @@ dependencies = [
"webpki",
]
[[package]]
name = "toml"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758664fc71a3a69038656bee8b6be6477d2a6c315a6b81f7081f591bffa4111f"
dependencies = [
"serde",
]
[[package]]
name = "tui"
version = "0.14.0"

@ -9,6 +9,10 @@ tui = { version = "0.14.0", features = ["crossterm"], default-features = false }
crossterm = "0.19"
anyhow = "1.0.38"
unicode-width = "0.1"
sqlx = { version = "0.4.1", features = ["mysql", "runtime-tokio-rustls"] }
tokio = { version = "0.2", features = ["full"] }
sqlx = { version = "0.4.1", features = ["mysql", "chrono", "runtime-tokio-rustls"] }
chrono = "0.4"
tokio = { version = "0.2.22", features = ["full"] }
futures = "0.3.5"
serde_json = "1.0"
serde = "1.0"
toml = "0.4"

@ -6,4 +6,6 @@ A cross-platform terminal database tool written in Rust
[![github workflow status](https://img.shields.io/github/workflow/status/TaKO8Ki/gobang/CI/main)](https://github.com/TaKO8Ki/gobang/actions)
![gobang](./resources/gobang.gif)
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 MiB

@ -0,0 +1,11 @@
[[conn]]
name = "sample"
user = "root"
host = "localhost"
port = 3306
[[conn]]
user = "root"
host = "localhost"
port = 3306
database = "world"

@ -1,16 +1,143 @@
use crate::{
user_config::{Connection, UserConfig},
utils::get_tables,
};
use sqlx::mysql::MySqlPool;
use tui::widgets::{ListState, TableState};
pub enum InputMode {
Normal,
Editing,
}
pub enum FocusType {
Dabatases(bool),
Tables(bool),
Records(bool),
Connections,
}
#[derive(Clone)]
pub struct Database {
pub selected_table: ListState,
pub name: String,
pub tables: Vec<Table>,
}
#[derive(Clone)]
pub struct Table {
pub name: String,
}
pub struct RecordTable {
pub state: TableState,
pub headers: Vec<String>,
pub rows: Vec<Vec<String>>,
pub column_index: usize,
}
impl Default for RecordTable {
fn default() -> Self {
Self {
state: TableState::default(),
headers: vec![],
rows: vec![],
column_index: 0,
}
}
}
impl RecordTable {
pub fn next(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i >= self.rows.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.state.select(Some(i));
}
pub fn previous(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i == 0 {
self.rows.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.state.select(Some(i));
}
pub fn next_column(&mut self) {
if self.headers.len() > 9 && self.column_index < self.headers.len() - 9 {
self.column_index += 1
}
}
pub fn previous_column(&mut self) {
if self.column_index > 0 {
self.column_index -= 1
}
}
}
impl Database {
pub async fn new(name: String, pool: &MySqlPool) -> anyhow::Result<Self> {
Ok(Self {
selected_table: ListState::default(),
name: name.clone(),
tables: get_tables(name, pool).await?,
})
}
pub fn next(&mut self) {
let i = match self.selected_table.selected() {
Some(i) => {
if i >= self.tables.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.selected_table.select(Some(i));
}
pub fn previous(&mut self) {
let i = match self.selected_table.selected() {
Some(i) => {
if i == 0 {
self.tables.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.selected_table.select(Some(i));
}
}
pub struct App {
/// Current value of the input box
pub input: String,
/// Current input mode
pub input_mode: InputMode,
/// History of recorded messages
pub messages: Vec<Vec<String>>,
pub tables: Vec<String>,
pub selected_database: ListState,
pub databases: Vec<Database>,
pub record_table: RecordTable,
pub focus_type: FocusType,
pub user_config: Option<UserConfig>,
pub selected_connection: ListState,
pub pool: Option<MySqlPool>,
}
impl Default for App {
@ -19,7 +146,13 @@ impl Default for App {
input: String::new(),
input_mode: InputMode::Normal,
messages: Vec::new(),
tables: Vec::new(),
selected_database: ListState::default(),
databases: Vec::new(),
record_table: RecordTable::default(),
focus_type: FocusType::Dabatases(false),
user_config: None,
selected_connection: ListState::default(),
pool: None,
}
}
}
@ -28,4 +161,94 @@ impl App {
pub fn new(title: &str, enhanced_graphics: bool) -> App {
Self::default()
}
pub fn next_database(&mut self) {
let i = match self.selected_database.selected() {
Some(i) => {
if i >= self.databases.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.selected_database.select(Some(i));
}
pub fn previous_database(&mut self) {
let i = match self.selected_database.selected() {
Some(i) => {
if i == 0 {
self.databases.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.selected_database.select(Some(i));
}
pub fn next_connection(&mut self) {
if let Some(config) = &self.user_config {
let i = match self.selected_connection.selected() {
Some(i) => {
if i >= config.conn.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.selected_connection.select(Some(i));
}
}
pub fn previous_connection(&mut self) {
if let Some(config) = &self.user_config {
let i = match self.selected_connection.selected() {
Some(i) => {
if i == 0 {
config.conn.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.selected_connection.select(Some(i));
}
}
pub fn selected_database(&self) -> Option<&Database> {
match self.selected_database.selected() {
Some(i) => match self.databases.get(i) {
Some(db) => Some(db),
None => None,
},
None => None,
}
}
pub fn selected_table(&self) -> Option<&Table> {
match self.selected_database() {
Some(db) => match db.selected_table.selected() {
Some(i) => db.tables.get(i),
None => None,
},
None => None,
}
}
pub fn selected_connection(&self) -> Option<&Connection> {
match &self.user_config {
Some(config) => match self.selected_connection.selected() {
Some(i) => config.conn.get(i),
None => None,
},
None => None,
}
}
}

@ -0,0 +1,76 @@
use crate::event::Key;
use crossterm::event;
use std::{sync::mpsc, thread, time::Duration};
#[derive(Debug, Clone, Copy)]
/// Configuration for event handling.
pub struct EventConfig {
/// The key that is used to exit the application.
pub exit_key: Key,
/// The tick rate at which the application will sent an tick event.
pub tick_rate: Duration,
}
impl Default for EventConfig {
fn default() -> EventConfig {
EventConfig {
exit_key: Key::Ctrl('c'),
tick_rate: Duration::from_millis(250),
}
}
}
/// An occurred event.
pub enum Event<I> {
/// An input event occurred.
Input(I),
/// An tick event occurred.
Tick,
}
/// A small event handler that wrap crossterm input and tick event. Each event
/// type is handled in its own thread and returned to a common `Receiver`
pub struct Events {
rx: mpsc::Receiver<Event<Key>>,
// Need to be kept around to prevent disposing the sender side.
_tx: mpsc::Sender<Event<Key>>,
}
impl Events {
/// Constructs an new instance of `Events` with the default config.
pub fn new(tick_rate: u64) -> Events {
Events::with_config(EventConfig {
tick_rate: Duration::from_millis(tick_rate),
..Default::default()
})
}
/// Constructs an new instance of `Events` from given config.
pub fn with_config(config: EventConfig) -> Events {
let (tx, rx) = mpsc::channel();
let event_tx = tx.clone();
thread::spawn(move || {
loop {
// poll for tick rate duration, if no event, sent tick event.
if event::poll(config.tick_rate).unwrap() {
if let event::Event::Key(key) = event::read().unwrap() {
let key = Key::from(key);
event_tx.send(Event::Input(key)).unwrap();
}
}
event_tx.send(Event::Tick).unwrap();
}
});
Events { rx, _tx: tx }
}
/// Attempts to read an event.
/// This function will block the current thread.
pub fn next(&self) -> Result<Event<Key>, mpsc::RecvError> {
self.rx.recv()
}
}

@ -0,0 +1,205 @@
use crossterm::event;
use std::fmt;
/// Represents a key.
#[derive(PartialEq, Eq, Clone, Copy, Hash, Debug)]
pub enum Key {
/// Both Enter (or Return) and numpad Enter
Enter,
/// Tabulation key
Tab,
/// Backspace key
Backspace,
/// Escape key
Esc,
/// Left arrow
Left,
/// Right arrow
Right,
/// Up arrow
Up,
/// Down arrow
Down,
/// Insert key
Ins,
/// Delete key
Delete,
/// Home key
Home,
/// End key
End,
/// Page Up key
PageUp,
/// Page Down key
PageDown,
/// F0 key
F0,
/// F1 key
F1,
/// F2 key
F2,
/// F3 key
F3,
/// F4 key
F4,
/// F5 key
F5,
/// F6 key
F6,
/// F7 key
F7,
/// F8 key
F8,
/// F9 key
F9,
/// F10 key
F10,
/// F11 key
F11,
/// F12 key
F12,
Char(char),
Ctrl(char),
Alt(char),
Unkown,
}
impl Key {
/// Returns the function key corresponding to the given number
///
/// 1 -> F1, etc...
///
/// # Panics
///
/// If `n == 0 || n > 12`
pub fn from_f(n: u8) -> Key {
match n {
0 => Key::F0,
1 => Key::F1,
2 => Key::F2,
3 => Key::F3,
4 => Key::F4,
5 => Key::F5,
6 => Key::F6,
7 => Key::F7,
8 => Key::F8,
9 => Key::F9,
10 => Key::F10,
11 => Key::F11,
12 => Key::F12,
_ => panic!("unknown function key: F{}", n),
}
}
}
impl fmt::Display for Key {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
Key::Alt(' ') => write!(f, "<Alt+Space>"),
Key::Ctrl(' ') => write!(f, "<Ctrl+Space>"),
Key::Char(' ') => write!(f, "<Space>"),
Key::Alt(c) => write!(f, "<Alt+{}>", c),
Key::Ctrl(c) => write!(f, "<Ctrl+{}>", c),
Key::Char(c) => write!(f, "{}", c),
Key::Left | Key::Right | Key::Up | Key::Down => write!(f, "<{:?} Arrow Key>", self),
Key::Enter
| Key::Tab
| Key::Backspace
| Key::Esc
| Key::Ins
| Key::Delete
| Key::Home
| Key::End
| Key::PageUp
| Key::PageDown => write!(f, "<{:?}>", self),
_ => write!(f, "{:?}", self),
}
}
}
impl From<event::KeyEvent> for Key {
fn from(key_event: event::KeyEvent) -> Self {
match key_event {
event::KeyEvent {
code: event::KeyCode::Esc,
..
} => Key::Esc,
event::KeyEvent {
code: event::KeyCode::Backspace,
..
} => Key::Backspace,
event::KeyEvent {
code: event::KeyCode::Left,
..
} => Key::Left,
event::KeyEvent {
code: event::KeyCode::Right,
..
} => Key::Right,
event::KeyEvent {
code: event::KeyCode::Up,
..
} => Key::Up,
event::KeyEvent {
code: event::KeyCode::Down,
..
} => Key::Down,
event::KeyEvent {
code: event::KeyCode::Home,
..
} => Key::Home,
event::KeyEvent {
code: event::KeyCode::End,
..
} => Key::End,
event::KeyEvent {
code: event::KeyCode::PageUp,
..
} => Key::PageUp,
event::KeyEvent {
code: event::KeyCode::PageDown,
..
} => Key::PageDown,
event::KeyEvent {
code: event::KeyCode::Delete,
..
} => Key::Delete,
event::KeyEvent {
code: event::KeyCode::Insert,
..
} => Key::Ins,
event::KeyEvent {
code: event::KeyCode::F(n),
..
} => Key::from_f(n),
event::KeyEvent {
code: event::KeyCode::Enter,
..
} => Key::Enter,
event::KeyEvent {
code: event::KeyCode::Tab,
..
} => Key::Tab,
// First check for char + modifier
event::KeyEvent {
code: event::KeyCode::Char(c),
modifiers: event::KeyModifiers::ALT,
} => Key::Alt(c),
event::KeyEvent {
code: event::KeyCode::Char(c),
modifiers: event::KeyModifiers::CONTROL,
} => Key::Ctrl(c),
event::KeyEvent {
code: event::KeyCode::Char(c),
..
} => Key::Char(c),
_ => Key::Unkown,
}
}
}

@ -0,0 +1,7 @@
mod events;
mod key;
pub use self::{
events::{Event, Events},
key::Key,
};

@ -0,0 +1,13 @@
use crate::app::{App, FocusType};
use crate::event::Key;
use sqlx::mysql::MySqlPool;
pub async fn handler(_key: Key, app: &mut App) -> anyhow::Result<()> {
if let Some(conn) = app.selected_connection() {
app.pool.as_ref().unwrap().close().await;
let pool = MySqlPool::connect(conn.database_url().as_str()).await?;
app.pool = Some(pool);
app.focus_type = FocusType::Dabatases(true);
}
Ok(())
}

@ -0,0 +1,16 @@
use crate::app::{App, Database};
use crate::event::Key;
use crate::utils::get_databases;
pub async fn handler(_key: Key, app: &mut App) -> anyhow::Result<()> {
app.databases = match app.selected_connection() {
Some(conn) => match &conn.database {
Some(database) => {
vec![Database::new(database.clone(), app.pool.as_ref().unwrap()).await?]
}
None => get_databases(app.pool.as_ref().unwrap()).await?,
},
None => vec![],
};
Ok(())
}

@ -0,0 +1,94 @@
pub mod create_connection;
pub mod database_list;
pub mod record_table;
use crate::app::{App, FocusType, InputMode};
use crate::event::Key;
pub async fn handle_app(key: Key, app: &mut App) -> anyhow::Result<()> {
match app.input_mode {
InputMode::Normal => match key {
Key::Char('e') => {
app.input_mode = InputMode::Editing;
}
Key::Char('c') => {
app.focus_type = FocusType::Connections;
}
Key::Char('l') => app.focus_type = FocusType::Records(false),
Key::Char('h') => app.focus_type = FocusType::Tables(false),
Key::Char('j') => {
if let FocusType::Dabatases(_) = app.focus_type {
app.focus_type = FocusType::Tables(false)
}
}
Key::Char('k') => {
if let FocusType::Tables(_) = app.focus_type {
app.focus_type = FocusType::Dabatases(false)
}
}
Key::Right => match app.focus_type {
FocusType::Records(true) => app.record_table.next_column(),
_ => (),
},
Key::Left => match app.focus_type {
FocusType::Records(true) => app.record_table.previous_column(),
_ => (),
},
Key::Up => match app.focus_type {
FocusType::Connections => app.previous_connection(),
FocusType::Records(true) => app.record_table.previous(),
FocusType::Dabatases(true) => app.previous_database(),
FocusType::Tables(true) => match app.selected_database.selected() {
Some(index) => {
app.record_table.column_index = 0;
app.databases[index].previous();
record_table::handler(key, app).await?;
}
None => (),
},
_ => (),
},
Key::Down => match app.focus_type {
FocusType::Connections => app.next_connection(),
FocusType::Records(true) => app.record_table.next(),
FocusType::Dabatases(true) => app.next_database(),
FocusType::Tables(true) => match app.selected_database.selected() {
Some(index) => {
app.record_table.column_index = 0;
app.databases[index].next();
record_table::handler(key, app).await?
}
None => (),
},
_ => (),
},
Key::Enter => match app.focus_type {
FocusType::Connections => {
create_connection::handler(key, app).await?;
database_list::handler(key, app).await?;
}
FocusType::Records(false) => app.focus_type = FocusType::Records(true),
FocusType::Dabatases(false) => app.focus_type = FocusType::Dabatases(true),
FocusType::Tables(false) => app.focus_type = FocusType::Tables(true),
_ => (),
},
_ => {}
},
InputMode::Editing => match key {
Key::Enter => {
app.messages.push(vec![app.input.drain(..).collect()]);
}
Key::Char(c) => {
app.input.push(c);
}
Key::Backspace => {
app.input.pop();
}
Key::Esc => {
app.input_mode = InputMode::Normal;
}
_ => {}
},
}
Ok(())
}

@ -0,0 +1,15 @@
use crate::app::App;
use crate::event::Key;
use crate::utils::get_records;
pub async fn handler(_key: Key, app: &mut App) -> anyhow::Result<()> {
if let Some(database) = app.selected_database() {
if let Some(table) = app.selected_table() {
let (headers, records) =
get_records(database, table, app.pool.as_ref().unwrap()).await?;
app.record_table.headers = headers;
app.record_table.rows = records;
}
}
Ok(())
}

@ -1,182 +1,84 @@
mod app;
mod event;
mod handlers;
mod ui;
mod user_config;
mod utils;
use crate::app::App;
use crate::app::InputMode;
use crate::app::FocusType;
use crate::event::{Event, Key};
use crate::handlers::handle_app;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event as CEvent, KeyCode},
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::{
error::Error,
io::stdout,
sync::mpsc,
thread,
time::{Duration, Instant},
};
use tui::{backend::CrosstermBackend, widgets::TableState, Terminal};
enum Event<I> {
Input(I),
Tick,
}
pub struct StatefulTable<'a> {
state: TableState,
items: Vec<Vec<&'a str>>,
}
impl<'a> StatefulTable<'a> {
fn new() -> StatefulTable<'a> {
StatefulTable {
state: TableState::default(),
items: vec![
vec!["Row11", "Row12", "Row13", "Row14", "Row15", "Row16"],
vec!["Row11", "Row12", "Row13", "Row13", "Row13", "Row13"],
],
}
}
pub fn next(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i >= self.items.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.state.select(Some(i));
}
pub fn previous(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i == 0 {
self.items.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.state.select(Some(i));
}
}
/// Crossterm demo
#[derive(Debug)]
struct Cli {
/// time in ms between two ticks.
tick_rate: u64,
/// whether unicode symbols are used to improve the overall look of the app
enhanced_graphics: bool,
}
use sqlx::mysql::MySqlPool;
use std::io::stdout;
use tui::{backend::CrosstermBackend, Terminal};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli: Cli = Cli {
tick_rate: 250,
enhanced_graphics: true,
};
enable_raw_mode()?;
let config = user_config::UserConfig::new("sample.toml").unwrap();
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Setup input handling
let (tx, rx) = mpsc::channel();
let tick_rate = Duration::from_millis(cli.tick_rate);
thread::spawn(move || {
let mut last_tick = Instant::now();
loop {
// poll for tick rate duration, if no events, sent tick event.
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0));
if event::poll(timeout).unwrap() {
if let CEvent::Key(key) = event::read().unwrap() {
tx.send(Event::Input(key)).unwrap();
}
}
if last_tick.elapsed() >= tick_rate {
tx.send(Event::Tick).unwrap();
last_tick = Instant::now();
}
}
});
use sqlx::mysql::{MySqlPool, MySqlRow};
use sqlx::Row as _;
let mut app = App::new("Crossterm Demo", cli.enhanced_graphics);
let pool = MySqlPool::connect("mysql://root:@localhost:3306/hoge").await?;
let mut rows = sqlx::query("SELECT * FROM user").fetch(&pool);
let mut tables = sqlx::query("show tables")
.fetch_all(&pool)
.await?
.iter()
.map(|table| table.get(0))
.collect::<Vec<String>>();
app.tables = tables;
let events = event::Events::new(250);
let mut app = &mut app::App::default();
app.user_config = Some(config);
let conn = &app.user_config.as_ref().unwrap().conn.get(0).unwrap();
let pool = MySqlPool::connect(
format!(
"mysql://{user}:@{host}:{port}",
user = conn.user,
host = conn.host,
port = conn.port
)
.as_str(),
)
.await?;
app.pool = Some(pool);
app.databases = utils::get_databases(app.pool.as_ref().unwrap()).await?;
let (headers, records) = utils::get_records(
app.databases.first().unwrap(),
app.databases.first().unwrap().tables.first().unwrap(),
app.pool.as_ref().unwrap(),
)
.await?;
app.record_table.rows = records;
app.record_table.headers = headers;
app.selected_database.select(Some(0));
app.focus_type = FocusType::Connections;
terminal.clear()?;
let mut table = StatefulTable::new();
loop {
terminal.draw(|f| ui::draw(f, &mut app, &mut table).unwrap())?;
match rx.recv()? {
Event::Input(event) => match app.input_mode {
InputMode::Normal => match event.code {
KeyCode::Char('e') => {
app.input_mode = InputMode::Editing;
}
KeyCode::Char('q') => {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
break;
}
KeyCode::Up => table.previous(),
KeyCode::Down => table.next(),
_ => {}
},
InputMode::Editing => match event.code {
KeyCode::Enter => {
app.messages.push(vec![app.input.drain(..).collect()]);
}
KeyCode::Char(c) => {
app.input.push(c);
}
KeyCode::Backspace => {
app.input.pop();
}
KeyCode::Esc => {
app.input_mode = InputMode::Normal;
}
_ => {}
},
},
terminal.draw(|f| ui::draw(f, &mut app).unwrap())?;
match events.next()? {
Event::Input(key) => {
if key == Key::Char('q') {
break;
};
handle_app(key, app).await?
}
Event::Tick => (),
}
}
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}

@ -1,141 +1,164 @@
use crate::app::InputMode;
use crate::App;
use crate::StatefulTable;
use crate::app::{App, FocusType};
use tui::{
backend::Backend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
symbols,
text::{Span, Spans, Text},
widgets::canvas::{Canvas, Line, Map, MapResolution, Rectangle},
widgets::{
Axis, BarChart, Block, Borders, Cell, Chart, Dataset, Gauge, LineGauge, List, ListItem,
Paragraph, Row, Sparkline, Table, Tabs, Wrap,
},
layout::{Constraint, Direction, Layout},
style::{Color, Style},
text::{Span, Spans},
widgets::{Block, Borders, Cell, Clear, List, ListItem, Paragraph, Row, Table},
Frame,
};
use unicode_width::UnicodeWidthStr;
pub fn draw<B: Backend>(
f: &mut Frame<'_, B>,
app: &mut App,
table: &mut StatefulTable<'_>,
) -> anyhow::Result<()> {
let chunks = Layout::default()
pub fn draw<B: Backend>(f: &mut Frame<'_, B>, app: &mut App) -> anyhow::Result<()> {
if let FocusType::Connections = app.focus_type {
let percent_x = 60;
let percent_y = 50;
let conns = &app.user_config.as_ref().unwrap().conn;
let connections: Vec<ListItem> = conns
.iter()
.map(|i| {
ListItem::new(vec![Spans::from(Span::raw(i.database_url()))])
.style(Style::default().fg(Color::White))
})
.collect();
let tasks = List::new(connections)
.block(Block::default().borders(Borders::ALL).title("Connections"))
.highlight_style(Style::default().fg(Color::Green))
.style(match app.focus_type {
FocusType::Connections => Style::default().fg(Color::Green),
_ => Style::default(),
});
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
]
.as_ref(),
)
.split(f.size());
let area = Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
]
.as_ref(),
)
.split(popup_layout[1])[1];
f.render_widget(Clear, area);
f.render_stateful_widget(tasks, area, &mut app.selected_connection);
return Ok(());
}
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.margin(2)
.constraints([Constraint::Percentage(15), Constraint::Percentage(85)])
.direction(Direction::Horizontal)
.split(f.size());
let tables: Vec<ListItem> = app
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)].as_ref())
.split(main_chunks[0]);
let databases: Vec<ListItem> = app
.databases
.iter()
.map(|i| {
ListItem::new(vec![Spans::from(Span::raw(&i.name))])
.style(Style::default().fg(Color::White))
})
.collect();
let tasks = List::new(databases)
.block(Block::default().borders(Borders::ALL).title("Databases"))
.highlight_style(Style::default().fg(Color::Green))
.style(match app.focus_type {
FocusType::Dabatases(false) => Style::default().fg(Color::Magenta),
FocusType::Dabatases(true) => Style::default().fg(Color::Green),
_ => Style::default(),
});
f.render_stateful_widget(tasks, left_chunks[0], &mut app.selected_database);
let databases = app.databases.clone();
let tables: Vec<ListItem> = databases[app.selected_database.selected().unwrap_or(0)]
.tables
.iter()
.map(|i| ListItem::new(vec![Spans::from(Span::raw(i))]))
.map(|i| {
ListItem::new(vec![Spans::from(Span::raw(&i.name))])
.style(Style::default().fg(Color::White))
})
.collect();
let tasks = List::new(tables)
.block(Block::default().borders(Borders::ALL).title("Tables"))
.highlight_style(Style::default().add_modifier(Modifier::BOLD))
.highlight_symbol("> ");
f.render_widget(tasks, chunks[0]);
.highlight_style(Style::default().fg(Color::Green))
.style(match app.focus_type {
FocusType::Tables(false) => Style::default().fg(Color::Magenta),
FocusType::Tables(true) => Style::default().fg(Color::Green),
_ => Style::default(),
});
f.render_stateful_widget(
tasks,
left_chunks[1],
&mut app.databases[app.selected_database.selected().unwrap_or(0)].selected_table,
);
let chunks = Layout::default()
let right_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(1),
Constraint::Length(3),
Constraint::Min(1),
]
.as_ref(),
)
.split(chunks[1]);
let (msg, style) = match app.input_mode {
InputMode::Normal => (
vec![
Span::raw("Press "),
Span::styled("q", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to exit, "),
Span::styled("e", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to start editing."),
],
Style::default().add_modifier(Modifier::RAPID_BLINK),
),
InputMode::Editing => (
vec![
Span::raw("Press "),
Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to stop editing, "),
Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to record the message"),
],
Style::default(),
),
};
let mut text = Text::from(Spans::from(msg));
text.patch_style(style);
let help_message = Paragraph::new(text);
f.render_widget(help_message, chunks[0]);
.constraints([Constraint::Length(3), Constraint::Min(1)].as_ref())
.split(main_chunks[1]);
let input = Paragraph::new(app.input.as_ref())
.style(match app.input_mode {
InputMode::Normal => Style::default(),
InputMode::Editing => Style::default().fg(Color::Yellow),
})
.block(Block::default().borders(Borders::ALL).title("Input"));
f.render_widget(input, chunks[1]);
.block(Block::default().borders(Borders::ALL).title("Query"));
f.render_widget(input, right_chunks[0]);
match app.input_mode {
InputMode::Normal =>
// Hide the cursor. `Frame` does this by default, so we don't need to do anything here
{}
InputMode::Editing => {
// Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering
f.set_cursor(
// Put cursor past the end of the input text
chunks[1].x + app.input.width() as u16 + 1,
// Move one line down, from the border to the input line
chunks[1].y + 1,
)
}
InputMode::Normal => (),
InputMode::Editing => f.set_cursor(
right_chunks[0].x + app.input.width() as u16 + 1,
right_chunks[0].y + 1,
),
}
let selected_style = Style::default().add_modifier(Modifier::REVERSED);
let normal_style = Style::default().bg(Color::Blue);
let header_cells = [
"Header1", "Header2", "Header3", "Header4", "Header5", "Header6",
]
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(Color::Red)));
let header = Row::new(header_cells)
.style(normal_style)
.height(1)
.bottom_margin(1);
let rows = app.messages.iter().map(|item| {
let height = item
let header_cells = app.record_table.headers[app.record_table.column_index..]
.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..]
.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()));
let cells = item[app.record_table.column_index..]
.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::<Vec<Constraint>>();
let t = Table::new(rows)
.header(header)
.block(Block::default().borders(Borders::ALL).title("Table"))
.highlight_style(selected_style)
.highlight_symbol(">> ")
.widths(&[
Constraint::Percentage(10),
Constraint::Percentage(10),
Constraint::Percentage(10),
Constraint::Percentage(10),
Constraint::Percentage(10),
Constraint::Percentage(10),
]);
f.render_stateful_widget(t, chunks[2], &mut table.state);
.block(Block::default().borders(Borders::ALL).title("Records"))
.highlight_style(Style::default().fg(Color::Green))
.style(match app.focus_type {
FocusType::Records(false) => Style::default().fg(Color::Magenta),
FocusType::Records(true) => Style::default().fg(Color::Green),
_ => Style::default(),
})
.widths(&widths);
f.render_stateful_widget(t, right_chunks[1], &mut app.record_table.state);
Ok(())
}

@ -0,0 +1,53 @@
use serde::Deserialize;
use std::fs::File;
use std::io::{BufReader, Read};
#[derive(Debug, Deserialize)]
pub struct UserConfig {
pub conn: Vec<Connection>,
}
#[derive(Debug, Deserialize)]
pub struct Connection {
pub name: Option<String>,
pub user: String,
pub host: String,
pub port: u64,
pub database: Option<String>,
}
impl UserConfig {
pub fn new(path: &str) -> anyhow::Result<Self> {
let file = File::open(path)?;
let mut buf_reader = BufReader::new(file);
let mut contents = String::new();
buf_reader.read_to_string(&mut contents)?;
let config: Result<UserConfig, toml::de::Error> = toml::from_str(&contents);
match config {
Ok(config) => Ok(config),
Err(e) => panic!("fail to parse config file: {}", e),
}
}
}
impl Connection {
pub fn database_url(&self) -> String {
match &self.database {
Some(database) => format!(
"mysql://{user}:@{host}:{port}/{database}",
user = self.user,
host = self.host,
port = self.port,
database = database
),
None => format!(
"mysql://{user}:@{host}:{port}",
user = self.user,
host = self.host,
port = self.port,
),
}
}
}

@ -0,0 +1,101 @@
use crate::app::{Database, Table};
use chrono::NaiveDate;
use futures::TryStreamExt;
use sqlx::mysql::MySqlPool;
use sqlx::{Column, Executor, Row, TypeInfo};
pub async fn get_databases(pool: &MySqlPool) -> anyhow::Result<Vec<Database>> {
let databases = sqlx::query("show databases")
.fetch_all(pool)
.await?
.iter()
.map(|table| table.get(0))
.collect::<Vec<String>>();
let mut list = vec![];
for db in databases {
list.push(Database::new(db, pool).await?)
}
Ok(list)
}
pub async fn get_tables(database: String, pool: &MySqlPool) -> anyhow::Result<Vec<Table>> {
let tables = sqlx::query(format!("show tables from `{}`", database).as_str())
.fetch_all(pool)
.await?
.iter()
.map(|table| Table { name: table.get(0) })
.collect::<Vec<Table>>();
Ok(tables)
}
pub async fn get_records(
database: &Database,
table: &Table,
pool: &MySqlPool,
) -> anyhow::Result<(Vec<String>, Vec<Vec<String>>)> {
pool.execute(format!("use `{}`", database.name).as_str())
.await?;
let table_name = format!("SELECT * FROM `{}`", table.name);
let mut rows = sqlx::query(table_name.as_str()).fetch(pool);
let headers = sqlx::query(format!("desc `{}`", table.name).as_str())
.fetch_all(pool)
.await?
.iter()
.map(|table| table.get(0))
.collect::<Vec<String>>();
let mut records = vec![];
while let Some(row) = rows.try_next().await? {
let mut row_vec = vec![];
for col in row.columns() {
let col_name = col.name();
match col.type_info().clone().name() {
"INT" | "DECIMAL" | "SMALLINT" => match row.try_get(col_name) {
Ok(value) => {
let value: i64 = value;
row_vec.push(value.to_string())
}
Err(_) => row_vec.push("".to_string()),
},
"INT UNSIGNED" => match row.try_get(col_name) {
Ok(value) => {
let value: u64 = value;
row_vec.push(value.to_string())
}
Err(_) => row_vec.push("".to_string()),
},
"VARCHAR" | "CHAR" => {
let value: String = row.try_get(col_name).unwrap_or("".to_string());
row_vec.push(value);
}
"DATE" => match row.try_get(col_name) {
Ok(value) => {
let value: NaiveDate = value;
row_vec.push(value.to_string())
}
Err(_) => row_vec.push("".to_string()),
},
"TIMESTAMP" => match row.try_get(col_name) {
Ok(value) => {
let value: chrono::DateTime<chrono::Utc> = value;
row_vec.push(value.to_string())
}
Err(_) => row_vec.push("".to_string()),
},
"BOOLEAN" => match row.try_get(col_name) {
Ok(value) => {
let value: bool = value;
row_vec.push(value.to_string())
}
Err(_) => row_vec.push("".to_string()),
},
"ENUM" => {
let value: String = row.try_get(col_name).unwrap_or("".to_string());
row_vec.push(value);
}
_ => (),
}
}
records.push(row_vec)
}
Ok((headers, records))
}
Loading…
Cancel
Save