Compare commits

..

No commits in common. 'main' and 'v0.1.0-alpha.1' have entirely different histories.

@ -1 +0,0 @@
github: TaKO8Ki

@ -1,15 +0,0 @@
Thank you for making gobang better!
Here's a checklist for things that will be checked during review or continuous integration.
- \[ ] Added passing unit tests
- \[ ] `cargo test` passes locally. It takes much time.
- \[ ] Run `cargo fmt`
Delete this line and everything above before opening your PR.
---
*Please write a short comment explaining your change (or "none" for internal only changes)*
changelog:

@ -14,9 +14,6 @@ on:
- 'LICENSE'
- '**.md'
env:
CARGO_INCREMENTAL: 0
jobs:
format:
name: Format
@ -26,6 +23,14 @@ jobs:
- name: Cargo fmt
run: cargo fmt --all -- --check
check:
name: Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Cargo check
run: cargo check
lint:
name: Lint
runs-on: ubuntu-latest
@ -53,73 +58,30 @@ jobs:
unit_tests:
name: Unit Tests
runs-on: ${{ matrix.os }}
env:
# For some builds, we use cross to test on 32-bit and big-endian
# systems.
CARGO: cargo
# When CARGO is set to CROSS, this is set to `--target matrix.target`.
TARGET_FLAGS: ""
# When CARGO is set to CROSS, TARGET_DIR includes matrix.target.
TARGET_DIR: ./target
# Emit backtraces on panics.
RUST_BACKTRACE: 1
RUST_MIN_SRV: "1.43.1"
strategy:
fail-fast: false
matrix:
build: [linux, linux-arm, macos, win-msvc, win32-msvc]
include:
- build: linux
os: ubuntu-18.04
rust: stable
target: x86_64-unknown-linux-musl
- build: linux-arm
os: ubuntu-18.04
rust: stable
target: arm-unknown-linux-gnueabihf
- build: macos
os: macos-latest
rust: stable
target: x86_64-apple-darwin
- build: win-msvc
os: windows-2019
rust: stable
target: x86_64-pc-windows-msvc
- build: win32-msvc
os: windows-2019
rust: stable
target: i686-pc-windows-msvc
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
needs: check
steps:
- uses: actions/checkout@v2
- name: Install Rust
uses: actions-rs/toolchain@v1
- name: Cache cargo registry
if: runner.os != 'macOS'
uses: actions/cache@v1
with:
toolchain: ${{ matrix.rust }}
profile: minimal
override: true
target: ${{ matrix.target }}
- name: Use Cross
shell: bash
run: |
cargo install cross
echo "CARGO=cross" >> $GITHUB_ENV
echo "TARGET_FLAGS=--target ${{ matrix.target }}" >> $GITHUB_ENV
echo "TARGET_DIR=./target/${{ matrix.target }}" >> $GITHUB_ENV
- name: Show command used for Cargo
run: |
echo "cargo command is: ${{ env.CARGO }}"
echo "target flag is: ${{ env.TARGET_FLAGS }}"
echo "target dir is: ${{ env.TARGET_DIR }}"
- name: Cache Dependencies
uses: Swatinem/rust-cache@v1
- name: Build
run: ${{ env.CARGO }} build --verbose ${{ env.TARGET_FLAGS }}
- name: Run tests
run: ${{ env.CARGO }} test --verbose --workspace ${{ env.TARGET_FLAGS }}
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v1
with:
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
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
- name: Run Tests
run: cargo test --workspace -- --skip=e2e --color always

@ -6,144 +6,32 @@ on:
- 'v*'
jobs:
release:
name: Release
runs-on: ${{ matrix.os }}
outputs:
gobang_version: ${{ env.GOBANG_VERSION }}
env:
# For some builds, we use cross to test on 32-bit and big-endian
# systems.
CARGO: cargo
# When CARGO is set to CROSS, this is set to `--target matrix.target`.
TARGET_FLAGS: ""
# When CARGO is set to CROSS, TARGET_DIR includes matrix.target.
TARGET_DIR: ./target
# Emit backtraces on panics.
RUST_BACKTRACE: 1
check:
name: Check
strategy:
fail-fast: false
matrix:
build: [linux, linux-arm, macos, win-msvc, win32-msvc]
include:
- build: linux
os: ubuntu-18.04
rust: stable
target: x86_64-unknown-linux-musl
- build: linux-arm
os: ubuntu-18.04
rust: stable
target: arm-unknown-linux-gnueabihf
- build: macos
os: macos-latest
rust: stable
target: x86_64-apple-darwin
- build: win-msvc
os: windows-2019
rust: stable
target: x86_64-pc-windows-msvc
- build: win32-msvc
os: windows-2019
rust: stable
target: i686-pc-windows-msvc
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Get the release version from the tag
shell: bash
if: env.GOBANG_VERSION == ''
run: |
# Apparently, this is the right way to get a tag name. Really?
#
# See: https://github.community/t5/GitHub-Actions/How-to-get-just-the-tag-name/m-p/32167/highlight/true#M1027
echo "GOBANG_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
echo "version is: ${{ env.GOBANG_VERSION }}"
- name: Checkout repository
uses: actions/checkout@v2
with:
fetch-depth: 1
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ matrix.rust }}
profile: minimal
override: true
target: ${{ matrix.target }}
- name: Use Cross
shell: bash
run: |
cargo install cross
echo "CARGO=cross" >> $GITHUB_ENV
echo "TARGET_FLAGS=--target ${{ matrix.target }}" >> $GITHUB_ENV
echo "TARGET_DIR=./target/${{ matrix.target }}" >> $GITHUB_ENV
- name: Show command used for Cargo
run: |
echo "cargo command is: ${{ env.CARGO }}"
echo "target flag is: ${{ env.TARGET_FLAGS }}"
echo "target dir is: ${{ env.TARGET_DIR }}"
- name: Build release binary
run: ${{ env.CARGO }} build --verbose --release ${{ env.TARGET_FLAGS }}
- name: Strip release binary (linux and macos)
if: matrix.build == 'linux' || matrix.build == 'macos'
run: strip "target/${{ matrix.target }}/release/gobang"
- name: Strip release binary (arm)
if: matrix.build == 'linux-arm'
run: |
docker run --rm -v \
"$PWD/target:/target:Z" \
rustembedded/cross:arm-unknown-linux-gnueabihf \
arm-linux-gnueabihf-strip \
/target/arm-unknown-linux-gnueabihf/release/gobang
- name: Build archive
shell: bash
run: |
staging="gobang-${{ env.GOBANG_VERSION }}-${{ matrix.target }}"
mkdir -p "$staging"/{complete,doc}
cp {README.md,LICENSE} "$staging/"
if [ "${{ matrix.os }}" = "windows-2019" ]; then
cp "target/${{ matrix.target }}/release/gobang.exe" "$staging/"
7z a "$staging.zip" "$staging"
echo "ASSET=$staging.zip" >> $GITHUB_ENV
else
# The man page is only generated on Unix systems. ¯\_(ツ)_/¯
cp "target/${{ matrix.target }}/release/gobang" "$staging/"
tar czf "$staging.tar.gz" "$staging"
echo "ASSET=$staging.tar.gz" >> $GITHUB_ENV
fi
- name: Publish
uses: softprops/action-gh-release@v1
with:
files: ${{ env.ASSET }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/checkout@v2
- name: Cargo check
uses: actions-rs/cargo@v1
with:
command: check
cargo-publish:
release:
name: Cargo publish
runs-on: ubuntu-latest
needs: release
needs: check
steps:
- uses: actions/checkout@v1
- name: Install cargo-workspaces
uses: actions-rs/install@v0.1
with:
crate: cargo-workspaces
- name: Cargo publish
- run: cargo login ${CRATES_IO_TOKEN}
env:
CRATES_IO_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
- name: Cargo puhlish database-tree
run: |
git config --global user.email "runner@gha.local"
git config --global user.name "Github Action"
cargo workspaces publish \
--yes --force '*' --exact \
--no-git-commit --allow-dirty --skip-published --token ${{ secrets.CRATES_IO_TOKEN }} \
custom ${{ needs.release.outputs.gobang_version }}
cd database-tree
cargo publish
- run: sleep 2
- run: cargo publish
if: ${{ always() }}

836
Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -1,14 +1,13 @@
[package]
name = "gobang"
version = "0.1.0-alpha.5"
version = "0.1.0-alpha.1"
authors = ["Takayuki Maeda <takoyaki0316@gmail.com>"]
edition = "2018"
license = "MIT"
homepage = "https://github.com/TaKO8Ki/gobang"
repository = "https://github.com/TaKO8Ki/gobang"
readme = "README.md"
description = "A cross-platform TUI database management tool written in Rust"
exclude = ["resources/"]
description = "A cross-platform terminal database tool written in Rust"
[workspace]
members=[
@ -16,32 +15,29 @@ members=[
]
[dependencies]
tui = { version = "0.15.0", features = ["crossterm"], default-features = false }
crossterm = "0.20"
tui = { version = "0.14.0", features = ["crossterm"], default-features = false }
crossterm = "0.19"
anyhow = "1.0.38"
unicode-width = "0.1"
sqlx = { version = "0.5.6", features = ["mysql", "postgres", "sqlite", "chrono", "runtime-tokio-rustls", "decimal", "json"], default-features = false }
sqlx = { version = "0.4.1", features = ["mysql", "postgres", "chrono", "runtime-tokio-rustls", "decimal", "json"] }
chrono = "0.4"
tokio = { version = "1.11.0", features = ["full"] }
tokio = { version = "0.2.22", features = ["full"] }
futures = "0.3.5"
serde_json = "1.0"
serde = "1.0"
toml = "0.4"
regex = "1"
strum = "0.21"
strum_macros = "0.21"
database-tree = { path = "./database-tree", version = "0.1.0-alpha.5" }
database-tree = { path = "./database-tree", version = "0.1.0-alpha.1" }
easy-cast = "0.4"
copypasta = { version = "0.7.0", default-features = false }
async-trait = "0.1.50"
itertools = "0.10.0"
rust_decimal = "1.15"
dirs-next = "2.0"
clap = "2.33.3"
structopt = "0.3.22"
syntect = { version = "4.5", default-features = false, features = ["metadata", "default-fancy"]}
unicode-segmentation = "1.7"
[target.'cfg(all(target_family="unix",not(target_os="macos")))'.dependencies]
which = "4.1"
[dev-dependencies]
pretty_assertions = "1.0.0"

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2021 Takayuki Maeda
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

@ -1,10 +1,10 @@
<div align="center">
![gobang](./resources/logo.png)
# gobang
gobang is currently in alpha
A cross-platform TUI database management tool written in Rust
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) [![crates](https://img.shields.io/crates/v/gobang.svg?logo=rust)](https://crates.io/crates/gobang)
@ -15,118 +15,40 @@ A cross-platform TUI database management tool written in Rust
## Features
- Cross-platform support (macOS, Windows, Linux)
- Multiple Database support (MySQL, PostgreSQL, SQLite)
- Multiple Database support (MySQL PostgreSQL, SQLite)
- Intuitive keyboard only control
## TODOs
- [ ] SQL editor
- [ ] Custom key bindings
- [ ] Custom theme settings
- [ ] Support the other databases
## What does "gobang" come from?
gobang means a Japanese game played on goban, a go board. The appearance of goban looks like table structure. And I live in Kyoto, Japan. In Kyoto city, streets are laid out on a grid (We call it “goban no me no youna (碁盤の目のような)”). They are why I named this project "gobang".
## Installation
### With Homebrew (Linux, macOS)
If youre using Homebrew or Linuxbrew, install the gobang formula:
```
brew install tako8ki/tap/gobang
```
### On Windows
If you're a Windows Scoop user, then you can install gobang from the [official bucket](https://github.com/ScoopInstaller/Main/blob/master/bucket/gobang.json):
```
scoop install gobang
```
### On NixOS
If you're a Nix user, you can install [gobang](https://github.com/NixOS/nixpkgs/blob/master/pkgs/development/tools/database/gobang/default.nix) from nixpkgs:
```
$ nix-env --install gobang
```
### On Archlinux
If you're an Archlinux user, you can install [gobang](https://aur.archlinux.org/packages/gobang-bin) from AUR:
```
paru -S gobang-bin
```
### On NetBSD
If you're a NetBSD user, then you can install gobang from [pkgsrc](https://pkgsrc.se/databases/gobang):
```
pkgin install gobang
```
### With Cargo (Linux, macOS, Windows)
### Cargo
If you already have a Rust environment set up, you can use the `cargo install` command:
```
cargo install --version 0.1.0-alpha.5 gobang
```
### From binaries (Linux, macOS, Windows)
- Download the [latest release binary](https://github.com/TaKO8Ki/gobang/releases) for your system
- Set the `PATH` environment variable
## Usage
```
$ gobang
```
```
$ gobang -h
USAGE:
gobang [OPTIONS]
FLAGS:
-h, --help Prints help information
-V, --version Prints version information
OPTIONS:
-c, --config-path <config-path> Set the config file
$ cargo install --version 0.1.0-alpha.1 gobang
```
If you want to add connections, you need to edit your config file. For more information, please see [Configuration](#Configuration).
## Keymap
| Key | Description |
| ---- | ---- |
| <kbd>h</kbd>, <kbd>j</kbd>, <kbd>k</kbd>, <kbd>l</kbd> | Scroll left/down/up/right |
| <kbd>Ctrl</kbd> + <kbd>u</kbd>, <kbd>Ctrl</kbd> + <kbd>d</kbd> | Scroll up/down multiple lines |
| <kbd>g</kbd> , <kbd>G</kbd> | Scroll to top/bottom |
| <kbd>H</kbd>, <kbd>J</kbd>, <kbd>K</kbd>, <kbd>L</kbd> | Extend selection by one cell left/down/up/right |
| <kbd>h</kbd> | Scroll left |
| <kbd>j</kbd> | Scroll down |
| <kbd>k</kbd> | Scroll up |
| <kbd>l</kbd> | Scroll right |
| <kbd>Ctrl</kbd> + <kbd>d</kbd> | Scroll down multiple lines |
| <kbd>Ctrl</kbd> + <kbd>u</kbd> | Scroll up multiple lines |
| <kbd>y</kbd> | Copy a cell value |
| <kbd></kbd>, <kbd></kbd> | Move focus to left/right |
| <kbd>c</kbd> | Move focus to connections |
| <kbd></kbd> | Move focus to right |
| <kbd></kbd> | Move focus to left |
| <kbd>/</kbd> | Filter |
| <kbd>?</kbd> | Help |
| <kbd>1</kbd>, <kbd>2</kbd>, <kbd>3</kbd>, <kbd>4</kbd>, <kbd>5</kbd> | Switch to records/columns/constraints/foreign keys/indexes tab |
| <kbd>Esc</kbd> | Hide pop up |
## Configuration
The location of the file depends on your OS:
- macOS: `$HOME/.config/gobang/config.toml`
- Linux: `$HOME/.config/gobang/config.toml`
- Windows: `%APPDATA%/gobang/config.toml`
- macOS: `$HOME/.config/gitui/config.toml`
- Linux: `$HOME/.config/gitui/config.toml`
- Windows: `%APPDATA%/gitui/config.toml`
The following is a sample config.toml file:
@ -142,23 +64,5 @@ type = "mysql"
user = "root"
host = "localhost"
port = 3306
password = "password"
database = "foo"
name = "mysql Foo DB"
[[conn]]
type = "postgres"
user = "root"
host = "localhost"
port = 5432
database = "bar"
name = "postgres Bar DB"
[[conn]]
type = "sqlite"
path = "/path/to/baz.db"
```
## Contribution
Contributions, issues and pull requests are welcome!

@ -1,6 +1,6 @@
[package]
name = "database-tree"
version = "0.1.0-alpha.5"
version = "0.1.0-alpha.1"
authors = ["Takayuki Maeda <takoyaki0316@gmail.com>"]
edition = "2018"
license = "MIT"
@ -11,5 +11,6 @@ description = "Database tree structure"
[dependencies]
thiserror = "1.0"
sqlx = { version = "0.4.1", features = ["mysql", "chrono", "runtime-tokio-rustls"] }
chrono = "0.4"
anyhow = "1.0.38"

@ -16,7 +16,6 @@ pub enum MoveSelection {
Right,
Top,
End,
Enter,
}
#[derive(Debug, Clone, Copy)]
@ -113,7 +112,6 @@ impl DatabaseTree {
MoveSelection::Right => self.selection_right(selection),
MoveSelection::Top => Self::selection_start(selection),
MoveSelection::End => self.selection_end(selection),
MoveSelection::Enter => self.expand(selection),
};
let changed_index = new_index.map(|i| i != selection).unwrap_or_default();
@ -317,22 +315,6 @@ impl DatabaseTree {
self.select_parent(current_index)
}
fn expand(&mut self, current_selection: usize) -> Option<usize> {
let item = &mut self.items.tree_items.get(current_selection)?;
if item.kind().is_database() && item.kind().is_database_collapsed() {
self.items.expand(current_selection, false);
return Some(current_selection);
}
if item.kind().is_schema() && item.kind().is_schema_collapsed() {
self.items.expand(current_selection, false);
return Some(current_selection);
}
None
}
fn selection_right(&mut self, current_selection: usize) -> Option<usize> {
let item = &mut self.items.tree_items.get(current_selection)?;
@ -433,62 +415,6 @@ mod test {
assert_eq!(tree.selection, Some(2));
}
#[test]
fn test_expand() {
let items = vec![Database::new(
"a".to_string(),
vec![Table::new("b".to_string()).into()],
)];
// a
// b
let mut tree = DatabaseTree::new(&items, &BTreeSet::new()).unwrap();
assert!(tree.move_selection(MoveSelection::Enter));
assert!(!tree.items.tree_items[0].kind().is_database_collapsed());
assert_eq!(tree.selection, Some(0));
assert!(tree.move_selection(MoveSelection::Down));
assert_eq!(tree.selection, Some(1));
assert!(!tree.move_selection(MoveSelection::Enter));
assert_eq!(tree.selection, Some(1));
let items = vec![Database::new(
"a".to_string(),
vec![Schema {
name: "b".to_string(),
tables: vec![Table::new("c".to_string()).into()],
}
.into()],
)];
// a
// b
// c
let mut tree = DatabaseTree::new(&items, &BTreeSet::new()).unwrap();
assert!(tree.move_selection(MoveSelection::Enter));
assert!(!tree.items.tree_items[0].kind().is_database_collapsed());
assert!(tree.items.tree_items[1].kind().is_schema_collapsed());
assert_eq!(tree.selection, Some(0));
assert!(tree.move_selection(MoveSelection::Down));
assert_eq!(tree.selection, Some(1));
assert!(tree.move_selection(MoveSelection::Enter));
assert!(!tree.items.tree_items[1].kind().is_database_collapsed());
assert_eq!(tree.selection, Some(1));
assert!(tree.move_selection(MoveSelection::Down));
assert_eq!(tree.selection, Some(2));
assert!(!tree.move_selection(MoveSelection::Enter));
assert_eq!(tree.selection, Some(2));
}
#[test]
fn test_selection_multiple_up_down() {
let items = vec![Database::new(

@ -50,11 +50,16 @@ pub struct Schema {
pub tables: Vec<Table>,
}
#[derive(Debug, Clone, PartialEq)]
#[derive(sqlx::FromRow, Debug, Clone, PartialEq)]
pub struct Table {
#[sqlx(rename = "Name")]
pub name: String,
#[sqlx(rename = "Create_time")]
pub create_time: Option<chrono::DateTime<chrono::Utc>>,
#[sqlx(rename = "Update_time")]
pub update_time: Option<chrono::DateTime<chrono::Utc>>,
#[sqlx(rename = "Engine")]
pub engine: Option<String>,
#[sqlx(default)]
pub schema: Option<String>,
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 8.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

@ -1,31 +1,10 @@
[[conn]]
type = "mysql"
name = "sample"
user = "root"
host = "localhost"
database = "world"
port = 3306
[[conn]]
type = "mysql"
user = "root"
host = "'127.0.0.1'"
database = "world"
port = 3306
[[conn]]
type = "mysql"
user = "root"
host = "localhost"
port = 3306
[[conn]]
type = "mysql"
user = "tako8ki"
host = "localhost"
port = 3306
database = "world"
password = "password"
[[conn]]
type = "postgres"
user = "postgres"
@ -37,24 +16,4 @@ type = "postgres"
user = "postgres"
host = "localhost"
port = 5432
password = "password"
database = "dvdrental"
[[conn]]
type = "postgres"
user = "hoge"
host = "localhost"
port = 5432
password = "hoge"
database = "dvdrental"
[[conn]]
type = "postgres"
user = "hoge"
host = "localhost"
port = 5432
database = "dvdrental"
[[conn]]
type = "sqlite"
path = "$HOME/Downloads/chinook.db"

@ -1,17 +1,16 @@
use crate::clipboard::copy_to_clipboard;
use crate::components::{
CommandInfo, Component as _, DrawableComponent as _, EventState, StatefulDrawableComponent,
};
use crate::database::{MySqlPool, Pool, PostgresPool, SqlitePool, RECORDS_LIMIT_PER_PAGE};
use crate::components::{CommandInfo, Component as _, DrawableComponent as _, EventState};
use crate::database::{MySqlPool, Pool, PostgresPool, RECORDS_LIMIT_PER_PAGE};
use crate::event::Key;
use crate::{
components::tab::Tab,
components::{
command, ConnectionsComponent, DatabasesComponent, ErrorComponent, HelpComponent,
PropertiesComponent, RecordTableComponent, SqlEditorComponent, TabComponent,
RecordTableComponent, TabComponent, TableComponent, TableStatusComponent,
},
config::Config,
};
use database_tree::Database;
use tui::{
backend::Backend,
layout::{Constraint, Direction, Layout, Rect},
@ -25,15 +24,17 @@ pub enum Focus {
}
pub struct App {
record_table: RecordTableComponent,
properties: PropertiesComponent,
sql_editor: SqlEditorComponent,
column_table: TableComponent,
constraint_table: TableComponent,
foreign_key_table: TableComponent,
index_table: TableComponent,
focus: Focus,
tab: TabComponent,
help: HelpComponent,
databases: DatabasesComponent,
connections: ConnectionsComponent,
table_status: TableStatusComponent,
pool: Option<Box<dyn Pool>>,
left_main_chunk_percentage: u16,
pub config: Config,
pub error: ErrorComponent,
}
@ -44,15 +45,17 @@ impl App {
config: config.clone(),
connections: ConnectionsComponent::new(config.key_config.clone(), config.conn),
record_table: RecordTableComponent::new(config.key_config.clone()),
properties: PropertiesComponent::new(config.key_config.clone()),
sql_editor: SqlEditorComponent::new(config.key_config.clone()),
column_table: TableComponent::new(config.key_config.clone()),
constraint_table: TableComponent::new(config.key_config.clone()),
foreign_key_table: TableComponent::new(config.key_config.clone()),
index_table: TableComponent::new(config.key_config.clone()),
tab: TabComponent::new(config.key_config.clone()),
help: HelpComponent::new(config.key_config.clone()),
databases: DatabasesComponent::new(config.key_config.clone()),
table_status: TableStatusComponent::default(),
error: ErrorComponent::new(config.key_config),
focus: Focus::ConnectionList,
pool: None,
left_main_chunk_percentage: 15,
}
}
@ -72,14 +75,17 @@ impl App {
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(self.left_main_chunk_percentage),
Constraint::Percentage((100_u16).saturating_sub(self.left_main_chunk_percentage)),
])
.constraints([Constraint::Percentage(15), Constraint::Percentage(85)])
.split(f.size());
let left_chunks = Layout::default()
.constraints([Constraint::Min(8), Constraint::Length(7)].as_ref())
.split(main_chunks[0]);
self.databases
.draw(f, main_chunks[0], matches!(self.focus, Focus::DabataseList))?;
.draw(f, left_chunks[0], matches!(self.focus, Focus::DabataseList))
.unwrap();
self.table_status
.draw(f, left_chunks[1], matches!(self.focus, Focus::DabataseList))?;
let right_chunks = Layout::default()
.direction(Direction::Vertical)
@ -93,13 +99,23 @@ impl App {
self.record_table
.draw(f, right_chunks[1], matches!(self.focus, Focus::Table))?
}
Tab::Sql => {
self.sql_editor
.draw(f, right_chunks[1], matches!(self.focus, Focus::Table))?;
Tab::Columns => {
self.column_table
.draw(f, right_chunks[1], matches!(self.focus, Focus::Table))?
}
Tab::Properties => {
self.properties
.draw(f, right_chunks[1], matches!(self.focus, Focus::Table))?;
Tab::Constraints => self.constraint_table.draw(
f,
right_chunks[1],
matches!(self.focus, Focus::Table),
)?,
Tab::ForeignKeys => self.foreign_key_table.draw(
f,
right_chunks[1],
matches!(self.focus, Focus::Table),
)?,
Tab::Indexes => {
self.index_table
.draw(f, right_chunks[1], matches!(self.focus, Focus::Table))?
}
}
self.error.draw(f, Rect::default(), false)?;
@ -113,24 +129,19 @@ impl App {
fn commands(&self) -> Vec<CommandInfo> {
let mut res = vec![
CommandInfo::new(command::exit_pop_up(&self.config.key_config)),
CommandInfo::new(command::filter(&self.config.key_config)),
CommandInfo::new(command::help(&self.config.key_config)),
CommandInfo::new(command::toggle_tabs(&self.config.key_config)),
CommandInfo::new(command::scroll(&self.config.key_config)),
CommandInfo::new(command::scroll_to_top_bottom(&self.config.key_config)),
CommandInfo::new(command::scroll_up_down_multiple_lines(
&self.config.key_config,
)),
CommandInfo::new(command::move_focus(&self.config.key_config)),
CommandInfo::new(command::extend_or_shorten_widget_width(
&self.config.key_config,
)),
CommandInfo::new(command::filter(&self.config.key_config)),
CommandInfo::new(command::help(&self.config.key_config)),
CommandInfo::new(command::toggle_tabs(&self.config.key_config)),
];
self.databases.commands(&mut res);
self.record_table.commands(&mut res);
self.properties.commands(&mut res);
res
}
@ -142,20 +153,25 @@ impl App {
}
self.pool = if conn.is_mysql() {
Some(Box::new(
MySqlPool::new(conn.database_url()?.as_str()).await?,
))
} else if conn.is_postgres() {
Some(Box::new(
PostgresPool::new(conn.database_url()?.as_str()).await?,
MySqlPool::new(conn.database_url().as_str()).await?,
))
} else {
Some(Box::new(
SqlitePool::new(conn.database_url()?.as_str()).await?,
PostgresPool::new(conn.database_url().as_str()).await?,
))
};
self.databases
.update(conn, self.pool.as_ref().unwrap())
.await?;
let databases = match &conn.database {
Some(database) => vec![Database::new(
database.clone(),
self.pool
.as_ref()
.unwrap()
.get_tables(database.clone())
.await?,
)],
None => self.pool.as_ref().unwrap().get_databases().await?,
};
self.databases.update(databases.as_slice()).unwrap();
self.focus = Focus::DabataseList;
self.record_table.reset();
self.tab.reset();
@ -163,6 +179,97 @@ impl App {
Ok(())
}
async fn update_table(&mut self) -> anyhow::Result<()> {
if let Some((database, table)) = self.databases.tree().selected_table() {
self.focus = Focus::Table;
self.record_table.reset();
let (headers, records) = self
.pool
.as_ref()
.unwrap()
.get_records(&database, &table, 0, None)
.await?;
self.record_table
.update(records, headers, database.clone(), table.clone());
self.column_table.reset();
let columns = self
.pool
.as_ref()
.unwrap()
.get_columns(&database, &table)
.await?;
if !columns.is_empty() {
self.column_table.update(
columns
.iter()
.map(|c| c.columns())
.collect::<Vec<Vec<String>>>(),
columns.get(0).unwrap().fields(),
database.clone(),
table.clone(),
);
}
self.constraint_table.reset();
let constraints = self
.pool
.as_ref()
.unwrap()
.get_constraints(&database, &table)
.await?;
if !constraints.is_empty() {
self.constraint_table.update(
constraints
.iter()
.map(|c| c.columns())
.collect::<Vec<Vec<String>>>(),
constraints.get(0).unwrap().fields(),
database.clone(),
table.clone(),
);
}
self.foreign_key_table.reset();
let foreign_keys = self
.pool
.as_ref()
.unwrap()
.get_foreign_keys(&database, &table)
.await?;
if !foreign_keys.is_empty() {
self.foreign_key_table.update(
foreign_keys
.iter()
.map(|c| c.columns())
.collect::<Vec<Vec<String>>>(),
constraints.get(0).unwrap().fields(),
database.clone(),
table.clone(),
);
}
self.index_table.reset();
let indexes = self
.pool
.as_ref()
.unwrap()
.get_indexes(&database, &table)
.await?;
if !indexes.is_empty() {
self.index_table.update(
indexes
.iter()
.map(|c| c.columns())
.collect::<Vec<Vec<String>>>(),
indexes.get(0).unwrap().fields(),
database.clone(),
table.clone(),
);
}
self.table_status
.update(self.record_table.len() as u64, table);
}
Ok(())
}
async fn update_record_table(&mut self) -> anyhow::Result<()> {
if let Some((database, table)) = self.databases.tree().selected_table() {
let (headers, records) = self
@ -173,7 +280,7 @@ impl App {
&database,
&table,
0,
if self.record_table.filter.input_str().is_empty() {
if self.record_table.filter.input.is_empty() {
None
} else {
Some(self.record_table.filter.input_str())
@ -199,7 +306,7 @@ impl App {
Ok(EventState::NotConsumed)
}
async fn components_event(&mut self, key: Key) -> anyhow::Result<EventState> {
pub async fn components_event(&mut self, key: Key) -> anyhow::Result<EventState> {
if self.error.event(key)?.is_consumed() {
return Ok(EventState::Consumed);
}
@ -225,21 +332,7 @@ impl App {
}
if key == self.config.key_config.enter && self.databases.tree_focused() {
if let Some((database, table)) = self.databases.tree().selected_table() {
self.record_table.reset();
let (headers, records) = self
.pool
.as_ref()
.unwrap()
.get_records(&database, &table, 0, None)
.await?;
self.record_table
.update(records, headers, database.clone(), table.clone());
self.properties
.update(database.clone(), table.clone(), self.pool.as_ref().unwrap())
.await?;
self.focus = Focus::Table;
}
self.update_table().await?;
return Ok(EventState::Consumed);
}
}
@ -279,7 +372,7 @@ impl App {
&database,
&table,
index as u16,
if self.record_table.filter.input_str().is_empty() {
if self.record_table.filter.input.is_empty() {
None
} else {
Some(self.record_table.filter.input_str())
@ -295,56 +388,57 @@ impl App {
}
};
}
Tab::Sql => {
if self.sql_editor.event(key)?.is_consumed()
|| self
.sql_editor
.async_event(key, self.pool.as_ref().unwrap())
.await?
.is_consumed()
{
Tab::Columns => {
if self.column_table.event(key)?.is_consumed() {
return Ok(EventState::Consumed);
};
if key == self.config.key_config.copy {
if let Some(text) = self.column_table.selected_cells() {
copy_to_clipboard(text.as_str())?
}
};
}
Tab::Properties => {
if self.properties.event(key)?.is_consumed() {
Tab::Constraints => {
if self.constraint_table.event(key)?.is_consumed() {
return Ok(EventState::Consumed);
};
}
};
}
}
if self.extend_or_shorten_widget_width(key)?.is_consumed() {
return Ok(EventState::Consumed);
};
if key == self.config.key_config.copy {
if let Some(text) = self.constraint_table.selected_cells() {
copy_to_clipboard(text.as_str())?
}
};
}
Tab::ForeignKeys => {
if self.foreign_key_table.event(key)?.is_consumed() {
return Ok(EventState::Consumed);
};
Ok(EventState::NotConsumed)
}
if key == self.config.key_config.copy {
if let Some(text) = self.foreign_key_table.selected_cells() {
copy_to_clipboard(text.as_str())?
}
};
}
Tab::Indexes => {
if self.index_table.event(key)?.is_consumed() {
return Ok(EventState::Consumed);
};
fn extend_or_shorten_widget_width(&mut self, key: Key) -> anyhow::Result<EventState> {
if key
== self
.config
.key_config
.extend_or_shorten_widget_width_to_left
{
self.left_main_chunk_percentage =
self.left_main_chunk_percentage.saturating_sub(5).max(15);
return Ok(EventState::Consumed);
} else if key
== self
.config
.key_config
.extend_or_shorten_widget_width_to_right
{
self.left_main_chunk_percentage = (self.left_main_chunk_percentage + 5).min(70);
return Ok(EventState::Consumed);
if key == self.config.key_config.copy {
if let Some(text) = self.index_table.selected_cells() {
copy_to_clipboard(text.as_str())?
}
};
}
};
}
}
Ok(EventState::NotConsumed)
}
fn move_focus(&mut self, key: Key) -> anyhow::Result<EventState> {
pub fn move_focus(&mut self, key: Key) -> anyhow::Result<EventState> {
if key == self.config.key_config.focus_connections {
self.focus = Focus::ConnectionList;
return Ok(EventState::Consumed);
@ -375,38 +469,3 @@ impl App {
Ok(EventState::NotConsumed)
}
}
#[cfg(test)]
mod test {
use super::{App, Config, EventState, Key};
#[test]
fn test_extend_or_shorten_widget_width() {
let mut app = App::new(Config::default());
assert_eq!(
app.extend_or_shorten_widget_width(Key::Char('>')).unwrap(),
EventState::Consumed
);
assert_eq!(app.left_main_chunk_percentage, 20);
app.left_main_chunk_percentage = 70;
assert_eq!(
app.extend_or_shorten_widget_width(Key::Char('>')).unwrap(),
EventState::Consumed
);
assert_eq!(app.left_main_chunk_percentage, 70);
assert_eq!(
app.extend_or_shorten_widget_width(Key::Char('<')).unwrap(),
EventState::Consumed
);
assert_eq!(app.left_main_chunk_percentage, 65);
app.left_main_chunk_percentage = 15;
assert_eq!(
app.extend_or_shorten_widget_width(Key::Char('<')).unwrap(),
EventState::Consumed
);
assert_eq!(app.left_main_chunk_percentage, 15);
}
}

@ -1,7 +1,7 @@
use crate::config::CliConfig;
use structopt::StructOpt;
/// A cross-platform TUI database management tool written in Rust
/// A cross-platform terminal database tool written in Rust
#[derive(StructOpt, Debug)]
#[structopt(name = "gobang")]
pub struct Cli {

@ -3,7 +3,6 @@ use crate::config::KeyConfig;
static CMD_GROUP_GENERAL: &str = "-- General --";
static CMD_GROUP_TABLE: &str = "-- Table --";
static CMD_GROUP_DATABASES: &str = "-- Databases --";
static CMD_GROUP_PROPERTIES: &str = "-- Properties --";
#[derive(Clone, PartialEq, PartialOrd, Ord, Eq)]
pub struct CommandText {
@ -96,16 +95,6 @@ pub fn extend_selection_by_one_cell(key: &KeyConfig) -> CommandText {
)
}
pub fn extend_or_shorten_widget_width(key: &KeyConfig) -> CommandText {
CommandText::new(
format!(
"Extend/shorten widget width to left/right [{},{}]",
key.extend_or_shorten_widget_width_to_left, key.extend_or_shorten_widget_width_to_right
),
CMD_GROUP_GENERAL,
)
}
pub fn tab_records(key: &KeyConfig) -> CommandText {
CommandText::new(format!("Records [{}]", key.tab_records), CMD_GROUP_TABLE)
}
@ -132,37 +121,16 @@ pub fn tab_indexes(key: &KeyConfig) -> CommandText {
CommandText::new(format!("Indexes [{}]", key.tab_indexes), CMD_GROUP_TABLE)
}
pub fn tab_sql_editor(key: &KeyConfig) -> CommandText {
CommandText::new(format!("SQL [{}]", key.tab_sql_editor), CMD_GROUP_TABLE)
}
pub fn tab_properties(key: &KeyConfig) -> CommandText {
CommandText::new(
format!("Properties [{}]", key.tab_properties),
CMD_GROUP_TABLE,
)
}
pub fn toggle_tabs(key_config: &KeyConfig) -> CommandText {
CommandText::new(
format!(
"Tab [{},{},{}]",
key_config.tab_records, key_config.tab_properties, key_config.tab_sql_editor
),
CMD_GROUP_GENERAL,
)
}
pub fn toggle_property_tabs(key_config: &KeyConfig) -> CommandText {
CommandText::new(
format!(
"Tab [{},{},{},{}]",
key_config.tab_records,
key_config.tab_columns,
key_config.tab_constraints,
key_config.tab_foreign_keys,
key_config.tab_indexes
key_config.tab_foreign_keys
),
CMD_GROUP_PROPERTIES,
CMD_GROUP_GENERAL,
)
}
@ -172,10 +140,3 @@ pub fn help(key_config: &KeyConfig) -> CommandText {
CMD_GROUP_GENERAL,
)
}
pub fn exit_pop_up(key_config: &KeyConfig) -> CommandText {
CommandText::new(
format!("Exit pop up [{}]", key_config.exit_popup),
CMD_GROUP_GENERAL,
)
}

@ -1,191 +0,0 @@
use super::{Component, EventState, MovableComponent};
use crate::components::command::CommandInfo;
use crate::config::KeyConfig;
use crate::event::Key;
use anyhow::Result;
use tui::{
backend::Backend,
layout::Rect,
style::{Color, Style},
widgets::{Block, Borders, Clear, List, ListItem, ListState},
Frame,
};
const RESERVED_WORDS_IN_WHERE_CLAUSE: &[&str] = &["IN", "AND", "OR", "NOT", "NULL", "IS"];
const ALL_RESERVED_WORDS: &[&str] = &[
"IN", "AND", "OR", "NOT", "NULL", "IS", "SELECT", "UPDATE", "DELETE", "FROM", "LIMIT", "WHERE",
];
pub struct CompletionComponent {
key_config: KeyConfig,
state: ListState,
word: String,
candidates: Vec<String>,
}
impl CompletionComponent {
pub fn new(key_config: KeyConfig, word: impl Into<String>, all: bool) -> Self {
Self {
key_config,
state: ListState::default(),
word: word.into(),
candidates: if all {
ALL_RESERVED_WORDS.iter().map(|w| w.to_string()).collect()
} else {
RESERVED_WORDS_IN_WHERE_CLAUSE
.iter()
.map(|w| w.to_string())
.collect()
},
}
}
pub fn update(&mut self, word: impl Into<String>) {
self.word = word.into();
self.state.select(None);
self.state.select(Some(0))
}
fn next(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i >= self.filterd_candidates().count() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.state.select(Some(i));
}
fn previous(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i == 0 {
self.filterd_candidates().count() - 1
} else {
i - 1
}
}
None => 0,
};
self.state.select(Some(i));
}
fn filterd_candidates(&self) -> impl Iterator<Item = &String> {
self.candidates.iter().filter(move |c| {
(c.starts_with(self.word.to_lowercase().as_str())
|| c.starts_with(self.word.to_uppercase().as_str()))
&& !self.word.is_empty()
})
}
pub fn selected_candidate(&self) -> Option<String> {
self.filterd_candidates()
.collect::<Vec<&String>>()
.get(self.state.selected()?)
.map(|c| c.to_string())
}
pub fn word(&self) -> String {
self.word.to_string()
}
}
impl MovableComponent for CompletionComponent {
fn draw<B: Backend>(
&mut self,
f: &mut Frame<B>,
area: Rect,
_focused: bool,
x: u16,
y: u16,
) -> Result<()> {
if !self.word.is_empty() {
let width = 30;
let candidates = self
.filterd_candidates()
.map(|c| ListItem::new(c.to_string()))
.collect::<Vec<ListItem>>();
if candidates.clone().is_empty() {
return Ok(());
}
let candidate_list = List::new(candidates.clone())
.block(Block::default().borders(Borders::ALL))
.highlight_style(Style::default().bg(Color::Blue))
.style(Style::default());
let area = Rect::new(
area.x + x,
area.y + y + 2,
width
.min(f.size().width)
.min(f.size().right().saturating_sub(area.x + x)),
(candidates.len().min(5) as u16 + 2)
.min(f.size().bottom().saturating_sub(area.y + y + 2)),
);
f.render_widget(Clear, area);
f.render_stateful_widget(candidate_list, area, &mut self.state);
}
Ok(())
}
}
impl Component for CompletionComponent {
fn commands(&self, _out: &mut Vec<CommandInfo>) {}
fn event(&mut self, key: Key) -> Result<EventState> {
if key == self.key_config.move_down {
self.next();
return Ok(EventState::Consumed);
} else if key == self.key_config.move_up {
self.previous();
return Ok(EventState::Consumed);
}
Ok(EventState::NotConsumed)
}
}
#[cfg(test)]
mod test {
use super::{CompletionComponent, KeyConfig};
#[test]
fn test_filterd_candidates_lowercase() {
assert_eq!(
CompletionComponent::new(KeyConfig::default(), "an", false)
.filterd_candidates()
.collect::<Vec<&String>>(),
vec![&"AND".to_string()]
);
}
#[test]
fn test_filterd_candidates_uppercase() {
assert_eq!(
CompletionComponent::new(KeyConfig::default(), "AN", false)
.filterd_candidates()
.collect::<Vec<&String>>(),
vec![&"AND".to_string()]
);
}
#[test]
fn test_filterd_candidates_multiple_candidates() {
assert_eq!(
CompletionComponent::new(KeyConfig::default(), "n", false)
.filterd_candidates()
.collect::<Vec<&String>>(),
vec![&"NOT".to_string(), &"NULL".to_string()]
);
assert_eq!(
CompletionComponent::new(KeyConfig::default(), "N", false)
.filterd_candidates()
.collect::<Vec<&String>>(),
vec![&"NOT".to_string(), &"NULL".to_string()]
);
}
}

@ -1,4 +1,4 @@
use super::{Component, EventState, StatefulDrawableComponent};
use super::{Component, DrawableComponent, EventState};
use crate::components::command::CommandInfo;
use crate::config::{Connection, KeyConfig};
use crate::event::Key;
@ -81,19 +81,19 @@ impl ConnectionsComponent {
}
}
impl StatefulDrawableComponent for ConnectionsComponent {
impl DrawableComponent for ConnectionsComponent {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, _area: Rect, _focused: bool) -> Result<()> {
let width = 80;
let height = 20;
let conns = &self.connections;
let mut connections: Vec<ListItem> = Vec::new();
for c in conns {
connections.push(
ListItem::new(vec![Spans::from(Span::raw(c.database_url_with_name()?))])
.style(Style::default()),
)
}
let connections = List::new(connections)
let connections: Vec<ListItem> = conns
.iter()
.map(|i| {
ListItem::new(vec![Spans::from(Span::raw(i.database_url()))])
.style(Style::default())
})
.collect();
let tasks = List::new(connections)
.block(Block::default().borders(Borders::ALL).title("Connections"))
.highlight_style(Style::default().bg(Color::Blue))
.style(Style::default());
@ -104,9 +104,8 @@ impl StatefulDrawableComponent for ConnectionsComponent {
width.min(f.size().width),
height.min(f.size().height),
);
f.render_widget(Clear, area);
f.render_stateful_widget(connections, area, &mut self.state);
f.render_stateful_widget(tasks, area, &mut self.state);
Ok(())
}
}

@ -1,132 +0,0 @@
use super::{compute_character_width, Component, DrawableComponent, EventState};
use crate::components::command::CommandInfo;
use crate::event::Key;
use anyhow::Result;
use database_tree::Table;
use tui::{
backend::Backend,
layout::Rect,
style::{Color, Style},
text::Spans,
widgets::{Block, Borders, Paragraph},
Frame,
};
use unicode_width::UnicodeWidthStr;
pub struct DatabaseFilterComponent {
pub table: Option<Table>,
input: Vec<char>,
input_idx: usize,
input_cursor_position: u16,
}
impl DatabaseFilterComponent {
pub fn new() -> Self {
Self {
table: None,
input: Vec::new(),
input_idx: 0,
input_cursor_position: 0,
}
}
pub fn input_str(&self) -> String {
self.input.iter().collect()
}
pub fn reset(&mut self) {
self.table = None;
self.input = Vec::new();
self.input_idx = 0;
self.input_cursor_position = 0;
}
}
impl DrawableComponent for DatabaseFilterComponent {
fn draw<B: Backend>(&self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
let query = Paragraph::new(Spans::from(format!(
"{:w$}",
if self.input.is_empty() && !focused {
"Filter tables".to_string()
} else {
self.input_str()
},
w = area.width as usize
)))
.style(if focused {
Style::default()
} else {
Style::default().fg(Color::DarkGray)
})
.block(Block::default().borders(Borders::BOTTOM));
f.render_widget(query, area);
if focused {
f.set_cursor(
(area.x + self.input_cursor_position).min(area.right().saturating_sub(1)),
area.y,
)
}
Ok(())
}
}
impl Component for DatabaseFilterComponent {
fn commands(&self, _out: &mut Vec<CommandInfo>) {}
fn event(&mut self, key: Key) -> Result<EventState> {
let input_str: String = self.input.iter().collect();
match key {
Key::Char(c) => {
self.input.insert(self.input_idx, c);
self.input_idx += 1;
self.input_cursor_position += compute_character_width(c);
return Ok(EventState::Consumed);
}
Key::Delete | Key::Backspace => {
if input_str.width() > 0 && !self.input.is_empty() && self.input_idx > 0 {
let last_c = self.input.remove(self.input_idx - 1);
self.input_idx -= 1;
self.input_cursor_position -= compute_character_width(last_c);
}
return Ok(EventState::Consumed);
}
Key::Left => {
if !self.input.is_empty() && self.input_idx > 0 {
self.input_idx -= 1;
self.input_cursor_position = self
.input_cursor_position
.saturating_sub(compute_character_width(self.input[self.input_idx]));
}
return Ok(EventState::Consumed);
}
Key::Ctrl('a') => {
if !self.input.is_empty() && self.input_idx > 0 {
self.input_idx = 0;
self.input_cursor_position = 0
}
return Ok(EventState::Consumed);
}
Key::Right => {
if self.input_idx < self.input.len() {
let next_c = self.input[self.input_idx];
self.input_idx += 1;
self.input_cursor_position += compute_character_width(next_c);
}
return Ok(EventState::Consumed);
}
Key::Ctrl('e') => {
if self.input_idx < self.input.len() {
self.input_idx = self.input.len();
self.input_cursor_position = self.input_str().width() as u16;
}
return Ok(EventState::Consumed);
}
_ => (),
}
Ok(EventState::NotConsumed)
}
}

@ -1,10 +1,9 @@
use super::{
utils::scroll_vertical::VerticalScroll, Component, DatabaseFilterComponent, DrawableComponent,
compute_character_width, utils::scroll_vertical::VerticalScroll, Component, DrawableComponent,
EventState,
};
use crate::components::command::{self, CommandInfo};
use crate::config::{Connection, KeyConfig};
use crate::database::Pool;
use crate::config::KeyConfig;
use crate::event::Key;
use crate::ui::common_nav;
use crate::ui::scrolllist::draw_list_block;
@ -17,9 +16,10 @@ use tui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Span, Spans},
widgets::{Block, Borders},
widgets::{Block, Borders, Paragraph},
Frame,
};
use unicode_width::UnicodeWidthStr;
// ▸
const FOLDER_ICON_COLLAPSED: &str = "\u{25b8}";
@ -35,9 +35,11 @@ pub enum Focus {
pub struct DatabasesComponent {
tree: DatabaseTree,
filter: DatabaseFilterComponent,
filterd_tree: Option<DatabaseTree>,
scroll: VerticalScroll,
input: Vec<char>,
input_idx: usize,
input_cursor_position: u16,
focus: Focus,
key_config: KeyConfig,
}
@ -46,25 +48,26 @@ impl DatabasesComponent {
pub fn new(key_config: KeyConfig) -> Self {
Self {
tree: DatabaseTree::default(),
filter: DatabaseFilterComponent::new(),
filterd_tree: None,
scroll: VerticalScroll::new(false, false),
scroll: VerticalScroll::new(),
input: Vec::new(),
input_idx: 0,
input_cursor_position: 0,
focus: Focus::Tree,
key_config,
}
}
pub async fn update(&mut self, connection: &Connection, pool: &Box<dyn Pool>) -> Result<()> {
let databases = match &connection.database {
Some(database) => vec![Database::new(
database.clone(),
pool.get_tables(database.clone()).await?,
)],
None => pool.get_databases().await?,
};
self.tree = DatabaseTree::new(databases.as_slice(), &BTreeSet::new())?;
fn input_str(&self) -> String {
self.input.iter().collect()
}
pub fn update(&mut self, list: &[Database]) -> Result<()> {
self.tree = DatabaseTree::new(list, &BTreeSet::new())?;
self.filterd_tree = None;
self.filter.reset();
self.input = Vec::new();
self.input_idx = 0;
self.input_cursor_position = 0;
Ok(())
}
@ -144,7 +147,7 @@ impl DatabasesComponent {
))
}
fn draw_tree<B: Backend>(&self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
fn draw_tree<B: Backend>(&self, f: &mut Frame<B>, area: Rect, focused: bool) {
f.render_widget(
Block::default()
.title("Databases")
@ -164,8 +167,24 @@ impl DatabasesComponent {
.constraints([Constraint::Length(2), Constraint::Min(1)].as_ref())
.split(area);
self.filter
.draw(f, chunks[0], matches!(self.focus, Focus::Filter))?;
let filter = Paragraph::new(Span::styled(
format!(
"{}{:w$}",
if self.input.is_empty() && matches!(self.focus, Focus::Tree) {
"Filter tables".to_string()
} else {
self.input_str()
},
w = area.width as usize
),
if let Focus::Filter = self.focus {
Style::default()
} else {
Style::default().fg(Color::DarkGray)
},
))
.block(Block::default().borders(Borders::BOTTOM));
f.render_widget(filter, chunks[0]);
let tree_height = chunks[1].height as usize;
let tree = if let Some(tree) = self.filterd_tree.as_ref() {
@ -190,29 +209,30 @@ impl DatabasesComponent {
item.clone(),
selected,
area.width,
if self.filter.input_str().is_empty() {
if self.input.is_empty() {
None
} else {
Some(self.filter.input_str())
Some(self.input_str())
},
)
});
draw_list_block(f, chunks[1], Block::default().borders(Borders::NONE), items);
self.scroll.draw(f, chunks[1]);
Ok(())
self.scroll.draw(f, area);
if let Focus::Filter = self.focus {
f.set_cursor(area.x + self.input_cursor_position + 1, area.y + 1)
}
}
}
impl DrawableComponent for DatabasesComponent {
fn draw<B: Backend>(&self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(100)].as_ref())
.split(area);
self.draw_tree(f, chunks[0], focused)?;
self.draw_tree(f, chunks[0], focused);
Ok(())
}
}
@ -223,29 +243,70 @@ impl Component for DatabasesComponent {
}
fn event(&mut self, key: Key) -> Result<EventState> {
let input_str: String = self.input.iter().collect();
if key == self.key_config.filter && self.focus == Focus::Tree {
self.focus = Focus::Filter;
return Ok(EventState::Consumed);
}
if matches!(self.focus, Focus::Filter) {
self.filterd_tree = if self.filter.input_str().is_empty() {
None
} else {
Some(self.tree.filter(self.filter.input_str()))
};
}
match key {
Key::Enter if matches!(self.focus, Focus::Filter) => {
self.focus = Focus::Tree;
Key::Char(c) if self.focus == Focus::Filter => {
self.input.insert(self.input_idx, c);
self.input_idx += 1;
self.input_cursor_position += compute_character_width(c);
self.filterd_tree = Some(self.tree.filter(self.input_str()));
return Ok(EventState::Consumed);
}
key if matches!(self.focus, Focus::Filter) => {
if self.filter.event(key)?.is_consumed() {
Key::Delete | Key::Backspace if matches!(self.focus, Focus::Filter) => {
if input_str.width() > 0 {
if !self.input.is_empty() && self.input_idx > 0 {
let last_c = self.input.remove(self.input_idx - 1);
self.input_idx -= 1;
self.input_cursor_position -= compute_character_width(last_c);
}
self.filterd_tree = if self.input.is_empty() {
None
} else {
Some(self.tree.filter(self.input_str()))
};
return Ok(EventState::Consumed);
}
}
Key::Left if matches!(self.focus, Focus::Filter) => {
if !self.input.is_empty() && self.input_idx > 0 {
self.input_idx -= 1;
self.input_cursor_position = self
.input_cursor_position
.saturating_sub(compute_character_width(self.input[self.input_idx]));
}
return Ok(EventState::Consumed);
}
Key::Ctrl('a') => {
if !self.input.is_empty() && self.input_idx > 0 {
self.input_idx = 0;
self.input_cursor_position = 0
}
return Ok(EventState::Consumed);
}
Key::Right if matches!(self.focus, Focus::Filter) => {
if self.input_idx < self.input.len() {
let next_c = self.input[self.input_idx];
self.input_idx += 1;
self.input_cursor_position += compute_character_width(next_c);
}
return Ok(EventState::Consumed);
}
Key::Ctrl('e') => {
if self.input_idx < self.input.len() {
self.input_idx = self.input.len();
self.input_cursor_position = self.input_str().width() as u16;
}
return Ok(EventState::Consumed);
}
Key::Enter if matches!(self.focus, Focus::Filter) => {
self.focus = Focus::Tree;
return Ok(EventState::Consumed);
}
key => {
if tree_nav(
if let Some(tree) = self.filterd_tree.as_mut() {

@ -1,66 +0,0 @@
use super::{Component, DrawableComponent, EventState};
use crate::components::command::CommandInfo;
use crate::config::KeyConfig;
use crate::event::Key;
use anyhow::Result;
use tui::{
backend::Backend,
layout::{Alignment, Rect},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
Frame,
};
pub struct DebugComponent {
msg: String,
visible: bool,
key_config: KeyConfig,
}
impl DebugComponent {
#[allow(dead_code)]
pub fn new(key_config: KeyConfig, msg: String) -> Self {
Self {
msg,
visible: false,
key_config,
}
}
}
impl DrawableComponent for DebugComponent {
fn draw<B: Backend>(&self, f: &mut Frame<B>, _area: Rect, _focused: bool) -> Result<()> {
if true {
let width = 65;
let height = 10;
let error = Paragraph::new(self.msg.to_string())
.block(Block::default().title("Debug").borders(Borders::ALL))
.alignment(Alignment::Left)
.wrap(Wrap { trim: true });
let area = Rect::new(
(f.size().width.saturating_sub(width)) / 2,
(f.size().height.saturating_sub(height)) / 2,
width.min(f.size().width),
height.min(f.size().height),
);
f.render_widget(Clear, area);
f.render_widget(error, area);
}
Ok(())
}
}
impl Component for DebugComponent {
fn commands(&self, _out: &mut Vec<CommandInfo>) {}
fn event(&mut self, key: Key) -> Result<EventState> {
if self.visible {
if key == self.key_config.exit_popup {
self.msg = String::new();
self.hide();
return Ok(EventState::Consumed);
}
return Ok(EventState::NotConsumed);
}
Ok(EventState::NotConsumed)
}
}

@ -35,7 +35,7 @@ impl ErrorComponent {
}
impl DrawableComponent for ErrorComponent {
fn draw<B: Backend>(&self, f: &mut Frame<B>, _area: Rect, _focused: bool) -> Result<()> {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, _area: Rect, _focused: bool) -> Result<()> {
if self.visible {
let width = 65;
let height = 10;

@ -2,7 +2,6 @@ use super::{Component, DrawableComponent, EventState};
use crate::components::command::CommandInfo;
use crate::config::KeyConfig;
use crate::event::Key;
use crate::version::Version;
use anyhow::Result;
use itertools::Itertools;
use std::convert::From;
@ -23,7 +22,7 @@ pub struct HelpComponent {
}
impl DrawableComponent for HelpComponent {
fn draw<B: Backend>(&self, f: &mut Frame<B>, _area: Rect, _focused: bool) -> Result<()> {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, _area: Rect, _focused: bool) -> Result<()> {
if self.visible {
const SIZE: (u16, u16) = (65, 24);
let scroll_threshold = SIZE.1 / 3;
@ -59,7 +58,7 @@ impl DrawableComponent for HelpComponent {
f.render_widget(
Paragraph::new(Spans::from(vec![Span::styled(
format!("gobang {}", Version::new()),
format!("gobang {}", "0.1.0"),
Style::default(),
)]))
.alignment(Alignment::Right),

@ -1,13 +1,9 @@
pub mod command;
pub mod completion;
pub mod connections;
pub mod database_filter;
pub mod databases;
pub mod error;
pub mod help;
pub mod properties;
pub mod record_table;
pub mod sql_editor;
pub mod tab;
pub mod table;
pub mod table_filter;
@ -15,36 +11,25 @@ pub mod table_status;
pub mod table_value;
pub mod utils;
#[cfg(debug_assertions)]
pub mod debug;
pub use command::{CommandInfo, CommandText};
pub use completion::CompletionComponent;
pub use connections::ConnectionsComponent;
pub use database_filter::DatabaseFilterComponent;
pub use databases::DatabasesComponent;
pub use error::ErrorComponent;
pub use help::HelpComponent;
pub use properties::PropertiesComponent;
pub use record_table::RecordTableComponent;
pub use sql_editor::SqlEditorComponent;
pub use tab::TabComponent;
pub use table::TableComponent;
pub use table_filter::TableFilterComponent;
pub use table_status::TableStatusComponent;
pub use table_value::TableValueComponent;
#[cfg(debug_assertions)]
pub use debug::DebugComponent;
use crate::database::Pool;
use anyhow::Result;
use async_trait::async_trait;
use std::convert::TryInto;
use tui::{backend::Backend, layout::Rect, Frame};
use unicode_width::UnicodeWidthChar;
#[derive(PartialEq, Debug)]
#[derive(PartialEq)]
pub enum EventState {
Consumed,
NotConsumed,
@ -67,24 +52,9 @@ impl From<bool> for EventState {
}
pub trait DrawableComponent {
fn draw<B: Backend>(&self, f: &mut Frame<B>, rect: Rect, focused: bool) -> Result<()>;
}
pub trait StatefulDrawableComponent {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, rect: Rect, focused: bool) -> Result<()>;
}
pub trait MovableComponent {
fn draw<B: Backend>(
&mut self,
f: &mut Frame<B>,
rect: Rect,
focused: bool,
x: u16,
y: u16,
) -> Result<()>;
}
/// base component trait
#[async_trait]
pub trait Component {
@ -92,14 +62,6 @@ pub trait Component {
fn event(&mut self, key: crate::event::Key) -> Result<EventState>;
async fn async_event(
&mut self,
_key: crate::event::Key,
_pool: &Box<dyn Pool>,
) -> Result<EventState> {
Ok(EventState::NotConsumed)
}
fn focused(&self) -> bool {
false
}

@ -1,200 +0,0 @@
use super::{Component, EventState, StatefulDrawableComponent};
use crate::clipboard::copy_to_clipboard;
use crate::components::command::{self, CommandInfo};
use crate::components::TableComponent;
use crate::config::KeyConfig;
use crate::database::Pool;
use crate::event::Key;
use anyhow::Result;
use async_trait::async_trait;
use database_tree::{Database, Table};
use tui::{
backend::Backend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
widgets::{Block, Borders, List, ListItem},
Frame,
};
#[derive(Debug, PartialEq)]
pub enum Focus {
Column,
Constraint,
ForeignKey,
Index,
}
impl std::fmt::Display for Focus {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
pub struct PropertiesComponent {
column_table: TableComponent,
constraint_table: TableComponent,
foreign_key_table: TableComponent,
index_table: TableComponent,
focus: Focus,
key_config: KeyConfig,
}
impl PropertiesComponent {
pub fn new(key_config: KeyConfig) -> Self {
Self {
column_table: TableComponent::new(key_config.clone()),
constraint_table: TableComponent::new(key_config.clone()),
foreign_key_table: TableComponent::new(key_config.clone()),
index_table: TableComponent::new(key_config.clone()),
focus: Focus::Column,
key_config,
}
}
fn focused_component(&mut self) -> &mut TableComponent {
match self.focus {
Focus::Column => &mut self.column_table,
Focus::Constraint => &mut self.constraint_table,
Focus::ForeignKey => &mut self.foreign_key_table,
Focus::Index => &mut self.index_table,
}
}
pub async fn update(
&mut self,
database: Database,
table: Table,
pool: &Box<dyn Pool>,
) -> Result<()> {
self.column_table.reset();
let columns = pool.get_columns(&database, &table).await?;
if !columns.is_empty() {
self.column_table.update(
columns
.iter()
.map(|c| c.columns())
.collect::<Vec<Vec<String>>>(),
columns.get(0).unwrap().fields(),
database.clone(),
table.clone(),
);
}
self.constraint_table.reset();
let constraints = pool.get_constraints(&database, &table).await?;
if !constraints.is_empty() {
self.constraint_table.update(
constraints
.iter()
.map(|c| c.columns())
.collect::<Vec<Vec<String>>>(),
constraints.get(0).unwrap().fields(),
database.clone(),
table.clone(),
);
}
self.foreign_key_table.reset();
let foreign_keys = pool.get_foreign_keys(&database, &table).await?;
if !foreign_keys.is_empty() {
self.foreign_key_table.update(
foreign_keys
.iter()
.map(|c| c.columns())
.collect::<Vec<Vec<String>>>(),
foreign_keys.get(0).unwrap().fields(),
database.clone(),
table.clone(),
);
}
self.index_table.reset();
let indexes = pool.get_indexes(&database, &table).await?;
if !indexes.is_empty() {
self.index_table.update(
indexes
.iter()
.map(|c| c.columns())
.collect::<Vec<Vec<String>>>(),
indexes.get(0).unwrap().fields(),
database.clone(),
table.clone(),
);
}
Ok(())
}
fn tab_names(&self) -> Vec<(Focus, String)> {
vec![
(Focus::Column, command::tab_columns(&self.key_config).name),
(
Focus::Constraint,
command::tab_constraints(&self.key_config).name,
),
(
Focus::ForeignKey,
command::tab_foreign_keys(&self.key_config).name,
),
(Focus::Index, command::tab_indexes(&self.key_config).name),
]
}
}
impl StatefulDrawableComponent for PropertiesComponent {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints(vec![Constraint::Length(20), Constraint::Min(1)])
.split(area);
let tab_names = self
.tab_names()
.iter()
.map(|(f, c)| {
ListItem::new(c.to_string()).style(if *f == self.focus {
Style::default().bg(Color::Blue)
} else {
Style::default()
})
})
.collect::<Vec<ListItem>>();
let tab_list = List::new(tab_names)
.block(Block::default().borders(Borders::ALL).style(if focused {
Style::default()
} else {
Style::default().fg(Color::DarkGray)
}))
.style(Style::default());
f.render_widget(tab_list, layout[0]);
self.focused_component().draw(f, layout[1], focused)?;
Ok(())
}
}
#[async_trait]
impl Component for PropertiesComponent {
fn commands(&self, out: &mut Vec<CommandInfo>) {
out.push(CommandInfo::new(command::toggle_property_tabs(
&self.key_config,
)));
}
fn event(&mut self, key: Key) -> Result<EventState> {
self.focused_component().event(key)?;
if key == self.key_config.copy {
if let Some(text) = self.focused_component().selected_cells() {
copy_to_clipboard(text.as_str())?
}
} else if key == self.key_config.tab_columns {
self.focus = Focus::Column;
} else if key == self.key_config.tab_constraints {
self.focus = Focus::Constraint;
} else if key == self.key_config.tab_foreign_keys {
self.focus = Focus::ForeignKey;
} else if key == self.key_config.tab_indexes {
self.focus = Focus::Index;
}
Ok(EventState::NotConsumed)
}
}

@ -1,4 +1,4 @@
use super::{Component, EventState, StatefulDrawableComponent};
use super::{Component, DrawableComponent, EventState};
use crate::components::command::CommandInfo;
use crate::components::{TableComponent, TableFilterComponent};
use crate::config::KeyConfig;
@ -26,7 +26,7 @@ pub struct RecordTableComponent {
impl RecordTableComponent {
pub fn new(key_config: KeyConfig) -> Self {
Self {
filter: TableFilterComponent::new(key_config.clone()),
filter: TableFilterComponent::default(),
table: TableComponent::new(key_config.clone()),
focus: Focus::Table,
key_config,
@ -49,23 +49,27 @@ impl RecordTableComponent {
self.filter.reset();
}
pub fn len(&self) -> usize {
self.table.rows.len()
}
pub fn filter_focused(&self) -> bool {
matches!(self.focus, Focus::Filter)
}
}
impl StatefulDrawableComponent for RecordTableComponent {
impl DrawableComponent for RecordTableComponent {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Length(3), Constraint::Length(5)])
.split(area);
self.table
.draw(f, layout[1], focused && matches!(self.focus, Focus::Table))?;
self.filter
.draw(f, layout[0], focused && matches!(self.focus, Focus::Filter))?;
self.table
.draw(f, layout[1], focused && matches!(self.focus, Focus::Table))?;
Ok(())
}
}

@ -1,285 +0,0 @@
use super::{
compute_character_width, CompletionComponent, Component, EventState, MovableComponent,
StatefulDrawableComponent, TableComponent,
};
use crate::components::command::CommandInfo;
use crate::config::KeyConfig;
use crate::database::{ExecuteResult, Pool};
use crate::event::Key;
use crate::ui::stateful_paragraph::{ParagraphState, StatefulParagraph};
use anyhow::Result;
use async_trait::async_trait;
use tui::{
backend::Backend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
widgets::{Block, Borders, Paragraph, Wrap},
Frame,
};
use unicode_width::UnicodeWidthStr;
struct QueryResult {
updated_rows: u64,
}
impl QueryResult {
fn result_str(&self) -> String {
format!("Query OK, {} row affected", self.updated_rows)
}
}
pub enum Focus {
Editor,
Table,
}
pub struct SqlEditorComponent {
input: Vec<char>,
input_cursor_position_x: u16,
input_idx: usize,
table: TableComponent,
query_result: Option<QueryResult>,
completion: CompletionComponent,
key_config: KeyConfig,
paragraph_state: ParagraphState,
focus: Focus,
}
impl SqlEditorComponent {
pub fn new(key_config: KeyConfig) -> Self {
Self {
input: Vec::new(),
input_idx: 0,
input_cursor_position_x: 0,
table: TableComponent::new(key_config.clone()),
completion: CompletionComponent::new(key_config.clone(), "", true),
focus: Focus::Editor,
paragraph_state: ParagraphState::default(),
query_result: None,
key_config,
}
}
fn update_completion(&mut self) {
let input = &self
.input
.iter()
.enumerate()
.filter(|(i, _)| i < &self.input_idx)
.map(|(_, i)| i)
.collect::<String>()
.split(' ')
.map(|i| i.to_string())
.collect::<Vec<String>>();
self.completion
.update(input.last().unwrap_or(&String::new()));
}
fn complete(&mut self) -> anyhow::Result<EventState> {
if let Some(candidate) = self.completion.selected_candidate() {
let mut input = Vec::new();
let first = self
.input
.iter()
.enumerate()
.filter(|(i, _)| i < &self.input_idx.saturating_sub(self.completion.word().len()))
.map(|(_, c)| c.to_string())
.collect::<Vec<String>>();
let last = self
.input
.iter()
.enumerate()
.filter(|(i, _)| i >= &self.input_idx)
.map(|(_, c)| c.to_string())
.collect::<Vec<String>>();
let is_last_word = last.first().map_or(false, |c| c == &" ".to_string());
let middle = if is_last_word {
candidate
.chars()
.map(|c| c.to_string())
.collect::<Vec<String>>()
} else {
let mut c = candidate
.chars()
.map(|c| c.to_string())
.collect::<Vec<String>>();
c.push(" ".to_string());
c
};
input.extend(first);
input.extend(middle.clone());
input.extend(last);
self.input = input.join("").chars().collect();
self.input_idx += &middle.len();
if is_last_word {
self.input_idx += 1;
}
self.input_idx -= self.completion.word().len();
self.input_cursor_position_x += middle
.join("")
.chars()
.map(compute_character_width)
.sum::<u16>();
if is_last_word {
self.input_cursor_position_x += " ".to_string().width() as u16
}
self.input_cursor_position_x -= self
.completion
.word()
.chars()
.map(compute_character_width)
.sum::<u16>();
self.update_completion();
return Ok(EventState::Consumed);
}
Ok(EventState::NotConsumed)
}
}
impl StatefulDrawableComponent for SqlEditorComponent {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(if matches!(self.focus, Focus::Table) {
vec![Constraint::Length(7), Constraint::Min(1)]
} else {
vec![Constraint::Percentage(50), Constraint::Min(1)]
})
.split(area);
let editor = StatefulParagraph::new(self.input.iter().collect::<String>())
.wrap(Wrap { trim: true })
.block(Block::default().borders(Borders::ALL));
f.render_stateful_widget(editor, layout[0], &mut self.paragraph_state);
if let Some(result) = self.query_result.as_ref() {
let result = Paragraph::new(result.result_str())
.block(Block::default().borders(Borders::ALL).style(
if focused && matches!(self.focus, Focus::Editor) {
Style::default()
} else {
Style::default().fg(Color::DarkGray)
},
))
.wrap(Wrap { trim: true });
f.render_widget(result, layout[1]);
} else {
self.table
.draw(f, layout[1], focused && matches!(self.focus, Focus::Table))?;
}
if focused && matches!(self.focus, Focus::Editor) {
f.set_cursor(
(layout[0].x + 1)
.saturating_add(
self.input_cursor_position_x % layout[0].width.saturating_sub(2),
)
.min(area.right().saturating_sub(2)),
(layout[0].y
+ 1
+ self.input_cursor_position_x / layout[0].width.saturating_sub(2))
.min(layout[0].bottom()),
)
}
if focused && matches!(self.focus, Focus::Editor) {
self.completion.draw(
f,
area,
false,
self.input_cursor_position_x % layout[0].width.saturating_sub(2) + 1,
self.input_cursor_position_x / layout[0].width.saturating_sub(2),
)?;
};
Ok(())
}
}
#[async_trait]
impl Component for SqlEditorComponent {
fn commands(&self, _out: &mut Vec<CommandInfo>) {}
fn event(&mut self, key: Key) -> Result<EventState> {
let input_str: String = self.input.iter().collect();
if key == self.key_config.focus_above && matches!(self.focus, Focus::Table) {
self.focus = Focus::Editor
} else if key == self.key_config.enter {
return self.complete();
}
match key {
Key::Char(c) if matches!(self.focus, Focus::Editor) => {
self.input.insert(self.input_idx, c);
self.input_idx += 1;
self.input_cursor_position_x += compute_character_width(c);
self.update_completion();
return Ok(EventState::Consumed);
}
Key::Esc if matches!(self.focus, Focus::Editor) => self.focus = Focus::Table,
Key::Delete | Key::Backspace if matches!(self.focus, Focus::Editor) => {
if input_str.width() > 0 && !self.input.is_empty() && self.input_idx > 0 {
let last_c = self.input.remove(self.input_idx - 1);
self.input_idx -= 1;
self.input_cursor_position_x -= compute_character_width(last_c);
self.completion.update("");
}
return Ok(EventState::Consumed);
}
Key::Left if matches!(self.focus, Focus::Editor) => {
if !self.input.is_empty() && self.input_idx > 0 {
self.input_idx -= 1;
self.input_cursor_position_x = self
.input_cursor_position_x
.saturating_sub(compute_character_width(self.input[self.input_idx]));
self.completion.update("");
}
return Ok(EventState::Consumed);
}
Key::Right if matches!(self.focus, Focus::Editor) => {
if self.input_idx < self.input.len() {
let next_c = self.input[self.input_idx];
self.input_idx += 1;
self.input_cursor_position_x += compute_character_width(next_c);
self.completion.update("");
}
return Ok(EventState::Consumed);
}
key if matches!(self.focus, Focus::Table) => return self.table.event(key),
_ => (),
}
return Ok(EventState::NotConsumed);
}
async fn async_event(&mut self, key: Key, pool: &Box<dyn Pool>) -> Result<EventState> {
if key == self.key_config.enter && matches!(self.focus, Focus::Editor) {
let query = self.input.iter().collect();
let result = pool.execute(&query).await?;
match result {
ExecuteResult::Read {
headers,
rows,
database,
table,
} => {
self.table.update(rows, headers, database, table);
self.focus = Focus::Table;
self.query_result = None;
}
ExecuteResult::Write { updated_rows } => {
self.query_result = Some(QueryResult { updated_rows })
}
}
return Ok(EventState::Consumed);
}
Ok(EventState::NotConsumed)
}
}

@ -16,8 +16,10 @@ use tui::{
#[derive(Debug, Clone, Copy, EnumIter)]
pub enum Tab {
Records,
Properties,
Sql,
Columns,
Constraints,
ForeignKeys,
Indexes,
}
impl std::fmt::Display for Tab {
@ -46,14 +48,16 @@ impl TabComponent {
fn names(&self) -> Vec<String> {
vec![
command::tab_records(&self.key_config).name,
command::tab_properties(&self.key_config).name,
command::tab_sql_editor(&self.key_config).name,
command::tab_columns(&self.key_config).name,
command::tab_constraints(&self.key_config).name,
command::tab_foreign_keys(&self.key_config).name,
command::tab_indexes(&self.key_config).name,
]
}
}
impl DrawableComponent for TabComponent {
fn draw<B: Backend>(&self, f: &mut Frame<B>, area: Rect, _focused: bool) -> Result<()> {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, _focused: bool) -> Result<()> {
let titles = self.names().iter().cloned().map(Spans::from).collect();
let tabs = Tabs::new(titles)
.block(Block::default().borders(Borders::ALL))
@ -76,11 +80,17 @@ impl Component for TabComponent {
if key == self.key_config.tab_records {
self.selected_tab = Tab::Records;
return Ok(EventState::Consumed);
} else if key == self.key_config.tab_sql_editor {
self.selected_tab = Tab::Sql;
} else if key == self.key_config.tab_columns {
self.selected_tab = Tab::Columns;
return Ok(EventState::Consumed);
} else if key == self.key_config.tab_properties {
self.selected_tab = Tab::Properties;
} else if key == self.key_config.tab_constraints {
self.selected_tab = Tab::Constraints;
return Ok(EventState::Consumed);
} else if key == self.key_config.tab_foreign_keys {
self.selected_tab = Tab::ForeignKeys;
return Ok(EventState::Consumed);
} else if key == self.key_config.tab_indexes {
self.selected_tab = Tab::Indexes;
return Ok(EventState::Consumed);
}
Ok(EventState::NotConsumed)

@ -1,6 +1,6 @@
use super::{
utils::scroll_vertical::VerticalScroll, Component, DrawableComponent, EventState,
StatefulDrawableComponent, TableStatusComponent, TableValueComponent,
TableValueComponent,
};
use crate::components::command::{self, CommandInfo};
use crate::config::KeyConfig;
@ -40,7 +40,7 @@ impl TableComponent {
selected_column: 0,
selection_area_corner: None,
column_page_start: std::cell::Cell::new(0),
scroll: VerticalScroll::new(false, false),
scroll: VerticalScroll::new(),
eod: false,
key_config,
}
@ -59,8 +59,8 @@ impl TableComponent {
database: Database,
table: DTable,
) {
self.selected_row.select(None);
if !rows.is_empty() {
self.selected_row.select(None);
self.selected_row.select(Some(0))
}
self.headers = headers;
@ -68,7 +68,7 @@ impl TableComponent {
self.selected_column = 0;
self.selection_area_corner = None;
self.column_page_start = std::cell::Cell::new(0);
self.scroll = VerticalScroll::new(false, false);
self.scroll = VerticalScroll::new();
self.eod = false;
self.table = Some((database, table));
}
@ -80,7 +80,7 @@ impl TableComponent {
self.selected_column = 0;
self.selection_area_corner = None;
self.column_page_start = std::cell::Cell::new(0);
self.scroll = VerticalScroll::new(false, false);
self.scroll = VerticalScroll::new();
self.eod = false;
self.table = None;
}
@ -97,7 +97,7 @@ impl TableComponent {
let i = match self.selected_row.selected() {
Some(i) => {
if i + lines >= self.rows.len() {
Some(self.rows.len().saturating_sub(1))
Some(self.rows.len() - 1)
} else {
Some(i + lines)
}
@ -114,7 +114,7 @@ impl TableComponent {
if i <= lines {
Some(0)
} else {
Some(i.saturating_sub(lines))
Some(i - lines)
}
}
None => None,
@ -136,18 +136,17 @@ impl TableComponent {
return;
}
self.reset_selection();
self.selected_row
.select(Some(self.rows.len().saturating_sub(1)));
self.selected_row.select(Some(self.rows.len() - 1));
}
fn next_column(&mut self) {
if self.rows.is_empty() {
return;
}
self.reset_selection();
if self.selected_column >= self.headers.len().saturating_sub(1) {
return;
}
self.reset_selection();
self.selected_column += 1;
}
@ -155,10 +154,10 @@ impl TableComponent {
if self.rows.is_empty() {
return;
}
self.reset_selection();
if self.selected_column == 0 {
return;
}
self.reset_selection();
self.selected_column -= 1;
}
@ -314,7 +313,7 @@ impl TableComponent {
)
.clamp(&3, &20)
});
if widths.iter().map(|(_, width)| width).sum::<usize>() + length + widths.len() + 1
if widths.iter().map(|(_, width)| width).sum::<usize>() + length + widths.len()
>= area_width.saturating_sub(number_column_width) as usize
{
column_index += 1;
@ -332,7 +331,7 @@ impl TableComponent {
let selected_column_index = widths.len().saturating_sub(1);
let mut column_index = far_right_column_index + 1;
while widths.iter().map(|(_, width)| width).sum::<usize>() + widths.len()
< area_width.saturating_sub(number_column_width) as usize
<= area_width.saturating_sub(number_column_width) as usize
{
let length = self
.rows
@ -400,34 +399,13 @@ impl TableComponent {
}
}
impl StatefulDrawableComponent for TableComponent {
impl DrawableComponent for TableComponent {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
let chunks = Layout::default()
.vertical_margin(1)
.horizontal_margin(1)
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(2),
Constraint::Min(1),
Constraint::Length(2),
]
.as_ref(),
)
.constraints(vec![Constraint::Length(3), Constraint::Length(5)])
.split(area);
f.render_widget(
Block::default()
.title(self.title())
.borders(Borders::ALL)
.style(if focused {
Style::default()
} else {
Style::default().fg(Color::DarkGray)
}),
area,
);
self.selected_row.selected().map_or_else(
|| {
self.scroll.reset();
@ -436,14 +414,17 @@ impl StatefulDrawableComponent for TableComponent {
self.scroll.update(
selection,
self.rows.len(),
chunks[1].height.saturating_sub(2) as usize,
layout[1].height.saturating_sub(2) as usize,
);
},
);
let block = Block::default().borders(Borders::NONE);
TableValueComponent::new(self.selected_cells().unwrap_or_default())
.draw(f, layout[0], focused)?;
let block = Block::default().borders(Borders::ALL).title(self.title());
let (selected_column_index, headers, rows, constraints) =
self.calculate_cell_widths(block.inner(chunks[0]).width);
self.calculate_cell_widths(block.inner(layout[1]).width);
let header_cells = headers.iter().enumerate().map(|(column_index, h)| {
Cell::from(h.to_string()).style(if selected_column_index == column_index {
Style::default().add_modifier(Modifier::BOLD)
@ -485,7 +466,7 @@ impl StatefulDrawableComponent for TableComponent {
let mut state = self.selected_row.clone();
f.render_stateful_widget(
table,
chunks[1],
layout[1],
if let Some((_, y)) = self.selection_area_corner {
state.select(Some(y));
&mut state
@ -494,25 +475,7 @@ impl StatefulDrawableComponent for TableComponent {
},
);
TableValueComponent::new(self.selected_cells().unwrap_or_default())
.draw(f, chunks[0], focused)?;
TableStatusComponent::new(
if self.rows.is_empty() {
None
} else {
Some(self.rows.len())
},
if self.headers.is_empty() {
None
} else {
Some(self.headers.len())
},
self.table.as_ref().map(|t| t.1.clone()),
)
.draw(f, chunks[2], focused)?;
self.scroll.draw(f, chunks[1]);
self.scroll.draw(f, layout[1]);
Ok(())
}
}

@ -1,9 +1,5 @@
use super::{
compute_character_width, CompletionComponent, Component, EventState, MovableComponent,
StatefulDrawableComponent,
};
use super::{compute_character_width, Component, DrawableComponent, EventState};
use crate::components::command::CommandInfo;
use crate::config::KeyConfig;
use crate::event::Key;
use anyhow::Result;
use database_tree::Table;
@ -18,26 +14,24 @@ use tui::{
use unicode_width::UnicodeWidthStr;
pub struct TableFilterComponent {
key_config: KeyConfig,
pub table: Option<Table>,
input: Vec<char>,
pub input: Vec<char>,
input_idx: usize,
input_cursor_position: u16,
completion: CompletionComponent,
}
impl TableFilterComponent {
pub fn new(key_config: KeyConfig) -> Self {
impl Default for TableFilterComponent {
fn default() -> Self {
Self {
key_config: key_config.clone(),
table: None,
input: Vec::new(),
input_idx: 0,
input_cursor_position: 0,
completion: CompletionComponent::new(key_config, "", false),
}
}
}
impl TableFilterComponent {
pub fn input_str(&self) -> String {
self.input.iter().collect()
}
@ -48,88 +42,9 @@ impl TableFilterComponent {
self.input_idx = 0;
self.input_cursor_position = 0;
}
fn update_completion(&mut self) {
let input = &self
.input
.iter()
.enumerate()
.filter(|(i, _)| i < &self.input_idx)
.map(|(_, i)| i)
.collect::<String>()
.split(' ')
.map(|i| i.to_string())
.collect::<Vec<String>>();
self.completion
.update(input.last().unwrap_or(&String::new()));
}
fn complete(&mut self) -> anyhow::Result<EventState> {
if let Some(candidate) = self.completion.selected_candidate() {
let mut input = Vec::new();
let first = self
.input
.iter()
.enumerate()
.filter(|(i, _)| i < &self.input_idx.saturating_sub(self.completion.word().len()))
.map(|(_, c)| c.to_string())
.collect::<Vec<String>>();
let last = self
.input
.iter()
.enumerate()
.filter(|(i, _)| i >= &self.input_idx)
.map(|(_, c)| c.to_string())
.collect::<Vec<String>>();
let is_last_word = last.first().map_or(false, |c| c == &" ".to_string());
let middle = if is_last_word {
candidate
.chars()
.map(|c| c.to_string())
.collect::<Vec<String>>()
} else {
let mut c = candidate
.chars()
.map(|c| c.to_string())
.collect::<Vec<String>>();
c.push(" ".to_string());
c
};
input.extend(first);
input.extend(middle.clone());
input.extend(last);
self.input = input.join("").chars().collect();
self.input_idx += &middle.len();
if is_last_word {
self.input_idx += 1;
}
self.input_idx -= self.completion.word().len();
self.input_cursor_position += middle
.join("")
.chars()
.map(compute_character_width)
.sum::<u16>();
if is_last_word {
self.input_cursor_position += " ".to_string().width() as u16
}
self.input_cursor_position -= self
.completion
.word()
.chars()
.map(compute_character_width)
.sum::<u16>();
self.update_completion();
return Ok(EventState::Consumed);
}
Ok(EventState::NotConsumed)
}
}
impl StatefulDrawableComponent for TableFilterComponent {
impl DrawableComponent for TableFilterComponent {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
let query = Paragraph::new(Spans::from(vec![
Span::styled(
@ -143,7 +58,7 @@ impl StatefulDrawableComponent for TableFilterComponent {
if focused || !self.input.is_empty() {
self.input.iter().collect::<String>()
} else {
"Enter a SQL expression in WHERE clause to filter records".to_string()
"Enter a SQL expression in WHERE clause".to_string()
}
)),
]))
@ -154,24 +69,6 @@ impl StatefulDrawableComponent for TableFilterComponent {
})
.block(Block::default().borders(Borders::ALL));
f.render_widget(query, area);
if focused {
self.completion.draw(
f,
area,
false,
(self
.table
.as_ref()
.map_or(String::new(), |table| {
format!("{} ", table.name.to_string())
})
.width() as u16)
.saturating_add(self.input_cursor_position),
0,
)?;
};
if focused {
f.set_cursor(
(area.x
@ -181,8 +78,7 @@ impl StatefulDrawableComponent for TableFilterComponent {
.map_or(String::new(), |table| table.name.to_string())
.width()
+ 1) as u16)
.saturating_add(self.input_cursor_position)
.min(area.right().saturating_sub(2)),
.saturating_add(self.input_cursor_position),
area.y + 1,
)
}
@ -195,31 +91,21 @@ impl Component for TableFilterComponent {
fn event(&mut self, key: Key) -> Result<EventState> {
let input_str: String = self.input.iter().collect();
// apply comletion candidates
if key == self.key_config.enter {
return self.complete();
}
self.completion.selected_candidate();
match key {
Key::Char(c) => {
self.input.insert(self.input_idx, c);
self.input_idx += 1;
self.input_cursor_position += compute_character_width(c);
self.update_completion();
Ok(EventState::Consumed)
return Ok(EventState::Consumed);
}
Key::Delete | Key::Backspace => {
if input_str.width() > 0 && !self.input.is_empty() && self.input_idx > 0 {
let last_c = self.input.remove(self.input_idx - 1);
self.input_idx -= 1;
self.input_cursor_position -= compute_character_width(last_c);
self.completion.update("");
}
Ok(EventState::Consumed)
return Ok(EventState::Consumed);
}
Key::Left => {
if !self.input.is_empty() && self.input_idx > 0 {
@ -227,75 +113,33 @@ impl Component for TableFilterComponent {
self.input_cursor_position = self
.input_cursor_position
.saturating_sub(compute_character_width(self.input[self.input_idx]));
self.completion.update("");
}
Ok(EventState::Consumed)
return Ok(EventState::Consumed);
}
Key::Ctrl('a') => {
if !self.input.is_empty() && self.input_idx > 0 {
self.input_idx = 0;
self.input_cursor_position = 0
}
Ok(EventState::Consumed)
return Ok(EventState::Consumed);
}
Key::Right => {
if self.input_idx < self.input.len() {
let next_c = self.input[self.input_idx];
self.input_idx += 1;
self.input_cursor_position += compute_character_width(next_c);
self.completion.update("");
}
Ok(EventState::Consumed)
return Ok(EventState::Consumed);
}
Key::Ctrl('e') => {
if self.input_idx < self.input.len() {
self.input_idx = self.input.len();
self.input_cursor_position = self.input_str().width() as u16;
}
Ok(EventState::Consumed)
return Ok(EventState::Consumed);
}
key => self.completion.event(key),
_ => (),
}
}
}
#[cfg(test)]
mod test {
use super::{KeyConfig, TableFilterComponent};
#[test]
fn test_complete() {
let mut filter = TableFilterComponent::new(KeyConfig::default());
filter.input_idx = 2;
filter.input = vec!['a', 'n', ' ', 'c', 'd', 'e', 'f', 'g'];
filter.completion.update("an");
assert!(filter.complete().is_ok());
assert_eq!(
filter.input,
vec!['A', 'N', 'D', ' ', 'c', 'd', 'e', 'f', 'g']
);
}
#[test]
fn test_complete_end() {
let mut filter = TableFilterComponent::new(KeyConfig::default());
filter.input_idx = 9;
filter.input = vec!['a', 'b', ' ', 'c', 'd', 'e', 'f', ' ', 'i'];
filter.completion.update('i');
assert!(filter.complete().is_ok());
assert_eq!(
filter.input,
vec!['a', 'b', ' ', 'c', 'd', 'e', 'f', ' ', 'I', 'N', ' ']
);
}
#[test]
fn test_complete_no_candidates() {
let mut filter = TableFilterComponent::new(KeyConfig::default());
filter.input_idx = 2;
filter.input = vec!['a', 'n', ' ', 'c', 'd', 'e', 'f', 'g'];
filter.completion.update("foo");
assert!(filter.complete().is_ok());
assert_eq!(filter.input, vec!['a', 'n', ' ', 'c', 'd', 'e', 'f', 'g']);
Ok(EventState::NotConsumed)
}
}

@ -8,64 +8,79 @@ use tui::{
layout::Rect,
style::{Color, Style},
text::{Span, Spans},
widgets::{Block, Borders, Paragraph},
widgets::{Block, Borders, List, ListItem},
Frame,
};
pub struct TableStatusComponent {
column_count: Option<usize>,
row_count: Option<usize>,
rows_count: u64,
table: Option<Table>,
}
impl Default for TableStatusComponent {
fn default() -> Self {
Self {
row_count: None,
column_count: None,
rows_count: 0,
table: None,
}
}
}
impl TableStatusComponent {
pub fn new(
row_count: Option<usize>,
column_count: Option<usize>,
table: Option<Table>,
) -> Self {
Self {
row_count,
column_count,
table,
pub fn update(&mut self, count: u64, table: Table) {
self.rows_count = count;
self.table = Some(table);
}
fn status_str(&self) -> Vec<String> {
if let Some(table) = self.table.as_ref() {
return vec![
format!(
"created: {}",
table
.create_time
.map(|time| time.to_string())
.unwrap_or_default()
),
format!(
"updated: {}",
table
.update_time
.map(|time| time.to_string())
.unwrap_or_default()
),
format!(
"engine: {}",
table
.engine
.as_ref()
.map(|engine| engine.to_string())
.unwrap_or_default()
),
format!("rows: {}", self.rows_count),
];
}
Vec::new()
}
}
impl DrawableComponent for TableStatusComponent {
fn draw<B: Backend>(&self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
let status = Paragraph::new(Spans::from(vec![
Span::from(format!(
"rows: {}, ",
self.row_count.map_or("-".to_string(), |c| c.to_string())
)),
Span::from(format!(
"columns: {}, ",
self.column_count.map_or("-".to_string(), |c| c.to_string())
)),
Span::from(format!(
"engine: {}",
self.table.as_ref().map_or("-".to_string(), |c| {
c.engine.as_ref().map_or("-".to_string(), |e| e.to_string())
})
)),
]))
.block(Block::default().borders(Borders::TOP).style(if focused {
Style::default()
} else {
Style::default().fg(Color::DarkGray)
}));
f.render_widget(status, area);
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
let table_status: Vec<ListItem> = self
.status_str()
.iter()
.map(|i| {
ListItem::new(vec![Spans::from(Span::raw(i.to_string()))]).style(Style::default())
})
.collect();
let tasks = List::new(table_status).block(Block::default().borders(Borders::ALL).style(
if focused {
Style::default()
} else {
Style::default().fg(Color::DarkGray)
},
));
f.render_widget(tasks, area);
Ok(())
}
}

@ -4,9 +4,10 @@ use crate::event::Key;
use anyhow::Result;
use tui::{
backend::Backend,
layout::Rect,
style::{Color, Style},
widgets::{Block, Borders, Paragraph},
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::Span,
widgets::{Block, Borders, Paragraph, Wrap},
Frame,
};
@ -21,14 +22,24 @@ impl TableValueComponent {
}
impl DrawableComponent for TableValueComponent {
fn draw<B: Backend>(&self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
let paragraph = Paragraph::new(self.value.clone())
.block(Block::default().borders(Borders::BOTTOM))
.block(
Block::default()
.borders(Borders::ALL)
.style(Style::default())
.title(Span::styled(
"Value",
Style::default().add_modifier(Modifier::BOLD),
)),
)
.style(if focused {
Style::default()
} else {
Style::default().fg(Color::DarkGray)
});
})
.alignment(Alignment::Left)
.wrap(Wrap { trim: true });
f.render_widget(paragraph, area);
Ok(())
}

@ -5,17 +5,13 @@ use tui::{backend::Backend, layout::Rect, Frame};
pub struct VerticalScroll {
top: Cell<usize>,
max_top: Cell<usize>,
inside: bool,
border: bool,
}
impl VerticalScroll {
pub const fn new(border: bool, inside: bool) -> Self {
pub const fn new() -> Self {
Self {
top: Cell::new(0),
max_top: Cell::new(0),
border,
inside,
}
}
@ -42,14 +38,7 @@ impl VerticalScroll {
}
pub fn draw<B: Backend>(&self, f: &mut Frame<B>, r: Rect) {
draw_scrollbar(
f,
r,
self.max_top.get(),
self.top.get(),
self.border,
self.inside,
);
draw_scrollbar(f, r, self.max_top.get(), self.top.get());
}
}
@ -74,18 +63,3 @@ const fn calc_scroll_top(
current_top
}
}
#[cfg(test)]
mod tests {
use super::calc_scroll_top;
#[test]
fn test_scroll_no_scroll_to_top() {
assert_eq!(calc_scroll_top(1, 10, 4, 4), 0);
}
#[test]
fn test_scroll_zero_height() {
assert_eq!(calc_scroll_top(4, 0, 4, 3), 0);
}
}

@ -1,15 +1,10 @@
use crate::log::LogLevel;
use crate::Key;
use serde::Deserialize;
use std::fmt;
use std::fs::File;
use std::io::{BufReader, Read};
use std::path::{Path, PathBuf};
use structopt::StructOpt;
#[cfg(test)]
use serde::Serialize;
#[derive(StructOpt, Debug)]
pub struct CliConfig {
/// Set the config file
@ -22,8 +17,6 @@ pub struct Config {
pub conn: Vec<Connection>,
#[serde(default)]
pub key_config: KeyConfig,
#[serde(default)]
pub log_level: LogLevel,
}
#[derive(Debug, Deserialize, Clone)]
@ -32,8 +25,6 @@ enum DatabaseType {
MySql,
#[serde(rename = "postgres")]
Postgres,
#[serde(rename = "sqlite")]
Sqlite,
}
impl fmt::Display for DatabaseType {
@ -41,7 +32,6 @@ impl fmt::Display for DatabaseType {
match self {
Self::MySql => write!(f, "mysql"),
Self::Postgres => write!(f, "postgres"),
Self::Sqlite => write!(f, "sqlite"),
}
}
}
@ -52,15 +42,12 @@ impl Default for Config {
conn: vec![Connection {
r#type: DatabaseType::MySql,
name: None,
user: Some("root".to_string()),
host: Some("localhost".to_string()),
port: Some(3306),
path: None,
password: None,
user: "root".to_string(),
host: "localhost".to_string(),
port: 3306,
database: None,
}],
key_config: KeyConfig::default(),
log_level: LogLevel::default(),
}
}
}
@ -69,23 +56,18 @@ impl Default for Config {
pub struct Connection {
r#type: DatabaseType,
name: Option<String>,
user: Option<String>,
host: Option<String>,
port: Option<u64>,
path: Option<std::path::PathBuf>,
password: Option<String>,
user: String,
host: String,
port: u64,
pub database: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(test, derive(Serialize))]
pub struct KeyConfig {
pub scroll_up: Key,
pub scroll_down: Key,
pub scroll_right: Key,
pub scroll_left: Key,
pub move_up: Key,
pub move_down: Key,
pub copy: Key,
pub enter: Key,
pub exit: Key,
@ -93,7 +75,6 @@ pub struct KeyConfig {
pub exit_popup: Key,
pub focus_right: Key,
pub focus_left: Key,
pub focus_above: Key,
pub focus_connections: Key,
pub open_help: Key,
pub filter: Key,
@ -110,10 +91,6 @@ pub struct KeyConfig {
pub tab_constraints: Key,
pub tab_foreign_keys: Key,
pub tab_indexes: Key,
pub tab_sql_editor: Key,
pub tab_properties: Key,
pub extend_or_shorten_widget_width_to_right: Key,
pub extend_or_shorten_widget_width_to_left: Key,
}
impl Default for KeyConfig {
@ -123,8 +100,6 @@ impl Default for KeyConfig {
scroll_down: Key::Char('j'),
scroll_right: Key::Char('l'),
scroll_left: Key::Char('h'),
move_up: Key::Up,
move_down: Key::Down,
copy: Key::Char('y'),
enter: Key::Enter,
exit: Key::Ctrl('c'),
@ -132,7 +107,6 @@ impl Default for KeyConfig {
exit_popup: Key::Esc,
focus_right: Key::Right,
focus_left: Key::Left,
focus_above: Key::Up,
focus_connections: Key::Char('c'),
open_help: Key::Char('?'),
filter: Key::Char('/'),
@ -145,14 +119,10 @@ impl Default for KeyConfig {
extend_selection_by_one_cell_down: Key::Char('J'),
extend_selection_by_one_cell_up: Key::Char('K'),
tab_records: Key::Char('1'),
tab_properties: Key::Char('2'),
tab_sql_editor: Key::Char('3'),
tab_columns: Key::Char('4'),
tab_constraints: Key::Char('5'),
tab_foreign_keys: Key::Char('6'),
tab_indexes: Key::Char('7'),
extend_or_shorten_widget_width_to_right: Key::Char('>'),
extend_or_shorten_widget_width_to_left: Key::Char('<'),
tab_columns: Key::Char('2'),
tab_constraints: Key::Char('3'),
tab_foreign_keys: Key::Char('4'),
tab_indexes: Key::Char('5'),
}
}
}
@ -180,113 +150,46 @@ impl Config {
}
impl Connection {
pub fn database_url(&self) -> anyhow::Result<String> {
match self.r#type {
DatabaseType::MySql => {
let user = self
.user
.as_ref()
.ok_or_else(|| anyhow::anyhow!("type mysql needs the user field"))?;
let host = self
.host
.as_ref()
.ok_or_else(|| anyhow::anyhow!("type mysql needs the host field"))?;
let port = self
.port
.as_ref()
.ok_or_else(|| anyhow::anyhow!("type mysql needs the port field"))?;
let password = self
.password
.as_ref()
.map_or(String::new(), |p| p.to_string());
match self.database.as_ref() {
Some(database) => Ok(format!(
"mysql://{user}:{password}@{host}:{port}/{database}",
user = user,
password = password,
host = host,
port = port,
database = database
)),
None => Ok(format!(
"mysql://{user}:{password}@{host}:{port}",
user = user,
password = password,
host = host,
port = port,
)),
}
}
DatabaseType::Postgres => {
let user = self
.user
.as_ref()
.ok_or_else(|| anyhow::anyhow!("type postgres needs the user field"))?;
let host = self
.host
.as_ref()
.ok_or_else(|| anyhow::anyhow!("type postgres needs the host field"))?;
let port = self
.port
.as_ref()
.ok_or_else(|| anyhow::anyhow!("type postgres needs the port field"))?;
let password = self
.password
.as_ref()
.map_or(String::new(), |p| p.to_string());
match self.database.as_ref() {
Some(database) => Ok(format!(
"postgres://{user}:{password}@{host}:{port}/{database}",
user = user,
password = password,
host = host,
port = port,
pub fn database_url(&self) -> String {
match &self.database {
Some(database) => match self.r#type {
DatabaseType::MySql => format!(
"mysql://{user}:@{host}:{port}/{database}",
user = self.user,
host = self.host,
port = self.port,
database = database
),
DatabaseType::Postgres => {
format!(
"postgres://{user}@{host}:{port}/{database}",
user = self.user,
host = self.host,
port = self.port,
database = database
)),
None => Ok(format!(
"postgres://{user}:{password}@{host}:{port}",
user = user,
password = password,
host = host,
port = port,
)),
)
}
}
DatabaseType::Sqlite => {
let path = self.path.as_ref().map_or(
Err(anyhow::anyhow!("type sqlite needs the path field")),
|path| {
expand_path(path).ok_or_else(|| anyhow::anyhow!("cannot expand file path"))
},
)?;
Ok(format!("sqlite://{path}", path = path.to_str().unwrap()))
}
},
None => match self.r#type {
DatabaseType::MySql => format!(
"mysql://{user}:@{host}:{port}",
user = self.user,
host = self.host,
port = self.port,
),
DatabaseType::Postgres => format!(
"postgres://{user}@{host}:{port}",
user = self.user,
host = self.host,
port = self.port,
),
},
}
}
pub fn database_url_with_name(&self) -> anyhow::Result<String> {
let database_url = self.database_url()?;
Ok(match &self.name {
Some(name) => format!(
"[{name}] {database_url}",
name = name,
database_url = database_url
),
None => database_url,
})
}
pub fn is_mysql(&self) -> bool {
matches!(self.r#type, DatabaseType::MySql)
}
pub fn is_postgres(&self) -> bool {
matches!(self.r#type, DatabaseType::Postgres)
}
}
pub fn get_app_config_path() -> anyhow::Result<std::path::PathBuf> {
@ -301,112 +204,3 @@ pub fn get_app_config_path() -> anyhow::Result<std::path::PathBuf> {
std::fs::create_dir_all(&path)?;
Ok(path)
}
fn expand_path(path: &Path) -> Option<PathBuf> {
let mut expanded_path = PathBuf::new();
let mut path_iter = path.iter();
if path.starts_with("~") {
path_iter.next()?;
expanded_path = expanded_path.join(dirs_next::home_dir()?);
}
for path in path_iter {
let path = path.to_str()?;
expanded_path = if cfg!(unix) && path.starts_with('$') {
expanded_path.join(std::env::var(path.strip_prefix('$')?).unwrap_or_default())
} else if cfg!(windows) && path.starts_with('%') && path.ends_with('%') {
expanded_path
.join(std::env::var(path.strip_prefix('%')?.strip_suffix('%')?).unwrap_or_default())
} else {
expanded_path.join(path)
}
}
Some(expanded_path)
}
#[cfg(test)]
mod test {
use super::{expand_path, KeyConfig, Path, PathBuf};
use serde_json::Value;
use std::env;
#[test]
fn test_overlappted_key() {
let value: Value =
serde_json::from_str(&serde_json::to_string(&KeyConfig::default()).unwrap()).unwrap();
if let Value::Object(map) = value {
let mut values: Vec<String> = map
.values()
.map(|v| match v {
Value::Object(map) => Some(format!("{:?}", map)),
_ => None,
})
.flatten()
.collect();
values.sort();
let before_values = values.clone();
values.dedup();
pretty_assertions::assert_eq!(before_values, values);
}
}
#[test]
#[cfg(unix)]
fn test_expand_path() {
let home = env::var("HOME").unwrap();
let test_env = "baz";
env::set_var("TEST", test_env);
assert_eq!(
expand_path(&Path::new("$HOME/foo")),
Some(PathBuf::from(&home).join("foo"))
);
assert_eq!(
expand_path(&Path::new("$HOME/foo/$TEST/bar")),
Some(PathBuf::from(&home).join("foo").join(test_env).join("bar"))
);
assert_eq!(
expand_path(&Path::new("~/foo")),
Some(PathBuf::from(&home).join("foo"))
);
assert_eq!(
expand_path(&Path::new("~/foo/~/bar")),
Some(PathBuf::from(&home).join("foo").join("~").join("bar"))
);
}
#[test]
#[cfg(windows)]
fn test_expand_patha() {
let home = std::env::var("HOMEPATH").unwrap();
let test_env = "baz";
env::set_var("TEST", test_env);
assert_eq!(
expand_path(&Path::new("%HOMEPATH%/foo")),
Some(PathBuf::from(&home).join("foo"))
);
assert_eq!(
expand_path(&Path::new("%HOMEPATH%/foo/%TEST%/bar")),
Some(PathBuf::from(&home).join("foo").join(test_env).join("bar"))
);
assert_eq!(
expand_path(&Path::new("~/foo")),
Some(PathBuf::from(&dirs_next::home_dir().unwrap()).join("foo"))
);
assert_eq!(
expand_path(&Path::new("~/foo/~/bar")),
Some(
PathBuf::from(&dirs_next::home_dir().unwrap())
.join("foo")
.join("~")
.join("bar")
)
);
}
}

@ -1,10 +1,8 @@
pub mod mysql;
pub mod postgres;
pub mod sqlite;
pub use mysql::MySqlPool;
pub use postgres::PostgresPool;
pub use sqlite::SqlitePool;
use async_trait::async_trait;
use database_tree::{Child, Database, Table};
@ -12,8 +10,7 @@ use database_tree::{Child, Database, Table};
pub const RECORDS_LIMIT_PER_PAGE: u8 = 200;
#[async_trait]
pub trait Pool: Send + Sync {
async fn execute(&self, query: &String) -> anyhow::Result<ExecuteResult>;
pub trait Pool {
async fn get_databases(&self) -> anyhow::Result<Vec<Database>>;
async fn get_tables(&self, database: String) -> anyhow::Result<Vec<Child>>;
async fn get_records(
@ -46,26 +43,7 @@ pub trait Pool: Send + Sync {
async fn close(&self);
}
pub enum ExecuteResult {
Read {
headers: Vec<String>,
rows: Vec<Vec<String>>,
database: Database,
table: Table,
},
Write {
updated_rows: u64,
},
}
pub trait TableRow: std::marker::Send {
fn fields(&self) -> Vec<String>;
fn columns(&self) -> Vec<String>;
}
#[macro_export]
macro_rules! get_or_null {
($value:expr) => {
$value.map_or("NULL".to_string(), |v| v.to_string())
};
}

@ -1,25 +1,19 @@
use crate::get_or_null;
use super::{ExecuteResult, Pool, TableRow, RECORDS_LIMIT_PER_PAGE};
use super::{Pool, TableRow, RECORDS_LIMIT_PER_PAGE};
use async_trait::async_trait;
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use chrono::NaiveDate;
use database_tree::{Child, Database, Table};
use futures::TryStreamExt;
use sqlx::mysql::{MySqlColumn, MySqlPoolOptions, MySqlRow};
use sqlx::mysql::{MySqlColumn, MySqlPool as MPool, MySqlRow};
use sqlx::{Column as _, Row as _, TypeInfo as _};
use std::time::Duration;
pub struct MySqlPool {
pool: sqlx::mysql::MySqlPool,
pool: MPool,
}
impl MySqlPool {
pub async fn new(database_url: &str) -> anyhow::Result<Self> {
Ok(Self {
pool: MySqlPoolOptions::new()
.connect_timeout(Duration::from_secs(5))
.connect(database_url)
.await?,
pool: MPool::connect(database_url).await?,
})
}
}
@ -146,49 +140,6 @@ impl TableRow for Index {
#[async_trait]
impl Pool for MySqlPool {
async fn execute(&self, query: &String) -> anyhow::Result<ExecuteResult> {
let query = query.trim();
if query.to_uppercase().starts_with("SELECT") {
let mut rows = sqlx::query(query).fetch(&self.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();
let mut new_row = vec![];
for column in row.columns() {
new_row.push(convert_column_value_to_string(&row, column)?)
}
records.push(new_row)
}
return Ok(ExecuteResult::Read {
headers,
rows: records,
database: Database {
name: "-".to_string(),
children: Vec::new(),
},
table: Table {
name: "-".to_string(),
create_time: None,
update_time: None,
engine: None,
schema: None,
},
});
}
let result = sqlx::query(query).execute(&self.pool).await?;
Ok(ExecuteResult::Write {
updated_rows: result.rows_affected(),
})
}
async fn get_databases(&self) -> anyhow::Result<Vec<Database>> {
let databases = sqlx::query("SHOW DATABASES")
.fetch_all(&self.pool)
@ -207,18 +158,10 @@ impl Pool for MySqlPool {
}
async fn get_tables(&self, database: String) -> anyhow::Result<Vec<Child>> {
let query = format!("SHOW TABLE STATUS FROM `{}`", database);
let mut rows = sqlx::query(query.as_str()).fetch(&self.pool);
let mut tables = vec![];
while let Some(row) = rows.try_next().await? {
tables.push(Table {
name: row.try_get("Name")?,
create_time: row.try_get("Create_time")?,
update_time: row.try_get("Update_time")?,
engine: row.try_get("Engine")?,
schema: None,
})
}
let tables =
sqlx::query_as::<_, Table>(format!("SHOW TABLE STATUS FROM `{}`", database).as_str())
.fetch_all(&self.pool)
.await?;
Ok(tables.into_iter().map(|table| table.into()).collect())
}
@ -240,7 +183,7 @@ impl Pool for MySqlPool {
)
} else {
format!(
"SELECT * FROM `{}`.`{}` LIMIT {page}, {limit}",
"SELECT * FROM `{}`.`{}` limit {page}, {limit}",
database.name,
table.name,
page = page,
@ -398,69 +341,65 @@ impl Pool for MySqlPool {
fn convert_column_value_to_string(row: &MySqlRow, column: &MySqlColumn) -> anyhow::Result<String> {
let column_name = column.name();
if let Ok(value) = row.try_get(column_name) {
let value: Option<String> = value;
Ok(value.unwrap_or_else(|| "NULL".to_string()))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.unwrap_or_else(|| "NULL".to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<&str> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<i8> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<i16> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<i32> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<i64> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<f32> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<f64> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<rust_decimal::Decimal> = value;
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<u8> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<u16> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<u32> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<u64> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<rust_decimal::Decimal> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<NaiveDate> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<NaiveTime> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<NaiveDateTime> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<chrono::DateTime<chrono::Utc>> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<serde_json::Value> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<bool> = value;
Ok(get_or_null!(value))
} else {
anyhow::bail!(
"column type not implemented: `{}` {}",
column_name,
column.type_info().clone().name()
)
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
Err(anyhow::anyhow!(
"column type not implemented: `{}` {}",
column_name,
column.type_info().clone().name()
))
}

@ -1,14 +1,11 @@
use crate::get_or_null;
use super::{ExecuteResult, Pool, TableRow, RECORDS_LIMIT_PER_PAGE};
use super::{Pool, TableRow, RECORDS_LIMIT_PER_PAGE};
use async_trait::async_trait;
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use chrono::NaiveDate;
use database_tree::{Child, Database, Schema, Table};
use futures::TryStreamExt;
use itertools::Itertools;
use sqlx::postgres::{PgColumn, PgPool, PgPoolOptions, PgRow};
use sqlx::postgres::{PgColumn, PgPool, PgRow};
use sqlx::{Column as _, Row as _, TypeInfo as _};
use std::time::Duration;
pub struct PostgresPool {
pool: PgPool,
@ -17,10 +14,7 @@ pub struct PostgresPool {
impl PostgresPool {
pub async fn new(database_url: &str) -> anyhow::Result<Self> {
Ok(Self {
pool: PgPoolOptions::new()
.connect_timeout(Duration::from_secs(5))
.connect(database_url)
.await?,
pool: PgPool::connect(database_url).await?,
})
}
}
@ -147,47 +141,6 @@ impl TableRow for Index {
#[async_trait]
impl Pool for PostgresPool {
async fn execute(&self, query: &String) -> anyhow::Result<ExecuteResult> {
let query = query.trim();
if query.to_uppercase().starts_with("SELECT") {
let mut rows = sqlx::query(query).fetch(&self.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();
let mut new_row = vec![];
for column in row.columns() {
new_row.push(convert_column_value_to_string(&row, column)?)
}
records.push(new_row)
}
return Ok(ExecuteResult::Read {
headers,
rows: records,
database: Database {
name: "-".to_string(),
children: Vec::new(),
},
table: Table {
name: "-".to_string(),
create_time: None,
update_time: None,
engine: None,
schema: None,
},
});
}
let result = sqlx::query(query).execute(&self.pool).await?;
Ok(ExecuteResult::Write {
updated_rows: result.rows_affected(),
})
}
async fn get_databases(&self) -> anyhow::Result<Vec<Database>> {
let databases = sqlx::query("SELECT datname FROM pg_database")
.fetch_all(&self.pool)
@ -248,7 +201,7 @@ impl Pool for PostgresPool {
) -> anyhow::Result<(Vec<String>, Vec<Vec<String>>)> {
let query = if let Some(filter) = filter.as_ref() {
format!(
r#"SELECT * FROM "{database}"."{table_schema}"."{table}" WHERE {filter} LIMIT {limit} OFFSET {page}"#,
r#"SELECT * FROM "{database}""{table_schema}"."{table}" WHERE {filter} LIMIT {page}, {limit}"#,
database = database.name,
table = table.name,
filter = filter,
@ -258,7 +211,7 @@ impl Pool for PostgresPool {
)
} else {
format!(
r#"SELECT * FROM "{database}"."{table_schema}"."{table}" LIMIT {limit} OFFSET {page}"#,
r#"SELECT * FROM "{database}"."{table_schema}"."{table}" limit {limit} offset {page}"#,
database = database.name,
table = table.name,
table_schema = table.schema.clone().unwrap_or_else(|| "public".to_string()),
@ -299,15 +252,7 @@ impl Pool for PostgresPool {
serde_json::Value::Array(v) => {
new_row.push(v.iter().map(|v| v.to_string()).join(","))
}
serde_json::Value::Number(v) => new_row.push(v.to_string()),
serde_json::Value::Bool(v) => new_row.push(v.to_string()),
others => {
panic!(
"column type not implemented: `{}` {}",
column.name(),
others
)
}
_ => (),
}
}
}
@ -482,7 +427,7 @@ impl PostgresPool {
) -> anyhow::Result<Vec<serde_json::Value>> {
let query = if let Some(filter) = filter {
format!(
r#"SELECT to_json("{table}".*) FROM "{database}"."{table_schema}"."{table}" WHERE {filter} LIMIT {limit} OFFSET {page}"#,
r#"SELECT to_json({table}.*) FROM "{database}""{table_schema}"."{table}" WHERE {filter} LIMIT {page}, {limit}"#,
database = database.name,
table = table.name,
filter = filter,
@ -492,7 +437,7 @@ impl PostgresPool {
)
} else {
format!(
r#"SELECT to_json("{table}".*) FROM "{database}"."{table_schema}"."{table}" LIMIT {limit} OFFSET {page}"#,
r#"SELECT to_json({table}.*) FROM "{database}"."{table_schema}"."{table}" limit {limit} offset {page}"#,
database = database.name,
table = table.name,
table_schema = table.schema.clone().unwrap_or_else(|| "public".to_string()),
@ -510,19 +455,23 @@ fn convert_column_value_to_string(row: &PgRow, column: &PgColumn) -> anyhow::Res
let column_name = column.name();
if let Ok(value) = row.try_get(column_name) {
let value: Option<i16> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<i32> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<i64> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<rust_decimal::Decimal> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<&[u8]> = value;
Ok(value.map_or("NULL".to_string(), |values| {
return Ok(value.map_or("NULL".to_string(), |values| {
format!(
"\\x{}",
values
@ -530,42 +479,35 @@ fn convert_column_value_to_string(row: &PgRow, column: &PgColumn) -> anyhow::Res
.map(|v| format!("{:02x}", v))
.collect::<String>()
)
}))
} else if let Ok(value) = row.try_get(column_name) {
}));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<NaiveDate> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: String = value;
Ok(value)
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value);
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<chrono::DateTime<chrono::Utc>> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<chrono::DateTime<chrono::Local>> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<NaiveDateTime> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<NaiveDate> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<NaiveTime> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<serde_json::Value> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get::<Option<bool>, _>(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<chrono::NaiveDateTime> = value;
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get::<Option<bool>, _>(column_name) {
let value: Option<bool> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<Vec<String>> = value;
Ok(value.map_or("NULL".to_string(), |v| v.join(",")))
} else {
anyhow::bail!(
"column type not implemented: `{}` {}",
column_name,
column.type_info().clone().name()
)
return Ok(value.map_or("NULL".to_string(), |v| v.join(",")));
}
Err(anyhow::anyhow!(
"column type not implemented: `{}` {}",
column_name,
column.type_info().clone().name()
))
}

@ -1,431 +0,0 @@
use crate::get_or_null;
use super::{ExecuteResult, Pool, TableRow, RECORDS_LIMIT_PER_PAGE};
use async_trait::async_trait;
use chrono::NaiveDateTime;
use database_tree::{Child, Database, Table};
use futures::TryStreamExt;
use sqlx::sqlite::{SqliteColumn, SqlitePoolOptions, SqliteRow};
use sqlx::{Column as _, Row as _, TypeInfo as _};
use std::time::Duration;
pub struct SqlitePool {
pool: sqlx::sqlite::SqlitePool,
}
impl SqlitePool {
pub async fn new(database_url: &str) -> anyhow::Result<Self> {
Ok(Self {
pool: SqlitePoolOptions::new()
.connect_timeout(Duration::from_secs(5))
.connect(database_url)
.await?,
})
}
}
pub struct Constraint {
name: String,
column_name: String,
origin: String,
}
impl TableRow for Constraint {
fn fields(&self) -> Vec<String> {
vec![
"name".to_string(),
"column_name".to_string(),
"origin".to_string(),
]
}
fn columns(&self) -> Vec<String> {
vec![
self.name.to_string(),
self.column_name.to_string(),
self.origin.to_string(),
]
}
}
pub struct Column {
name: Option<String>,
r#type: Option<String>,
null: Option<String>,
default: Option<String>,
comment: Option<String>,
}
impl TableRow for Column {
fn fields(&self) -> Vec<String> {
vec![
"name".to_string(),
"type".to_string(),
"null".to_string(),
"default".to_string(),
"comment".to_string(),
]
}
fn columns(&self) -> Vec<String> {
vec![
self.name
.as_ref()
.map_or(String::new(), |name| name.to_string()),
self.r#type
.as_ref()
.map_or(String::new(), |r#type| r#type.to_string()),
self.null
.as_ref()
.map_or(String::new(), |null| null.to_string()),
self.default
.as_ref()
.map_or(String::new(), |default| default.to_string()),
self.comment
.as_ref()
.map_or(String::new(), |comment| comment.to_string()),
]
}
}
pub struct ForeignKey {
column_name: Option<String>,
ref_table: Option<String>,
ref_column: Option<String>,
}
impl TableRow for ForeignKey {
fn fields(&self) -> Vec<String> {
vec![
"column_name".to_string(),
"ref_table".to_string(),
"ref_column".to_string(),
]
}
fn columns(&self) -> Vec<String> {
vec![
self.column_name
.as_ref()
.map_or(String::new(), |r#type| r#type.to_string()),
self.ref_table
.as_ref()
.map_or(String::new(), |r#type| r#type.to_string()),
self.ref_column
.as_ref()
.map_or(String::new(), |r#type| r#type.to_string()),
]
}
}
pub struct Index {
name: Option<String>,
column_name: Option<String>,
r#type: Option<String>,
}
impl TableRow for Index {
fn fields(&self) -> Vec<String> {
vec![
"name".to_string(),
"column_name".to_string(),
"type".to_string(),
]
}
fn columns(&self) -> Vec<String> {
vec![
self.name
.as_ref()
.map_or(String::new(), |name| name.to_string()),
self.column_name
.as_ref()
.map_or(String::new(), |column_name| column_name.to_string()),
self.r#type
.as_ref()
.map_or(String::new(), |r#type| r#type.to_string()),
]
}
}
#[async_trait]
impl Pool for SqlitePool {
async fn execute(&self, query: &String) -> anyhow::Result<ExecuteResult> {
let query = query.trim();
if query.to_uppercase().starts_with("SELECT") {
let mut rows = sqlx::query(query).fetch(&self.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();
let mut new_row = vec![];
for column in row.columns() {
new_row.push(convert_column_value_to_string(&row, column)?)
}
records.push(new_row)
}
return Ok(ExecuteResult::Read {
headers,
rows: records,
database: Database {
name: "-".to_string(),
children: Vec::new(),
},
table: Table {
name: "-".to_string(),
create_time: None,
update_time: None,
engine: None,
schema: None,
},
});
}
let result = sqlx::query(query).execute(&self.pool).await?;
Ok(ExecuteResult::Write {
updated_rows: result.rows_affected(),
})
}
async fn get_databases(&self) -> anyhow::Result<Vec<Database>> {
let databases = sqlx::query("SELECT name FROM pragma_database_list")
.fetch_all(&self.pool)
.await?
.iter()
.map(|table| table.get(0))
.collect::<Vec<String>>();
let mut list = vec![];
for db in databases {
list.push(Database::new(
db.clone(),
self.get_tables(db.clone()).await?,
))
}
Ok(list)
}
async fn get_tables(&self, _database: String) -> anyhow::Result<Vec<Child>> {
let mut rows =
sqlx::query("SELECT name FROM sqlite_master WHERE type = 'table'").fetch(&self.pool);
let mut tables = Vec::new();
while let Some(row) = rows.try_next().await? {
tables.push(Table {
name: row.try_get("name")?,
create_time: None,
update_time: None,
engine: None,
schema: None,
})
}
Ok(tables.into_iter().map(|table| table.into()).collect())
}
async fn get_records(
&self,
_database: &Database,
table: &Table,
page: u16,
filter: Option<String>,
) -> anyhow::Result<(Vec<String>, Vec<Vec<String>>)> {
let query = if let Some(filter) = filter {
format!(
"SELECT * FROM `{table}` WHERE {filter} LIMIT {page}, {limit}",
table = table.name,
filter = filter,
page = page,
limit = RECORDS_LIMIT_PER_PAGE
)
} else {
format!(
"SELECT * FROM `{}` LIMIT {page}, {limit}",
table.name,
page = page,
limit = RECORDS_LIMIT_PER_PAGE
)
};
let mut rows = sqlx::query(query.as_str()).fetch(&self.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();
let mut new_row = vec![];
for column in row.columns() {
new_row.push(convert_column_value_to_string(&row, column)?)
}
records.push(new_row)
}
Ok((headers, records))
}
async fn get_columns(
&self,
_database: &Database,
table: &Table,
) -> anyhow::Result<Vec<Box<dyn TableRow>>> {
let query = format!("SELECT * FROM pragma_table_info('{}');", table.name);
let mut rows = sqlx::query(query.as_str()).fetch(&self.pool);
let mut columns: Vec<Box<dyn TableRow>> = vec![];
while let Some(row) = rows.try_next().await? {
let null: Option<i16> = row.try_get("notnull")?;
columns.push(Box::new(Column {
name: row.try_get("name")?,
r#type: row.try_get("type")?,
null: if matches!(null, Some(null) if null == 1) {
Some("✔︎".to_string())
} else {
Some("".to_string())
},
default: row.try_get("dflt_value")?,
comment: None,
}))
}
Ok(columns)
}
async fn get_constraints(
&self,
_database: &Database,
table: &Table,
) -> anyhow::Result<Vec<Box<dyn TableRow>>> {
let mut rows = sqlx::query(
"
SELECT
p.origin,
s.name AS index_name,
i.name AS column_name
FROM
sqlite_master s
JOIN pragma_index_list(s.tbl_name) p ON s.name = p.name,
pragma_index_info(s.name) i
WHERE
s.type = 'index'
AND tbl_name = ?
AND NOT p.origin = 'c'
",
)
.bind(&table.name)
.fetch(&self.pool);
let mut constraints: Vec<Box<dyn TableRow>> = vec![];
while let Some(row) = rows.try_next().await? {
constraints.push(Box::new(Constraint {
name: row.try_get("index_name")?,
column_name: row.try_get("column_name")?,
origin: row.try_get("origin")?,
}))
}
Ok(constraints)
}
async fn get_foreign_keys(
&self,
_database: &Database,
table: &Table,
) -> anyhow::Result<Vec<Box<dyn TableRow>>> {
let query = format!(
"SELECT p.`from`, p.`to`, p.`table` FROM pragma_foreign_key_list('{}') p",
&table.name
);
let mut rows = sqlx::query(query.as_str())
.bind(&table.name)
.fetch(&self.pool);
let mut foreign_keys: Vec<Box<dyn TableRow>> = vec![];
while let Some(row) = rows.try_next().await? {
foreign_keys.push(Box::new(ForeignKey {
column_name: row.try_get("from")?,
ref_table: row.try_get("table")?,
ref_column: row.try_get("to")?,
}))
}
Ok(foreign_keys)
}
async fn get_indexes(
&self,
_database: &Database,
table: &Table,
) -> anyhow::Result<Vec<Box<dyn TableRow>>> {
let mut rows = sqlx::query(
"
SELECT
m.name AS index_name,
p.*
FROM
sqlite_master m,
pragma_index_info(m.name) p
WHERE
m.type = 'index'
AND m.tbl_name = ?
",
)
.bind(&table.name)
.fetch(&self.pool);
let mut foreign_keys: Vec<Box<dyn TableRow>> = vec![];
while let Some(row) = rows.try_next().await? {
foreign_keys.push(Box::new(Index {
name: row.try_get("index_name")?,
column_name: row.try_get("name")?,
r#type: Some(String::new()),
}))
}
Ok(foreign_keys)
}
async fn close(&self) {
self.pool.close().await;
}
}
fn convert_column_value_to_string(
row: &SqliteRow,
column: &SqliteColumn,
) -> anyhow::Result<String> {
let column_name = column.name();
if let Ok(value) = row.try_get(column_name) {
let value: Option<String> = value;
Ok(value.unwrap_or_else(|| "NULL".to_string()))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<&str> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<i16> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<i32> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<i64> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<f32> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<f64> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<chrono::DateTime<chrono::Utc>> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<chrono::DateTime<chrono::Local>> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<NaiveDateTime> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
let value: Option<bool> = value;
Ok(get_or_null!(value))
} else {
anyhow::bail!(
"column type not implemented: `{}` {}",
column_name,
column.type_info().clone().name()
)
}
}

@ -2,12 +2,8 @@ use crossterm::event;
use serde::Deserialize;
use std::fmt;
#[cfg(test)]
use serde::Serialize;
/// Represents a key.
#[derive(PartialEq, Eq, Clone, Copy, Hash, Debug, Deserialize)]
#[cfg_attr(test, derive(Serialize))]
pub enum Key {
/// Both Enter (or Return) and numpad Enter
Enter,
@ -69,7 +65,7 @@ pub enum Key {
Char(char),
Ctrl(char),
Alt(char),
Unknown,
Unkown,
}
impl Key {
@ -207,7 +203,7 @@ impl From<event::KeyEvent> for Key {
..
} => Key::Char(c),
_ => Key::Unknown,
_ => Key::Unkown,
}
}
}

@ -1,81 +1,14 @@
use serde::Deserialize;
#[derive(Debug, PartialEq, PartialOrd, Eq, Ord, Clone, Deserialize)]
pub enum LogLevel {
Quiet,
Error,
Info,
}
impl Default for LogLevel {
fn default() -> Self {
Self::Info
}
}
impl LogLevel {
pub fn is_writable(&self, level: &Self) -> bool {
use std::cmp::Ordering;
matches!(self.cmp(level), Ordering::Greater | Ordering::Equal)
}
pub fn write(&self, level: &Self) -> Box<dyn std::io::Write> {
if self.is_writable(level) {
match level {
Self::Error => Box::from(std::io::stderr()),
_ => Box::from(std::io::stdout()),
}
} else {
Box::from(std::io::sink())
}
}
}
impl From<LogLevel> for &'static str {
fn from(log_level: LogLevel) -> &'static str {
match log_level {
LogLevel::Quiet => "quiet",
LogLevel::Info => "info",
LogLevel::Error => "error",
}
}
}
impl std::str::FromStr for LogLevel {
type Err = String;
fn from_str(s: &str) -> Result<LogLevel, Self::Err> {
match s {
"quiet" => Ok(Self::Quiet),
"info" | "all" => Ok(Self::Info),
"error" => Ok(Self::Error),
level => Err(format!("I don't know the log level of {:?}", level)),
}
}
}
#[macro_export]
macro_rules! outln {
($config:ident#$level:path, $($expr:expr),+) => {{
use $crate::log::LogLevel::*;
writeln!($config.log_level.write(&$level), $($expr),+).expect("Can't write output");
($($expr:expr),+) => {{
use std::io::{Write};
use std::fs::OpenOptions;
let mut file = OpenOptions::new()
.write(true)
.create(true)
.append(true)
.open("gobang.log")
.unwrap();
writeln!(file, $($expr),+).expect("Can't write output");
}}
}
#[macro_export]
macro_rules! debug {
($($expr:expr),+) => {
#[cfg(debug_assertions)]
{
use std::io::{Write};
use std::fs::OpenOptions;
let mut file = OpenOptions::new()
.write(true)
.create(true)
.append(true)
.open("gobang.log")
.unwrap();
writeln!(file, $($expr),+).expect("Can't write output");
}
}
}

@ -6,7 +6,6 @@ mod config;
mod database;
mod event;
mod ui;
mod version;
#[macro_use]
mod log;
@ -18,11 +17,13 @@ use crossterm::{
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
use std::io;
use std::{io, panic};
use tui::{backend::CrosstermBackend, Terminal};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
outln!("gobang logger");
let value = crate::cli::parse();
let config = config::Config::new(&value.config)?;
@ -31,17 +32,12 @@ async fn main() -> anyhow::Result<()> {
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend)?;
let events = event::Events::new(250);
let mut app = App::new(config.clone());
let mut app = App::new(config);
terminal.clear()?;
loop {
terminal.draw(|f| {
if let Err(err) = app.draw(f) {
outln!(config#Error, "error: {}", err.to_string());
std::process::exit(1);
}
})?;
terminal.draw(|f| app.draw(f).unwrap())?;
match events.next()? {
Event::Input(key) => match app.event(key).await {
Ok(state) => {
@ -69,6 +65,14 @@ fn setup_terminal() -> Result<()> {
Ok(())
}
fn set_panic_handlers() -> Result<()> {
panic::set_hook(Box::new(|e| {
eprintln!("panic: {:?}", e);
shutdown_terminal();
}));
Ok(())
}
fn shutdown_terminal() {
let leave_screen = io::stdout().execute(LeaveAlternateScreen).map(|_f| ());

@ -2,11 +2,8 @@ use crate::config::KeyConfig;
use crate::event::Key;
use database_tree::MoveSelection;
pub mod reflow;
pub mod scrollbar;
pub mod scrolllist;
pub mod stateful_paragraph;
pub mod syntax_text;
pub fn common_nav(key: Key, key_config: &KeyConfig) -> Option<MoveSelection> {
if key == key_config.scroll_down {
@ -25,8 +22,6 @@ pub fn common_nav(key: Key, key_config: &KeyConfig) -> Option<MoveSelection> {
Some(MoveSelection::Top)
} else if key == key_config.scroll_to_bottom {
Some(MoveSelection::End)
} else if key == key_config.enter {
Some(MoveSelection::Enter)
} else {
None
}

@ -1,545 +0,0 @@
use easy_cast::Cast;
use tui::text::StyledGrapheme;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
const NBSP: &str = "\u{00a0}";
/// A state machine to pack styled symbols into lines.
/// Cannot implement it as Iterator since it yields slices of the internal buffer (need streaming
/// iterators for that).
pub trait LineComposer<'a> {
fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)>;
}
/// A state machine that wraps lines on word boundaries.
pub struct WordWrapper<'a, 'b> {
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
max_line_width: u16,
current_line: Vec<StyledGrapheme<'a>>,
next_line: Vec<StyledGrapheme<'a>>,
/// Removes the leading whitespace from lines
trim: bool,
}
impl<'a, 'b> WordWrapper<'a, 'b> {
pub fn new(
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
max_line_width: u16,
trim: bool,
) -> WordWrapper<'a, 'b> {
WordWrapper {
symbols,
max_line_width,
current_line: vec![],
next_line: vec![],
trim,
}
}
}
impl<'a, 'b> LineComposer<'a> for WordWrapper<'a, 'b> {
fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)> {
if self.max_line_width == 0 {
return None;
}
std::mem::swap(&mut self.current_line, &mut self.next_line);
self.next_line.truncate(0);
let mut current_line_width = self
.current_line
.iter()
.map(|StyledGrapheme { symbol, .. }| -> u16 { symbol.width().cast() })
.sum();
let mut symbols_to_last_word_end: usize = 0;
let mut width_to_last_word_end: u16 = 0;
let mut prev_whitespace = false;
let mut symbols_exhausted = true;
for StyledGrapheme { symbol, style } in &mut self.symbols {
symbols_exhausted = false;
let symbol_whitespace = symbol.chars().all(&char::is_whitespace) && symbol != NBSP;
// Ignore characters wider that the total max width.
if Cast::<u16>::cast(symbol.width()) > self.max_line_width
// Skip leading whitespace when trim is enabled.
|| self.trim && symbol_whitespace && symbol != "\n" && current_line_width == 0
{
continue;
}
// Break on newline and discard it.
if symbol == "\n" {
if prev_whitespace {
current_line_width = width_to_last_word_end;
self.current_line.truncate(symbols_to_last_word_end);
}
break;
}
// Mark the previous symbol as word end.
if symbol_whitespace && !prev_whitespace {
symbols_to_last_word_end = self.current_line.len();
width_to_last_word_end = current_line_width;
}
self.current_line.push(StyledGrapheme { symbol, style });
current_line_width += Cast::<u16>::cast(symbol.width());
if current_line_width > self.max_line_width {
// If there was no word break in the text, wrap at the end of the line.
let (truncate_at, truncated_width) = if symbols_to_last_word_end == 0 {
(self.current_line.len() - 1, self.max_line_width)
} else {
(self.current_line.len() - 1, width_to_last_word_end)
};
// Push the remainder to the next line but strip leading whitespace:
{
let remainder = &self.current_line[truncate_at..];
if !remainder.is_empty() {
self.next_line.extend_from_slice(&remainder);
}
}
self.current_line.truncate(truncate_at);
current_line_width = truncated_width;
break;
}
prev_whitespace = symbol_whitespace;
}
// Even if the iterator is exhausted, pass the previous remainder.
if symbols_exhausted && self.current_line.is_empty() {
None
} else {
Some((&self.current_line[..], current_line_width))
}
}
}
/// A state machine that truncates overhanging lines.
pub struct LineTruncator<'a, 'b> {
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
max_line_width: u16,
current_line: Vec<StyledGrapheme<'a>>,
/// Record the offet to skip render
horizontal_offset: u16,
}
impl<'a, 'b> LineTruncator<'a, 'b> {
pub fn new(
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
max_line_width: u16,
) -> LineTruncator<'a, 'b> {
LineTruncator {
symbols,
max_line_width,
horizontal_offset: 0,
current_line: vec![],
}
}
pub fn set_horizontal_offset(&mut self, horizontal_offset: u16) {
self.horizontal_offset = horizontal_offset;
}
}
impl<'a, 'b> LineComposer<'a> for LineTruncator<'a, 'b> {
fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)> {
if self.max_line_width == 0 {
return None;
}
self.current_line.truncate(0);
let mut current_line_width = 0;
let mut skip_rest = false;
let mut symbols_exhausted = true;
let mut horizontal_offset = self.horizontal_offset as usize;
for StyledGrapheme { symbol, style } in &mut self.symbols {
symbols_exhausted = false;
// Ignore characters wider that the total max width.
if Cast::<u16>::cast(symbol.width()) > self.max_line_width {
continue;
}
// Break on newline and discard it.
if symbol == "\n" {
break;
}
if current_line_width + Cast::<u16>::cast(symbol.width()) > self.max_line_width {
// Exhaust the remainder of the line.
skip_rest = true;
break;
}
let symbol = if horizontal_offset == 0 {
symbol
} else {
let w = symbol.width();
if w > horizontal_offset {
let t = trim_offset(symbol, horizontal_offset);
horizontal_offset = 0;
t
} else {
horizontal_offset -= w;
""
}
};
current_line_width += Cast::<u16>::cast(symbol.width());
self.current_line.push(StyledGrapheme { symbol, style });
}
if skip_rest {
for StyledGrapheme { symbol, .. } in &mut self.symbols {
if symbol == "\n" {
break;
}
}
}
if symbols_exhausted && self.current_line.is_empty() {
None
} else {
Some((&self.current_line[..], current_line_width))
}
}
}
/// This function will return a str slice which start at specified offset.
/// As src is a unicode str, start offset has to be calculated with each character.
fn trim_offset(src: &str, mut offset: usize) -> &str {
let mut start = 0;
for c in UnicodeSegmentation::graphemes(src, true) {
let w = c.width();
if w <= offset {
offset -= w;
start += c.len();
} else {
break;
}
}
&src[start..]
}
#[cfg(test)]
mod test {
use super::*;
use unicode_segmentation::UnicodeSegmentation;
enum Composer {
WordWrapper { trim: bool },
LineTruncator,
}
fn run_composer(which: Composer, text: &str, text_area_width: u16) -> (Vec<String>, Vec<u16>) {
let style = Default::default();
let mut styled =
UnicodeSegmentation::graphemes(text, true).map(|g| StyledGrapheme { symbol: g, style });
let mut composer: Box<dyn LineComposer> = match which {
Composer::WordWrapper { trim } => {
Box::new(WordWrapper::new(&mut styled, text_area_width, trim))
}
Composer::LineTruncator => Box::new(LineTruncator::new(&mut styled, text_area_width)),
};
let mut lines = vec![];
let mut widths = vec![];
while let Some((styled, width)) = composer.next_line() {
let line = styled
.iter()
.map(|StyledGrapheme { symbol, .. }| *symbol)
.collect::<String>();
assert!(width <= text_area_width);
lines.push(line);
widths.push(width);
}
(lines, widths)
}
#[test]
fn line_composer_one_line() {
let width = 40;
for i in 1..width {
let text = "a".repeat(i);
let (word_wrapper, _) =
run_composer(Composer::WordWrapper { trim: true }, &text, width as u16);
let (line_truncator, _) = run_composer(Composer::LineTruncator, &text, width as u16);
let expected = vec![text];
assert_eq!(word_wrapper, expected);
assert_eq!(line_truncator, expected);
}
}
#[test]
fn line_composer_short_lines() {
let width = 20;
let text =
"abcdefg\nhijklmno\npabcdefg\nhijklmn\nopabcdefghijk\nlmnopabcd\n\n\nefghijklmno";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
let wrapped: Vec<&str> = text.split('\n').collect();
assert_eq!(word_wrapper, wrapped);
assert_eq!(line_truncator, wrapped);
}
#[test]
fn line_composer_long_word() {
let width = 20;
let text = "abcdefghijklmnopabcdefghijklmnopabcdefghijklmnopabcdefghijklmno";
let (word_wrapper, _) =
run_composer(Composer::WordWrapper { trim: true }, text, width as u16);
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width as u16);
let wrapped = vec![
&text[..width],
&text[width..width * 2],
&text[width * 2..width * 3],
&text[width * 3..],
];
assert_eq!(
word_wrapper, wrapped,
"WordWrapper should detect the line cannot be broken on word boundary and \
break it at line width limit."
);
assert_eq!(line_truncator, vec![&text[..width]]);
}
#[test]
fn line_composer_long_sentence() {
let width = 20;
let text =
"abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab c d e f g h i j k l m n o";
let text_multi_space =
"abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab c d e f g h i j k l \
m n o";
let (word_wrapper_single_space, _) =
run_composer(Composer::WordWrapper { trim: true }, text, width as u16);
let (word_wrapper_multi_space, _) = run_composer(
Composer::WordWrapper { trim: true },
text_multi_space,
width as u16,
);
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width as u16);
assert_eq!(
word_wrapper_single_space,
vec![
"abcd efghij klmnopab",
"cd efgh ijklmnopabcd",
"efg hijkl mnopab c d",
" e f g h i j k l m n",
" o",
]
);
assert_eq!(
word_wrapper_multi_space,
vec![
"abcd efghij klmno",
"pabcd efgh ijklm",
"nopabcdefg hijkl mno",
"pab c d e f g h i j ",
"k l m n o"
]
);
assert_eq!(line_truncator, vec![&text[..width]]);
}
#[test]
fn line_composer_zero_width() {
let width = 0;
let text = "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab ";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
let expected: Vec<&str> = Vec::new();
assert_eq!(word_wrapper, expected);
assert_eq!(line_truncator, expected);
}
#[test]
fn line_composer_max_line_width_of_1() {
let width = 1;
let text = "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab ";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
let expected: Vec<&str> = UnicodeSegmentation::graphemes(text, true).collect();
assert_eq!(word_wrapper, expected);
assert_eq!(line_truncator, vec!["a"]);
}
#[test]
fn line_composer_max_line_width_of_1_double_width_characters() {
let width = 1;
let text = "\naaa\
";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
assert_eq!(word_wrapper, vec!["", "a", "a", "a"]);
assert_eq!(line_truncator, vec!["", "a"]);
}
/// Tests WordWrapper with words some of which exceed line length and some not.
#[test]
fn line_composer_word_wrapper_mixed_length() {
let width = 20;
let text = "abcd efghij klmnopabcdefghijklmnopabcdefghijkl mnopab cdefghi j klmno";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
assert_eq!(
word_wrapper,
vec![
"abcd efghij klmnopab",
"cdefghijklmnopabcdef",
"ghijkl mnopab cdefgh",
"i j klmno"
]
)
}
#[test]
fn line_composer_double_width_chars() {
let width = 20;
let text = "\
";
let (word_wrapper, word_wrapper_width) =
run_composer(Composer::WordWrapper { trim: true }, &text, width);
let (line_truncator, _) = run_composer(Composer::LineTruncator, &text, width);
assert_eq!(line_truncator, vec!["コンピュータ上で文字"]);
let wrapped = vec![
"コンピュータ上で文字",
"を扱う場合、典型的に",
"は文字による通信を行",
"う場合にその両端点で",
"は、",
];
assert_eq!(word_wrapper, wrapped);
assert_eq!(word_wrapper_width, vec![width, width, width, width, 4]);
}
#[test]
fn line_composer_leading_whitespace_removal() {
let width = 20;
let text = "AAAAAAAAAAAAAAAAAAAA AAA";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAAAAAAA", " AAA",]);
assert_eq!(line_truncator, vec!["AAAAAAAAAAAAAAAAAAAA"]);
}
/// Tests truncation of leading whitespace.
#[test]
fn line_composer_lots_of_spaces() {
let width = 20;
let text = " ";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
assert_eq!(word_wrapper, vec![""]);
assert_eq!(line_truncator, vec![" "]);
}
/// Tests an input starting with a letter, folowed by spaces - some of the behaviour is
/// incidental.
#[test]
fn line_composer_char_plus_lots_of_spaces() {
let width = 20;
let text = "a ";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
// What's happening below is: the first line gets consumed, trailing spaces discarded,
// after 20 of which a word break occurs (probably shouldn't). The second line break
// discards all whitespace. The result should probably be vec!["a"] but it doesn't matter
// that much.
assert_eq!(
word_wrapper,
vec![
"a ",
" ",
" ",
" "
]
);
assert_eq!(line_truncator, vec!["a "]);
}
#[test]
fn line_composer_word_wrapper_double_width_chars_mixed_with_spaces() {
let width = 20;
// Japanese seems not to use spaces but we should break on spaces anyway... We're using it
// to test double-width chars.
// You are more than welcome to add word boundary detection based of alterations of
// hiragana and katakana...
// This happens to also be a test case for mixed width because regular spaces are single width.
let text = "コンピュ ータ上で文字を扱う場合、 典型的には文 字による 通信を行 う場合にその両端点では、";
let (word_wrapper, word_wrapper_width) =
run_composer(Composer::WordWrapper { trim: true }, text, width);
assert_eq!(
word_wrapper,
vec![
"コンピュ ータ上で文",
"字を扱う場合、 典型",
"的には文 字による 通",
"信を行 う場合にその",
"両端点では、"
]
);
// Odd-sized lines have a space in them.
assert_eq!(word_wrapper_width, vec![8, 14, 17, 6, 12]);
}
/// Ensure words separated by nbsp are wrapped as if they were a single one.
#[test]
fn line_composer_word_wrapper_nbsp() {
let width = 20;
let text = "AAAAAAAAAAAAAAA AAAA\u{00a0}AAA";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAA AAAA", "\u{a0}AAA"]);
// Ensure that if the character was a regular space, it would be wrapped differently.
let text_space = text.replace("\u{00a0}", " ");
let (word_wrapper_space, _) =
run_composer(Composer::WordWrapper { trim: true }, &text_space, width);
assert_eq!(word_wrapper_space, vec!["AAAAAAAAAAAAAAA AAAA", " AAA"]);
}
#[test]
fn line_composer_word_wrapper_preserve_indentation() {
let width = 20;
let text = "AAAAAAAAAAAAAAAAAAAA AAA";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width);
assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAAAAAAA", " AAA"]);
}
#[test]
fn line_composer_word_wrapper_preserve_indentation_with_wrap() {
let width = 10;
let text = "AAA AAA AAAAA AA AAAAAA\n B\n C\n D";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width);
assert_eq!(
word_wrapper,
vec!["AAA AAA AA", "AAA AA AAA", "AAA", " B", " C", " D"]
);
}
#[test]
fn line_composer_word_wrapper_preserve_indentation_lots_of_whitespace() {
let width = 10;
let text = " 4 Indent\n must wrap!";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width);
assert_eq!(
word_wrapper,
vec![
" ",
" 4 Ind",
"ent",
" ",
" mus",
"t wrap!"
]
);
}
}

@ -15,19 +15,15 @@ struct Scrollbar {
pos: u16,
style_bar: Style,
style_pos: Style,
inside: bool,
border: bool,
}
impl Scrollbar {
fn new(max: usize, pos: usize, border: bool, inside: bool) -> Self {
fn new(max: usize, pos: usize) -> Self {
Self {
max: u16::try_from(max).unwrap_or_default(),
pos: u16::try_from(pos).unwrap_or_default(),
style_pos: Style::default(),
style_bar: Style::default(),
inside,
border,
}
}
}
@ -42,11 +38,7 @@ impl Widget for Scrollbar {
return;
}
let right = if self.inside {
area.right().saturating_sub(1)
} else {
area.right()
};
let right = area.right().saturating_sub(1);
if right <= area.left() {
return;
};
@ -54,7 +46,7 @@ impl Widget for Scrollbar {
let (bar_top, bar_height) = {
let scrollbar_area = area.inner(&Margin {
horizontal: 0,
vertical: if self.border { 1 } else { 0 },
vertical: 1,
});
(scrollbar_area.top(), scrollbar_area.height)
@ -75,15 +67,8 @@ impl Widget for Scrollbar {
}
}
pub fn draw_scrollbar<B: Backend>(
f: &mut Frame<B>,
r: Rect,
max: usize,
pos: usize,
border: bool,
inside: bool,
) {
let mut widget = Scrollbar::new(max, pos, border, inside);
pub fn draw_scrollbar<B: Backend>(f: &mut Frame<B>, r: Rect, max: usize, pos: usize) {
let mut widget = Scrollbar::new(max, pos);
widget.style_pos = Style::default().fg(Color::Blue);
f.render_widget(widget, r);
}

@ -1,182 +0,0 @@
#![allow(dead_code)]
use easy_cast::Cast;
use std::iter;
use tui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::Style,
text::{StyledGrapheme, Text},
widgets::{Block, StatefulWidget, Widget, Wrap},
};
use unicode_width::UnicodeWidthStr;
use super::reflow::{LineComposer, LineTruncator, WordWrapper};
const fn get_line_offset(line_width: u16, text_area_width: u16, alignment: Alignment) -> u16 {
match alignment {
Alignment::Center => (text_area_width / 2).saturating_sub(line_width / 2),
Alignment::Right => text_area_width.saturating_sub(line_width),
Alignment::Left => 0,
}
}
#[derive(Debug, Clone)]
pub struct StatefulParagraph<'a> {
/// A block to wrap the widget in
block: Option<Block<'a>>,
/// Widget style
style: Style,
/// How to wrap the text
wrap: Option<Wrap>,
/// The text to display
text: Text<'a>,
/// Alignment of the text
alignment: Alignment,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct ScrollPos {
pub x: u16,
pub y: u16,
}
impl ScrollPos {
pub const fn new(x: u16, y: u16) -> Self {
Self { x, y }
}
}
#[derive(Debug, Copy, Clone, Default)]
pub struct ParagraphState {
/// Scroll
scroll: ScrollPos,
/// after all wrapping this is the amount of lines
lines: u16,
/// last visible height
height: u16,
}
impl ParagraphState {
pub const fn lines(self) -> u16 {
self.lines
}
pub const fn height(self) -> u16 {
self.height
}
pub const fn scroll(self) -> ScrollPos {
self.scroll
}
pub fn set_scroll(&mut self, scroll: ScrollPos) {
self.scroll = scroll;
}
}
impl<'a> StatefulParagraph<'a> {
pub fn new<T>(text: T) -> Self
where
T: Into<Text<'a>>,
{
Self {
block: None,
style: Style::default(),
wrap: None,
text: text.into(),
alignment: Alignment::Left,
}
}
#[allow(clippy::missing_const_for_fn)]
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
pub const fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub const fn wrap(mut self, wrap: Wrap) -> Self {
self.wrap = Some(wrap);
self
}
pub const fn alignment(mut self, alignment: Alignment) -> Self {
self.alignment = alignment;
self
}
}
impl<'a> StatefulWidget for StatefulParagraph<'a> {
type State = ParagraphState;
fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
buf.set_style(area, self.style);
let text_area = match self.block.take() {
Some(b) => {
let inner_area = b.inner(area);
b.render(area, buf);
inner_area
}
None => area,
};
if text_area.height < 1 {
return;
}
let style = self.style;
let mut styled = self.text.lines.iter().flat_map(|spans| {
spans
.0
.iter()
.flat_map(|span| span.styled_graphemes(style))
// Required given the way composers work but might be refactored out if we change
// composers to operate on lines instead of a stream of graphemes.
.chain(iter::once(StyledGrapheme {
symbol: "\n",
style: self.style,
}))
});
let mut line_composer: Box<dyn LineComposer> = if let Some(Wrap { trim }) = self.wrap {
Box::new(WordWrapper::new(&mut styled, text_area.width, trim))
} else {
let mut line_composer = Box::new(LineTruncator::new(&mut styled, text_area.width));
if let Alignment::Left = self.alignment {
line_composer.set_horizontal_offset(state.scroll.x);
}
line_composer
};
let mut y = 0;
let mut end_reached = false;
while let Some((current_line, current_line_width)) = line_composer.next_line() {
if !end_reached && y >= state.scroll.y {
let mut x = get_line_offset(current_line_width, text_area.width, self.alignment);
for StyledGrapheme { symbol, style } in current_line {
buf.get_mut(text_area.left() + x, text_area.top() + y - state.scroll.y)
.set_symbol(if symbol.is_empty() {
// If the symbol is empty, the last char which rendered last time will
// leave on the line. It's a quick fix.
" "
} else {
symbol
})
.set_style(*style);
x += Cast::<u16>::cast(symbol.width());
}
}
y += 1;
if y >= text_area.height + state.scroll.y {
end_reached = true;
}
}
state.lines = y;
state.height = area.height;
}
}

@ -1,106 +0,0 @@
use std::ops::Range;
use syntect::{
highlighting::{
FontStyle, HighlightState, Highlighter, RangedHighlightIterator, Style, ThemeSet,
},
parsing::{ParseState, ScopeStack, SyntaxSet},
};
use tui::text::{Span, Spans};
struct SyntaxLine {
items: Vec<(Style, usize, Range<usize>)>,
}
pub struct SyntaxText {
text: String,
lines: Vec<SyntaxLine>,
}
impl SyntaxText {
pub fn new(text: String) -> Self {
let syntax_set: SyntaxSet = SyntaxSet::load_defaults_nonewlines();
let theme_set: ThemeSet = ThemeSet::load_defaults();
let mut state = ParseState::new(syntax_set.find_syntax_by_extension("sql").unwrap());
let highlighter = Highlighter::new(&theme_set.themes["base16-eighties.dark"]);
let mut syntax_lines: Vec<SyntaxLine> = Vec::new();
let mut highlight_state = HighlightState::new(&highlighter, ScopeStack::new());
for (number, line) in text.lines().enumerate() {
let ops = state.parse_line(line, &syntax_set);
let iter =
RangedHighlightIterator::new(&mut highlight_state, &ops[..], line, &highlighter);
syntax_lines.push(SyntaxLine {
items: iter
.map(|(style, _, range)| (style, number, range))
.collect(),
});
}
Self {
text,
lines: syntax_lines,
}
}
pub fn convert(&self) -> tui::text::Text<'_> {
let mut result_lines: Vec<Spans> = Vec::with_capacity(self.lines.len());
for (syntax_line, line_content) in self.lines.iter().zip(self.text.lines()) {
let mut line_span = Spans(Vec::with_capacity(syntax_line.items.len()));
for (style, _, range) in &syntax_line.items {
let item_content = &line_content[range.clone()];
let item_style = syntact_style_to_tui(style);
line_span.0.push(Span::styled(item_content, item_style));
}
result_lines.push(line_span);
}
result_lines.into()
}
}
impl<'a> From<&'a SyntaxText> for tui::text::Text<'a> {
fn from(v: &'a SyntaxText) -> Self {
let mut result_lines: Vec<Spans> = Vec::with_capacity(v.lines.len());
for (syntax_line, line_content) in v.lines.iter().zip(v.text.lines()) {
let mut line_span = Spans(Vec::with_capacity(syntax_line.items.len()));
for (style, _, range) in &syntax_line.items {
let item_content = &line_content[range.clone()];
let item_style = syntact_style_to_tui(style);
line_span.0.push(Span::styled(item_content, item_style));
}
result_lines.push(line_span);
}
result_lines.into()
}
}
fn syntact_style_to_tui(style: &Style) -> tui::style::Style {
let mut res = tui::style::Style::default().fg(tui::style::Color::Rgb(
style.foreground.r,
style.foreground.g,
style.foreground.b,
));
if style.font_style.contains(FontStyle::BOLD) {
res = res.add_modifier(tui::style::Modifier::BOLD);
}
if style.font_style.contains(FontStyle::ITALIC) {
res = res.add_modifier(tui::style::Modifier::ITALIC);
}
if style.font_style.contains(FontStyle::UNDERLINE) {
res = res.add_modifier(tui::style::Modifier::UNDERLINED);
}
res
}

@ -1,50 +0,0 @@
use std::{env, fmt};
#[derive(Default)]
pub struct Version {
major: u32,
minor: u32,
patch: u32,
pre: Option<String>,
}
impl Version {
/// read version at compile time from env variables
pub fn new() -> Self {
let mut res = Self::default();
let major_str = env!("CARGO_PKG_VERSION_MAJOR");
if let Ok(major) = major_str.parse::<u32>() {
res.major = major;
}
let minor_str = env!("CARGO_PKG_VERSION_MINOR");
if let Ok(minor) = minor_str.parse::<u32>() {
res.minor = minor;
}
let patch_str = env!("CARGO_PKG_VERSION_PATCH");
if let Ok(patch) = patch_str.parse::<u32>() {
res.patch = patch;
}
let pre_str = env!("CARGO_PKG_VERSION_PRE");
res.pre = if pre_str.is_empty() {
None
} else {
Some(pre_str.to_string())
};
res
}
}
impl fmt::Display for Version {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"v{}.{}.{}{}",
self.major,
self.minor,
self.patch,
self.pre
.as_ref()
.map_or(String::new(), |pre| format!("-{}", pre.to_string()))
)
}
}
Loading…
Cancel
Save