From 1620f53fabb31b88ab169d1c6f6da2f4c5506531 Mon Sep 17 00:00:00 2001 From: "Will.Chou" Date: Tue, 8 Jul 2025 09:07:57 +1200 Subject: [PATCH] Page support, default 100 records per page --- Cargo.lock | 34 ++++++- Cargo.toml | 3 +- src/app.rs | 36 ++++--- src/ui.rs | 4 +- src/ui/help_view.rs | 6 +- src/ui/{talbe_view.rs => table_view.rs} | 129 +++++++++++++++++++----- 6 files changed, 167 insertions(+), 45 deletions(-) rename src/ui/{talbe_view.rs => table_view.rs} (74%) diff --git a/Cargo.lock b/Cargo.lock index d55ef4d..12c385c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -648,6 +648,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -673,6 +682,7 @@ dependencies = [ "crossterm", "ratatui", "rusqlite", + "sqlformat", "sqlparser", "strum", ] @@ -701,6 +711,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad8935b44e7c13394a179a438e0cebba0fe08fe01b54f152e29a93b5cf993fd4" dependencies = [ + "cc", "pkg-config", "vcpkg", ] @@ -1052,7 +1063,7 @@ dependencies = [ "crossterm", "indoc", "instability", - "itertools", + "itertools 0.13.0", "lru", "paste", "serde", @@ -1219,6 +1230,17 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "sqlformat" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4b7922be017ee70900be125523f38bdd644f4f06a1b16e8fa5a8ee8c34bffd4" +dependencies = [ + "itertools 0.10.5", + "nom", + "unicode_categories", +] + [[package]] name = "sqlparser" version = "0.54.0" @@ -1298,7 +1320,7 @@ dependencies = [ "getrandom", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1414,7 +1436,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ - "itertools", + "itertools 0.13.0", "unicode-segmentation", "unicode-width 0.1.14", ] @@ -1431,6 +1453,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "utf8parse" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 7f6f640..d13006f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ clap = { version = "4.5.27" , features = ["derive"]} color-eyre = "0.6.3" crossterm = "0.28.1" ratatui = { version = "0.29.0", features = ["serde"] } -rusqlite = "0.33.0" +rusqlite = { version = "0.33.0", features = ["bundled"] } sqlparser = "0.54.0" +sqlformat = "0.1" strum = "0.26.3" diff --git a/src/app.rs b/src/app.rs index 1c26eea..b7a422c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -39,27 +39,39 @@ impl App { Ok(()) } - /// Select * from a given Table, Returns (Vec Column Names, Vec Row Data) + /// Select a page of rows from a given Table. pub fn select( &self, table: &Table, + limit: usize, + offset: usize, ) -> Result<(Vec, Vec>), rusqlite::Error> { - let sql = format!("SELECT * FROM {};", table.name); + let sql = format!( + "SELECT * FROM {} LIMIT {} OFFSET {};", + table.name, limit, offset + ); if let Some(db) = &self.current_db { let con = Connection::open(&db.path)?; let mut stmt = con.prepare(&sql)?; + let num_cols = stmt.column_names().len(); + let rows: Vec<_> = stmt + .query_map([], |row| map_row(num_cols, row))? + .collect::>()?; + let cols = stmt.column_names().iter().map(|s| s.to_string()).collect(); + return Ok((cols, rows)); + } + Ok((Vec::new(), Vec::new())) + } - let num_of_columns = stmt.column_names().len(); - let data: Vec> = stmt - .query_map([], |row| map_row(num_of_columns, row))? - .map(|x| (x.unwrap_or_default())) - .collect(); - return Ok(( - stmt.column_names().iter().map(|x| x.to_string()).collect(), - data, - )); + pub fn prepare_total_rows(&self, table: &Table) -> Result { + let count_sql = format!("SELECT COUNT(*) FROM {};", table.name); + + if let Some(db) = &self.current_db { + let total = Connection::open(&db.path)?.query_row(&count_sql, [], |r| r.get(0))?; + Ok(total) + } else { + Ok(0) } - Ok((Vec::default(), Vec::default())) } } diff --git a/src/ui.rs b/src/ui.rs index 18c059c..3b77c91 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,13 +10,13 @@ use ratatui::{ }; use std::io; use string_list::StringList; -use talbe_view::TableView; +use table_view::TableView; pub mod colors; pub mod file_menu; pub mod help_view; pub mod string_list; -pub mod talbe_view; +pub mod table_view; pub mod utils; const APP_NAME: &str = " JDbrowser "; diff --git a/src/ui/help_view.rs b/src/ui/help_view.rs index 6483b9e..74dfcc2 100644 --- a/src/ui/help_view.rs +++ b/src/ui/help_view.rs @@ -22,7 +22,7 @@ const NAV_LIST_KEYS: [[&str; 2]; 3] = [ ]; const TABLE_VIEW_TITLE: &str = " Table View "; -const TABLE_KEYS: [[&str; 2]; 8] = [ +const TABLE_KEYS: [[&str; 2]; 10] = [ ["View Schema - Browse Data", "SHIFT + h - l"], ["Page Up Half", "u"], ["Page Down Half", "d"], @@ -31,6 +31,8 @@ const TABLE_KEYS: [[&str; 2]; 8] = [ ["Move Cell Left", "h"], ["Move Cell Right", "l"], ["Yank Cell to Clipboard", "y"], + ["Prev Page", "p"], + ["Next Page", "n"], ]; const GENERAL_TITLE: &str = " General "; @@ -48,7 +50,7 @@ pub fn draw_help_window(frame: &mut Frame, lay: Rect) { frame.render_widget(background, lay); let area = center(lay, Constraint::Length(60), Constraint::Length(60)); - let split_area = Layout::vertical(Constraint::from_lengths([5, 5, 10, 4])) + let split_area = Layout::vertical(Constraint::from_lengths([5, 5, 12, 4])) .margin(2) .split(area); let widths = Constraint::from_lengths([40, 14]); diff --git a/src/ui/talbe_view.rs b/src/ui/table_view.rs similarity index 74% rename from src/ui/talbe_view.rs rename to src/ui/table_view.rs index 1af9f61..52e4a2c 100644 --- a/src/ui/talbe_view.rs +++ b/src/ui/table_view.rs @@ -17,6 +17,7 @@ use ratatui::{ }, Frame, }; +use sqlformat::{format, FormatOptions, Indent, QueryParams}; use strum::{Display, EnumIter, IntoEnumIterator}; #[derive(Clone, Copy, Default, Debug, Display, EnumIter)] @@ -125,7 +126,10 @@ pub struct TableView { pub data: (Vec, Vec>), pub table_state: TableState, table_scroll_height: u16, - clipboard: Clipboard, + clipboard: Option, + page_size: usize, + offset: usize, + pub total_rows: Option, } impl Default for TableView { @@ -138,7 +142,10 @@ impl Default for TableView { data: (Vec::default(), Vec::default()), table_state: TableState::default(), table_scroll_height: 0, - clipboard: Clipboard::new().unwrap(), + clipboard: Clipboard::new().ok(), + page_size: 50, + offset: 0, + total_rows: None, } } } @@ -229,17 +236,68 @@ impl TableView { let lay = Layout::vertical([Constraint::Fill(1)]) .margin(margin) .split(r); + /* let p = Paragraph::new(table.sql.trim()) .wrap(Wrap { trim: true }) .fg(TEXT_COLOR); frame.render_widget(p, lay[0]); + */ + + let raw_sql = table.sql.trim(); + + // build your options + let opts = FormatOptions { + indent: Indent::Spaces(4), // 4‑space indent + uppercase: false, // keep keywords lower‑case + lines_between_queries: 1, // default + }; + + // call the formatter (no Dialect parameter) + let formatted = format(raw_sql, &QueryParams::None, opts); + + // then render `formatted` instead of `raw` + let p = Paragraph::new(formatted) + .wrap(Wrap { trim: false }) + .fg(TEXT_COLOR); + frame.render_widget(p, lay[0]); } SelectedTableTab::Browse => { - let lay = Layout::vertical([Constraint::Fill(1), Constraint::Length(3)]) - .margin(margin) - .split(r); + // new: table, then preview, then footer for page‑info + let lay = Layout::vertical([ + Constraint::Fill(1), // table takes all leftover space + Constraint::Length(3), // preview box height + Constraint::Length(1), // exactly one row for page info + ]) + .margin(margin) + .split(r); + + // draw the data table self.draw_table(frame, lay[0], table.name.as_str()); + + // draw the preview inside a bordered box self.draw_preview(frame, lay[1]); + + // draw the page‑info *below* the preview + if let Some(total) = self.total_rows { + let page = (self.offset / self.page_size) + 1; + let last_page = ((total + self.page_size - 1) / self.page_size).max(1); + let start = self.offset + 1; + let end = (self.offset + self.page_size).min(total); + let info = format!( + "displaying records {start}-{end} of {total} (page {page}/{last_page})" + ); + + use ratatui::layout::Alignment; + use ratatui::widgets::Paragraph; + + // render it centered/right‑aligned in that one‑row footer + frame.render_widget( + Paragraph::new(info) + .alignment(Alignment::Right) + .wrap(ratatui::widgets::Wrap { trim: true }), + lay[2], + ); + } } } } @@ -332,6 +390,21 @@ impl TableView { } else if key.code == KeyCode::Char('y') { self.yank_cell()?; return Ok(()); + } else if key.code == KeyCode::Char('n') { + // next page: only if there’s more data + if let Some(total) = self.total_rows { + let next_off = self.offset + self.page_size; + if next_off < total { + self.offset = next_off; + } + } + } else if key.code == KeyCode::Char('p') { + // prev page: never go below zero + if self.offset >= self.page_size { + self.offset -= self.page_size; + } else { + self.offset = 0; + } } self.load_table_data(app, db)?; } @@ -342,7 +415,11 @@ impl TableView { Ok(if let Some((x, y)) = self.table_state.selected_cell() { if let Some(row) = self.data.1.get(x) { if let Some(val) = row.get(y) { - self.clipboard.set_text(val)?; + if let Some(cb) = &mut self.clipboard { + if let Err(e) = cb.set_text(val) { + eprintln!("Clipboard warning: {}", e); + } + } return Ok(()); } } @@ -350,28 +427,30 @@ impl TableView { } fn load_table_data(&mut self, app: &App, db: &Db) -> Result<(), Box> { + // always reset the cursor to top-left of the page self.table_state.select_cell(Some((0, 0))); + if self.selected_table_tab as usize == SelectedTableTab::Browse as usize { - match self.table_nav_tab { - NavigationTab::Tables => { - if let Some(table) = db - .tables - .iter() - .find(|x| x.name == self.tables_list.get_selected().unwrap_or("")) - { - self.data = app.select(table)?; - } - } - NavigationTab::Views => { - if let Some(table) = db - .views - .iter() - .find(|x| x.name == self.view_list.get_selected().unwrap_or("")) - { - self.data = app.select(table)?; - } - } + // pick the currently selected Table or View + let maybe_table = match self.table_nav_tab { + NavigationTab::Tables => db + .tables + .iter() + .find(|t| t.name == self.tables_list.get_selected().unwrap_or("")), + NavigationTab::Views => db + .views + .iter() + .find(|v| v.name == self.view_list.get_selected().unwrap_or("")), }; + + if let Some(table) = maybe_table { + // load just one page of data + let (cols, rows) = app.select(table, self.page_size, self.offset)?; + self.data = (cols, rows); + + // now fetch and remember the grand total + self.total_rows = Some(app.prepare_total_rows(table)?); + } } Ok(()) }