├── screenshots ├── linux-1.png ├── linux-2.png ├── macos-1.png ├── macos-2.png ├── windows-1.png └── windows-2.png ├── v.mod ├── .gitattributes ├── .editorconfig ├── src ├── utils.v ├── colours.v ├── main.v ├── layout.v ├── view.v ├── input_event.v ├── database.v └── draw.v ├── .gitignore ├── LICENSE └── README.md /screenshots/linux-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZetloStudio/ZeQLplus/HEAD/screenshots/linux-1.png -------------------------------------------------------------------------------- /screenshots/linux-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZetloStudio/ZeQLplus/HEAD/screenshots/linux-2.png -------------------------------------------------------------------------------- /screenshots/macos-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZetloStudio/ZeQLplus/HEAD/screenshots/macos-1.png -------------------------------------------------------------------------------- /screenshots/macos-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZetloStudio/ZeQLplus/HEAD/screenshots/macos-2.png -------------------------------------------------------------------------------- /screenshots/windows-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZetloStudio/ZeQLplus/HEAD/screenshots/windows-1.png -------------------------------------------------------------------------------- /screenshots/windows-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZetloStudio/ZeQLplus/HEAD/screenshots/windows-2.png -------------------------------------------------------------------------------- /v.mod: -------------------------------------------------------------------------------- 1 | Module { 2 | name: 'ZeQL+' 3 | description: 'Terminal SQLite Database Browser' 4 | version: '1.1.0' 5 | license: 'MIT' 6 | dependencies: [] 7 | } 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.bat eol=crlf 3 | 4 | **/*.v linguist-language=V 5 | **/*.vv linguist-language=V 6 | **/*.vsh linguist-language=V 7 | **/v.mod linguist-language=V 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | insert_final_newline = true 5 | trim_trailing_whitespace = true 6 | 7 | [*.v] 8 | indent_style = tab 9 | indent_size = 4 10 | -------------------------------------------------------------------------------- /src/utils.v: -------------------------------------------------------------------------------- 1 | module main 2 | 3 | import strings 4 | 5 | fn substr_with_runes(str string, start int, end int) string { 6 | r := str.runes() 7 | mut sb := strings.new_builder(end - start) 8 | for i in start .. end { 9 | if i >= r.len { 10 | break 11 | } 12 | sb.write_rune(r[i]) 13 | } 14 | return sb.str() 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | main 3 | zeql 4 | *.exe 5 | *.exe~ 6 | *.so 7 | *.dylib 8 | *.dll 9 | 10 | # Ignore binary output folders 11 | bin/ 12 | 13 | # Ignore common editor/system specific metadata 14 | .DS_Store 15 | .idea/ 16 | .vscode/ 17 | *.iml 18 | 19 | # ENV 20 | .env 21 | 22 | # vweb and database 23 | *.sqlite 24 | *.db 25 | *.js 26 | -------------------------------------------------------------------------------- /src/colours.v: -------------------------------------------------------------------------------- 1 | module main 2 | 3 | import term.ui as tui 4 | 5 | const white = tui.Color{ 6 | r: 240 7 | g: 240 8 | b: 240 9 | } 10 | const blue = tui.Color{ 11 | r: 100 12 | g: 220 13 | b: 220 14 | } 15 | const red = tui.Color{ 16 | r: 200 17 | g: 0 18 | b: 0 19 | } 20 | const grey = tui.Color{ 21 | r: 200 22 | g: 200 23 | b: 200 24 | } 25 | const black = tui.Color{ 26 | r: 10 27 | g: 10 28 | b: 10 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Zetlo Studio - www.zetlo.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/main.v: -------------------------------------------------------------------------------- 1 | module main 2 | 3 | import term.ui as tui 4 | import os 5 | 6 | struct App { 7 | mut: 8 | tui &tui.Context = unsafe { nil } 9 | db DB 10 | cursor_location Cursor = Cursor{layout['sql']['input_x'], layout['sql']['input_y']} 11 | active_view ActiveView 12 | table_list View 13 | result View 14 | col_widths map[int]int 15 | sql_statement string 16 | redraw bool = true 17 | error string 18 | } 19 | 20 | fn init(mut app App) { 21 | app.tui.set_window_title('ZeQL+') 22 | app.load_table_list() 23 | app.active_view = .table_list 24 | } 25 | 26 | fn frame(mut app App) { 27 | if !app.redraw { 28 | return 29 | } 30 | 31 | app.tui.clear() 32 | app.tui.hide_cursor() 33 | 34 | app.draw_table_list() 35 | app.draw_sql() 36 | app.draw_result() 37 | app.draw_layout() 38 | 39 | app.tui.reset() 40 | app.tui.set_cursor_position(app.cursor_location.x, app.cursor_location.y) 41 | app.tui.flush() 42 | 43 | app.redraw = false 44 | } 45 | 46 | fn main() { 47 | if os.args.len < 2 { 48 | println('Usage: zeql ') 49 | return 50 | } 51 | path := os.args[1] 52 | if !os.is_file(path) { 53 | println('Can\'t find file: ${path}') 54 | exit(1) 55 | } 56 | 57 | mut app := &App{ 58 | db: DB{ 59 | path: path 60 | } 61 | } 62 | 63 | app.tui = tui.init( 64 | init_fn: init 65 | user_data: app 66 | event_fn: event 67 | frame_fn: frame 68 | ) 69 | 70 | app.tui.run()! 71 | } 72 | -------------------------------------------------------------------------------- /src/layout.v: -------------------------------------------------------------------------------- 1 | module main 2 | 3 | const col_spacer = 2 4 | 5 | const layout = { 6 | 'table_list': { 7 | 'x1': 1 8 | 'y1': 1 9 | 'x2': 25 10 | 'y2': 25 11 | } 12 | 'sql': { 13 | 'x1': 26 14 | 'y1': 1 15 | 'x2': 50 16 | 'y2': 3 17 | 'input_x': 29 18 | 'input_y': 2 19 | } 20 | 'result': { 21 | 'x1': 26 22 | 'y1': 4 23 | 'x2': 50 24 | 'y2': 25 25 | 'heading_x': 28 26 | 'heading_y': 5 27 | 'row_start_x': 28 28 | 'row_start_y': 6 29 | } 30 | } 31 | 32 | fn (mut app App) draw_layout() { 33 | // TABLE LIST 34 | app.tui.reset() 35 | if app.active_view == .table_list { 36 | app.tui.set_color(blue) 37 | } 38 | app.draw_box(layout['table_list']['x1'], layout['table_list']['y1'], layout['table_list']['x2'], 39 | app.tui.window_height - 1, 'Tables') 40 | 41 | // SQL 42 | app.tui.reset() 43 | if app.active_view == .sql_view { 44 | app.tui.set_color(blue) 45 | } 46 | app.draw_box(layout['sql']['x1'], layout['sql']['y1'], app.tui.window_width, layout['sql']['y2'], 47 | 'SQL') 48 | app.tui.draw_text(27, 2, '>') 49 | 50 | // RESULT 51 | app.tui.reset() 52 | if app.active_view == .result { 53 | app.tui.set_color(blue) 54 | } 55 | app.draw_box(layout['result']['x1'], layout['result']['y1'], app.tui.window_width, 56 | app.tui.window_height - 1, 'Result') 57 | 58 | app.draw_info_footer() 59 | } 60 | 61 | fn (mut app App) calculate_col_widths() { 62 | for i, _ in app.col_widths { 63 | app.col_widths.delete(i) 64 | } 65 | // MAX LENGTH COLUMN HEADINGS 66 | for i, col in app.db.table_cols { 67 | length := col.name.len 68 | if length > app.col_widths[i] { 69 | app.col_widths[i] = length 70 | } 71 | } 72 | // MAX LENGTH TABLE COLUMN DATA 73 | for row in app.db.data { 74 | for i, val in row.vals { 75 | length := utf8_str_visible_length(val) 76 | if length > app.col_widths[i] { 77 | app.col_widths[i] = length 78 | } 79 | } 80 | } 81 | } 82 | 83 | fn (mut app App) col_with_padding(val string, col_index int) string { 84 | padding := (app.col_widths[col_index] + col_spacer) - utf8_str_visible_length(val) 85 | return val + ' '.repeat(padding) 86 | } 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ZeQL+ : Terminal SQLite Database Browser 2 | 3 | Screenshot of ZeQL+ 4 | 5 | ## Features 6 | 7 | - Open any SQLite database file 8 | - Very fast 9 | - Runs in a Terminal / CMD window 10 | - Tiny executable with no dependencies 11 | - List all tables in the database to browse 12 | - Paginated view of table rows 13 | - Run custom SQL queries and view the results 14 | - Cross platform: macOS, Linux, Windows 15 | - Open source 16 | 17 | --- 18 | 19 | ## Install 20 | 21 | Pre-built binaries for macOS, Linux, Windows 10+ are available as zip files in the [releases](https://github.com/ZetloStudio/ZeQLplus/releases) page. Just extract and run directly with no need to install. 22 | 23 | --- 24 | 25 | ## How to use ZeQL+ 26 | 27 | From the command line in a Terminal / CMD window: 28 | 29 | ```shell 30 | zeql 31 | ``` 32 | 33 | Note: you should move the `zeql` executable to a location in your path. 34 | 35 | ### Sample SQLite database 36 | 37 | You can download a sample SQLite database to test the functionality of ZeQL+ here: [Chinook SQLite](https://github.com/lerocha/chinook-database/blob/master/ChinookDatabase/DataSources/Chinook_Sqlite.sqlite) 38 | 39 | --- 40 | 41 | ## Building from source 42 | 43 | ZeQL+ is using [Vlang](https://github.com/vlang/v) v0.4.10 or above. To build ZeQL+ from source: 44 | 45 | 1. First you need to [install V](https://github.com/vlang/v#installing-v-from-source). 46 | 1. Clone the ZeQL+ repo 47 | 1. Build the executable in production mode 48 | 49 | ### macOS / Linux / Windows 10+ 50 | 51 | ```shell 52 | v -prod -skip-unused . -o zeql 53 | ``` 54 | 55 | --- 56 | 57 | ## Screenshots 58 | 59 | ### macOS 60 | 61 | - Startup 62 | 63 | 64 | - Table Browser 65 | 66 | 67 | ### Linux 68 | 69 | - Startup 70 | 71 | 72 | - Table Browser 73 | 74 | 75 | ### Windows 76 | 77 | - Startup 78 | 79 | 80 | - Table Browser 81 | 82 | 83 | --- 84 | 85 | ## License 86 | 87 | Licensed under [MIT](LICENSE) 88 | -------------------------------------------------------------------------------- /src/view.v: -------------------------------------------------------------------------------- 1 | module main 2 | 3 | enum Direction { 4 | up 5 | down 6 | left 7 | right 8 | } 9 | 10 | struct Visible { 11 | mut: 12 | visible_data []string 13 | visible_start int 14 | visible_max int 15 | current_index int 16 | hover_index int 17 | } 18 | 19 | struct View { 20 | mut: 21 | data []string 22 | rows Visible 23 | cols Visible 24 | } 25 | 26 | fn (mut v View) reset() { 27 | v = View{} 28 | } 29 | 30 | fn (mut v View) calc_visible_data() { 31 | v.rows.visible_data = []string{} 32 | // CALC ROWS TO SHOW 33 | for i in v.rows.visible_start .. (v.rows.visible_start + v.rows.visible_max) { 34 | if i < v.data.len { 35 | // allow for horizontal scrolling if needed 36 | row_data := if utf8_str_visible_length(v.data[i]) > v.cols.visible_start + 37 | v.cols.visible_max { 38 | substr_with_runes(v.data[i], v.cols.visible_start, v.cols.visible_start + 39 | v.cols.visible_max) 40 | } else if utf8_str_visible_length(v.data[i]) > v.cols.visible_max 41 | && v.cols.visible_start < utf8_str_visible_length(v.data[i]) - 1 { 42 | substr_with_runes(v.data[i], v.cols.visible_start, utf8_str_visible_length(v.data[i])) 43 | } else { 44 | v.data[i] 45 | } 46 | 47 | v.rows.visible_data << row_data 48 | } 49 | } 50 | } 51 | 52 | fn (mut v View) move(dir Direction, amount int) { 53 | match dir { 54 | .up { 55 | if v.rows.hover_index > 0 { 56 | // MOVE HOVER 57 | v.rows.hover_index -= amount 58 | if v.rows.hover_index < v.rows.visible_start { 59 | // SCROLL 60 | v.rows.visible_start -= amount 61 | } 62 | } 63 | } 64 | .down { 65 | if v.rows.hover_index < v.data.len - 1 { 66 | // MOVE HOVER 67 | v.rows.hover_index += amount 68 | if v.rows.hover_index >= v.rows.visible_max 69 | && (v.rows.hover_index - v.rows.visible_max) >= v.rows.visible_start { 70 | // SCROLL 71 | v.rows.visible_start += amount 72 | } 73 | } 74 | } 75 | .right { 76 | if v.cols.visible_start < v.cols.visible_max - amount { 77 | v.cols.visible_start += amount 78 | } 79 | } 80 | .left { 81 | if v.cols.visible_start >= amount { 82 | v.cols.visible_start -= amount 83 | } 84 | } 85 | } 86 | } 87 | 88 | enum ActiveView { 89 | table_list 90 | sql_view 91 | result 92 | } 93 | 94 | fn (a ActiveView) next() ActiveView { 95 | match a { 96 | .table_list { 97 | return .sql_view 98 | } 99 | .sql_view { 100 | return .result 101 | } 102 | .result { 103 | return .table_list 104 | } 105 | } 106 | } 107 | 108 | fn (a ActiveView) prev() ActiveView { 109 | match a { 110 | .table_list { 111 | return .result 112 | } 113 | .sql_view { 114 | return .table_list 115 | } 116 | .result { 117 | return .sql_view 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/input_event.v: -------------------------------------------------------------------------------- 1 | module main 2 | 3 | import term.ui as tui 4 | 5 | fn event(e &tui.Event, mut app App) { 6 | if e.typ == .key_down { 7 | match e.code { 8 | .tab { 9 | keyboard_tab(mut app, e.modifiers) 10 | } 11 | .up { 12 | keyboard_up(mut app) 13 | } 14 | .down { 15 | keyboard_down(mut app) 16 | } 17 | .left { 18 | keyboard_left(mut app) 19 | } 20 | .right { 21 | keyboard_right(mut app) 22 | } 23 | 32...126 { // most alpha 24 | // BUG: Windows always send .ctrl modifier 25 | $if windows { 26 | keyboard_alpha(mut app, e.ascii.ascii_str()) 27 | } $else { 28 | if e.modifiers == .ctrl { 29 | if e.code == .k { 30 | app.sql_statement = '' 31 | app.cursor_location.set(layout['sql']['input_x'], layout['sql']['input_y']) 32 | } 33 | } else { 34 | keyboard_alpha(mut app, e.ascii.ascii_str()) 35 | } 36 | } 37 | } 38 | .enter { 39 | keyboard_enter(mut app) 40 | } 41 | .backspace { 42 | keyboard_backspace(mut app) 43 | } 44 | .escape { 45 | exit(0) 46 | } 47 | .f4 { 48 | keyboard_f4(mut app) 49 | } 50 | .f5 { 51 | keyboard_f5(mut app) 52 | } 53 | else {} 54 | } 55 | } 56 | 57 | app.redraw = true 58 | } 59 | 60 | fn keyboard_tab(mut app App, m tui.Modifiers) { 61 | if m.has(.shift) { 62 | app.active_view = app.active_view.prev() 63 | } else { 64 | app.active_view = app.active_view.next() 65 | } 66 | } 67 | 68 | fn keyboard_up(mut app App) { 69 | match app.active_view { 70 | .table_list { 71 | app.table_list.move(.up, 1) 72 | } 73 | .result { 74 | app.result.move(.up, 1) 75 | } 76 | else {} 77 | } 78 | } 79 | 80 | fn keyboard_down(mut app App) { 81 | match app.active_view { 82 | .table_list { 83 | app.table_list.move(.down, 1) 84 | } 85 | .result { 86 | app.result.move(.down, 1) 87 | } 88 | else {} 89 | } 90 | } 91 | 92 | fn keyboard_left(mut app App) { 93 | match app.active_view { 94 | .table_list { 95 | app.table_list.move(.left, 1) 96 | } 97 | .sql_view { 98 | if app.cursor_location.x > layout['sql']['input_x'] { 99 | app.cursor_location.x -= 1 100 | } 101 | } 102 | .result { 103 | app.result.move(.left, 1) 104 | } 105 | } 106 | } 107 | 108 | fn keyboard_right(mut app App) { 109 | match app.active_view { 110 | .table_list { 111 | app.table_list.move(.right, 1) 112 | } 113 | .sql_view { 114 | if app.cursor_location.x < layout['sql']['input_x'] + app.sql_statement.len { 115 | app.cursor_location.x += 1 116 | } 117 | } 118 | .result { 119 | app.result.move(.right, 1) 120 | } 121 | } 122 | } 123 | 124 | fn keyboard_alpha(mut app App, alpha string) { 125 | if app.active_view != .sql_view { 126 | return 127 | } 128 | 129 | rel_cur := app.cursor_location.x - layout['sql']['input_x'] - 1 130 | left := app.sql_statement.substr(0, rel_cur + 1) 131 | right := app.sql_statement.substr(rel_cur + 1, app.sql_statement.len) 132 | 133 | app.sql_statement = '${left}${alpha}${right}' 134 | app.cursor_location.move(.right) 135 | } 136 | 137 | fn keyboard_backspace(mut app App) { 138 | if app.active_view != .sql_view { 139 | return 140 | } 141 | 142 | rel_cur := app.cursor_location.x - layout['sql']['input_x'] - 1 143 | if rel_cur < 0 { 144 | return 145 | } 146 | 147 | left := app.sql_statement.substr(0, rel_cur) 148 | right := app.sql_statement.substr(rel_cur + 1, app.sql_statement.len) 149 | app.sql_statement = '${left}${right}' 150 | app.cursor_location.move(.left) 151 | } 152 | 153 | fn keyboard_enter(mut app App) { 154 | match app.active_view { 155 | .table_list { 156 | app.table_list.rows.current_index = app.table_list.rows.hover_index 157 | app.load_table(app.table_list.data[app.table_list.rows.current_index]) 158 | if app.db.data.len > 0 { 159 | app.active_view = .result 160 | } 161 | } 162 | .sql_view { 163 | app.exec_sql(app.sql_statement) 164 | } 165 | else {} 166 | } 167 | } 168 | 169 | fn keyboard_f4(mut app App) { 170 | // Prev Page 171 | if app.active_view == .result && app.db.page_offset > 0 { 172 | app.db.page_offset-- 173 | app.exec_sql(app.sql_statement) 174 | } 175 | } 176 | 177 | fn keyboard_f5(mut app App) { 178 | // Next Page 179 | if app.active_view == .result && app.db.has_next_page { 180 | app.db.page_offset++ 181 | app.exec_sql(app.sql_statement) 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/database.v: -------------------------------------------------------------------------------- 1 | module main 2 | 3 | import db.sqlite 4 | import strings 5 | 6 | const max_per_page = 100 7 | 8 | struct DB { 9 | path string 10 | mut: 11 | table_cols []Column 12 | data []sqlite.Row 13 | has_next_page bool 14 | page_offset int 15 | custom_pagination bool 16 | } 17 | 18 | struct Column { 19 | name string 20 | datatype string 21 | notnull bool 22 | pk bool 23 | } 24 | 25 | fn (mut app App) load_table_list() { 26 | app.table_list.data = [] 27 | mut conn := sqlite.connect(app.db.path) or { panic(err) } 28 | defer { 29 | conn.close() or { panic(err) } 30 | } 31 | 32 | data := conn.exec("SELECT name FROM sqlite_master WHERE type='table'") or { 33 | app.error = err.msg() 34 | return 35 | } 36 | for table in data { 37 | app.table_list.data << table.vals[0] 38 | } 39 | 40 | app.table_list.data.sort() 41 | } 42 | 43 | fn (mut app App) load_table(table_name string) { 44 | app.db.page_offset = 0 45 | app.exec_sql('SELECT * FROM ${table_name};') 46 | } 47 | 48 | fn (mut app App) exec_sql(sql_query string) ? { 49 | if sql_query.len == 0 { 50 | return 51 | } 52 | 53 | app.error = '' 54 | app.db.data = [] 55 | mut conn := sqlite.connect(app.db.path) or { panic(err) } 56 | defer { 57 | conn.close() or { panic(err) } 58 | } 59 | 60 | query := enforce_limit(mut app, sql_query) 61 | data := conn.exec(query) or { 62 | app.error = err.msg() 63 | return 64 | } 65 | app.db.data = data 66 | 67 | get_query_columns(mut app, sql_query) 68 | app.calculate_col_widths() 69 | 70 | // SETUP DATA IN VIEW FROM DB TABLE 71 | app.result.reset() 72 | for row in app.db.data { 73 | mut sb := strings.new_builder(row.vals.len) 74 | for i in 0 .. row.vals.len { 75 | sb.write_string(app.col_with_padding(row.vals[i], i)) 76 | } 77 | app.result.data << sb.str() 78 | } 79 | 80 | if sql_query.to_lower().starts_with('drop') || sql_query.to_lower().starts_with('create') { 81 | app.load_table_list() 82 | } 83 | 84 | app.db.has_next_page = if app.db.data.len == max_per_page { true } else { false } 85 | 86 | app.sql_statement = '${sql_query}' 87 | app.cursor_location.set_line_end(mut app) 88 | app.redraw = true 89 | } 90 | 91 | fn enforce_limit(mut app App, sql_query string) string { 92 | mut parts := sql_query.to_lower().trim(';').split(' ') 93 | 94 | // Only apply limit to select queries 95 | if parts.index('select') != 0 { 96 | return sql_query 97 | } 98 | 99 | has_limit_index := parts.index('limit') 100 | has_limit := if parts.len > has_limit_index + 1 { true } else { false } 101 | has_offset_index := parts.index('offset') 102 | has_offset := if parts.len > has_offset_index + 1 { true } else { false } 103 | 104 | if has_limit_index > 0 || has_offset_index > 0 { 105 | app.db.custom_pagination = true 106 | return sql_query 107 | } 108 | 109 | app.db.custom_pagination = false 110 | 111 | // LIMIT 112 | if has_limit && has_limit_index >= 0 && parts[has_limit_index + 1].int() > max_per_page { 113 | parts[has_limit_index + 1] = max_per_page.str() 114 | } else if has_limit_index == -1 { 115 | parts << 'limit ${max_per_page}' 116 | } 117 | 118 | // OFFSET 119 | if has_offset_index >= 0 && !has_offset { 120 | parts[has_offset_index + 1] = '${app.db.page_offset * max_per_page}' 121 | } else if has_offset_index == -1 { 122 | parts << 'offset ${(app.db.page_offset * max_per_page)}' 123 | } 124 | 125 | return parts.join(' ') 126 | } 127 | 128 | fn get_query_columns(mut app App, sql_query string) { 129 | app.db.table_cols = [] 130 | 131 | if sql_query.to_lower().starts_with('select * from') { 132 | // They want the whole table, work out the table name to get all columns 133 | s := sql_query.split(' ') 134 | if s.len >= 3 { 135 | get_table_columns(mut app, s[3].trim(';')) 136 | } 137 | } else if sql_query.to_lower().starts_with('select ') { 138 | // NOTE: this is just a simple split of any words separated by ',' 139 | // between SELECT & FROM - will only work with simple queries 140 | from_idx := sql_query.to_lower().index('from') or { 141 | app.error = 'Invalid SQL: no FROM clause' 142 | return 143 | } 144 | all_cols := sql_query.substr('select'.len, from_idx) 145 | cols := all_cols.split(',') 146 | 147 | for col in cols { 148 | alias := col.split(' as ') 149 | col_name := if alias.len == 2 { 150 | alias[1] 151 | } else { 152 | col 153 | } 154 | 155 | c := Column{ 156 | name: col_name.trim_space() 157 | datatype: 'text' 158 | notnull: false 159 | pk: false 160 | } 161 | 162 | app.db.table_cols << c 163 | } 164 | } 165 | } 166 | 167 | fn get_table_columns(mut app App, table_name string) { 168 | mut conn := sqlite.connect(app.db.path) or { panic(err) } 169 | defer { 170 | conn.close() or { panic(err) } 171 | } 172 | 173 | data := conn.exec('PRAGMA table_info(${table_name})') or { 174 | app.error = err.msg() 175 | return 176 | } 177 | for row in data { 178 | col := Column{ 179 | name: row.vals[1] 180 | datatype: row.vals[2] 181 | notnull: row.vals[3] == '1' 182 | pk: row.vals[5] == '1' 183 | } 184 | app.db.table_cols << col 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/draw.v: -------------------------------------------------------------------------------- 1 | module main 2 | 3 | import math 4 | 5 | const min_padding = 2 6 | 7 | const box = { 8 | 'top_right': '╮' 9 | 'top_left': '╭' 10 | 'bottom_right': '╯' 11 | 'bottom_left': '╰' 12 | 'horizontal': '─' 13 | 'vertical': '│' 14 | } 15 | 16 | struct Cursor { 17 | mut: 18 | x int 19 | y int 20 | } 21 | 22 | fn (mut c Cursor) set(x int, y int) { 23 | c.x = x 24 | c.y = y 25 | } 26 | 27 | fn (mut c Cursor) move(dir Direction) { 28 | match dir { 29 | .left { 30 | c.x-- 31 | } 32 | .right { 33 | c.x++ 34 | } 35 | else {} 36 | } 37 | } 38 | 39 | fn (mut c Cursor) set_line_end(mut app App) { 40 | c.set(layout['sql']['input_x'] + app.sql_statement.len, layout['sql']['input_y']) 41 | } 42 | 43 | fn (mut app App) draw_box(x1 int, y1 int, x2 int, y2 int, title string) { 44 | width := x2 - x1 45 | bar := box['horizontal'].repeat(width - 2) 46 | // HEADER 47 | app.tui.draw_text(x1, y1, '${box['top_left']}${bar}${box['top_right']}') 48 | // BODY 49 | for i in y1 + 1 .. y2 { 50 | app.tui.draw_text(x1, i, box['vertical']) 51 | app.tui.draw_text(x2 - 1, i, box['vertical']) 52 | } 53 | // FOOTER 54 | app.tui.draw_text(x1, y2, '${box['bottom_left']}${bar}${box['bottom_right']}') 55 | // TITLE 56 | if title.len > 0 { 57 | app.tui.draw_text(x1 + 1, y1, title) 58 | } 59 | } 60 | 61 | fn (mut app App) draw_table_list() { 62 | app.table_list.cols.visible_max = (layout['table_list']['x2'] - layout['table_list']['x1']) - 2 63 | app.table_list.rows.visible_max = (app.tui.window_height - layout['table_list']['y1']) - 2 64 | 65 | app.table_list.calc_visible_data() 66 | 67 | mut y := 0 68 | for i, table in app.table_list.rows.visible_data { 69 | if app.table_list.rows.current_index == app.table_list.rows.visible_start + i { 70 | app.tui.reset() 71 | app.tui.set_color(blue) 72 | app.tui.bold() 73 | if app.table_list.rows.hover_index == i { 74 | app.tui.set_color(black) 75 | app.tui.set_bg_color(blue) 76 | } 77 | app.tui.draw_text(layout['table_list']['x1'] + 1, y + layout['table_list']['y1'] + 1, 78 | '»${table}') 79 | } else if app.table_list.rows.hover_index == app.table_list.rows.visible_start + i { 80 | app.tui.reset() 81 | app.tui.set_bg_color(grey) 82 | app.tui.set_color(white) 83 | app.tui.draw_text(layout['table_list']['x1'] + 1, y + 2, '›${table}') 84 | } else { 85 | app.tui.reset() 86 | app.tui.draw_text(layout['table_list']['x1'] + 1, y + 2, '∙${table}') 87 | } 88 | y++ 89 | } 90 | } 91 | 92 | fn (mut app App) draw_sql() { 93 | app.tui.reset() 94 | app.tui.draw_text(layout['sql']['input_x'], layout['sql']['input_y'], app.sql_statement) 95 | if app.active_view == .sql_view { 96 | app.tui.show_cursor() 97 | } 98 | } 99 | 100 | fn (mut app App) draw_result() { 101 | app.result.cols.visible_max = (app.tui.window_width - layout['result']['x1']) - 2 102 | app.result.rows.visible_max = (app.tui.window_height - layout['result']['row_start_y']) - 1 103 | 104 | app.result.calc_visible_data() 105 | 106 | // TABLE HEADINGS 107 | x, y := layout['result']['heading_x'], layout['result']['heading_y'] 108 | app.tui.reset() 109 | app.tui.bold() 110 | 111 | mut col_data := '' 112 | for i, col in app.db.table_cols { 113 | col_data += app.col_with_padding(col.name.to_upper(), i) 114 | } 115 | 116 | // allow for horizontal scrolling if needed 117 | col_str := if col_data.len > app.result.cols.visible_start + app.result.cols.visible_max { 118 | substr_with_runes(col_data, app.result.cols.visible_start, app.result.cols.visible_start + 119 | app.result.cols.visible_max) 120 | } else if col_data.len > app.result.cols.visible_max 121 | && app.result.cols.visible_start < col_data.len - 1 { 122 | substr_with_runes(col_data, app.result.cols.visible_start, col_data.len) 123 | } else { 124 | col_data 125 | } 126 | 127 | app.tui.draw_text(x, y, col_str) 128 | 129 | // TABLE ROWS 130 | if app.result.rows.visible_data.len == 0 { 131 | draw_branding(mut app) 132 | } else { 133 | app.tui.reset() 134 | app.tui.set_color(grey) 135 | row_start_x := layout['result']['row_start_x'] 136 | row_start_y := layout['result']['row_start_y'] 137 | 138 | for n, row in app.result.rows.visible_data { 139 | app.tui.reset() 140 | if app.result.rows.hover_index == n + app.result.rows.visible_start { 141 | app.tui.set_color(black) 142 | app.tui.set_bg_color(blue) 143 | } 144 | app.tui.draw_text(row_start_x, row_start_y + n, row) 145 | } 146 | } 147 | } 148 | 149 | fn (mut app App) draw_info_footer() { 150 | db_name := 'DB: ${app.db.path}' 151 | 152 | mut row_status := '' 153 | if app.active_view == .result { 154 | row := (app.db.page_offset * max_per_page) + app.result.rows.hover_index + 1 155 | mut total := '${(app.db.page_offset * max_per_page) + app.result.data.len}' 156 | if app.db.has_next_page { 157 | total = '${total}+' 158 | } 159 | row_status = '(Row: ${row}/${total})' 160 | } 161 | 162 | is_windows := $if windows { true } $else { false } 163 | mut instructions := '[TAB: Next Panel] ' 164 | instructions += if app.active_view == .table_list { 165 | '[ENTER: Load Table]' 166 | } else if app.active_view == .sql_view && !is_windows { 167 | '[ENTER: Execute SQL Ctrl-K: Clear]' 168 | } else if app.active_view == .sql_view && is_windows { 169 | '[ENTER: Execute SQL]' 170 | } else if !app.db.custom_pagination { 171 | '[F4: Prev Page F5: Next Page]' 172 | } else { 173 | '' 174 | } 175 | instructions += ' [ESC: Quit]' 176 | 177 | version := 'v1.1' 178 | 179 | padding_size := math.max((app.tui.window_width - instructions.len) / 2, min_padding) 180 | 181 | // DB NAME 182 | app.tui.reset() 183 | app.tui.draw_text(1, app.tui.window_height, '${db_name}') 184 | 185 | // ERROR 186 | if app.error.len > 0 { 187 | app.tui.set_bg_color(red) 188 | app.tui.draw_text(db_name.len + 3, app.tui.window_height, '<${app.error}>') 189 | } 190 | 191 | // INSTRUCTIONS 192 | app.tui.set_bg_color(grey) 193 | app.tui.set_color(black) 194 | app.tui.draw_text(padding_size, app.tui.window_height, '${instructions}') 195 | 196 | app.tui.reset() 197 | 198 | // ROW STATUS 199 | app.tui.draw_text((app.tui.window_width / 2) + padding_size, app.tui.window_height, 200 | '${row_status}') 201 | 202 | // VERSION 203 | app.tui.draw_text(app.tui.window_width - version.len, app.tui.window_height, '${version}') 204 | } 205 | 206 | fn draw_branding(mut app App) { 207 | app.tui.reset() 208 | app.tui.set_color(blue) 209 | app.tui.draw_text(app.tui.window_width - 40, app.tui.window_height - 7, '███████╗███████╗ ██████╗ ██╗') 210 | app.tui.draw_text(app.tui.window_width - 40, app.tui.window_height - 6, '╚══███╔╝██╔════╝██╔═══██╗██║ ██╗') 211 | app.tui.draw_text(app.tui.window_width - 40, app.tui.window_height - 5, ' ███╔╝ █████╗ ██║ ██║██║ ██████╗') 212 | app.tui.draw_text(app.tui.window_width - 40, app.tui.window_height - 4, ' ███╔╝ ██╔══╝ ██║▄▄ ██║██║ ╚═██╔═╝') 213 | app.tui.draw_text(app.tui.window_width - 40, app.tui.window_height - 3, '███████╗███████╗╚██████╔╝███████╗ ╚═╝') 214 | app.tui.draw_text(app.tui.window_width - 40, app.tui.window_height - 2, '╚══════╝╚══════╝ ╚══▀▀═╝ ╚══════╝') 215 | } 216 | --------------------------------------------------------------------------------