From 23ac4f39372a71afff0e6542c85467b8a9ba6f56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Ho=C3=A0ng=20Hi=E1=BB=87p?= Date: Mon, 25 May 2026 20:38:27 +0700 Subject: [PATCH 01/10] feat: replace hardcoded colors with theme-based styling - Add render_tr method for stripe styling using theme's table_even color instead of table-level stripe config - Migrate hardcoded colors to theme tokens: danger_foreground for deleted rows, warning for edited cells background - Add warning.background to theme with yellow color (#fff085) - Add mono_font_family to data table container and configure row_header(false) in TableState - Refactor cell rendering to use outer/inner div pattern for consistent styling structure - Update connection window to use flex_grow() instead of flex_1() and add text_ellipsis_start() stub --- desktop/src/main.rs | 2 +- .../shared/smart_data_grid/grid_delegate.rs | 46 +++++++++++-------- .../shared/smart_data_grid/smart_data_grid.rs | 3 +- desktop/src/window/connection_window.rs | 10 ++-- themes/truyvansql.json | 1 + 5 files changed, 38 insertions(+), 24 deletions(-) diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 660a68d..803a48a 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -50,7 +50,7 @@ async fn main() { height: px(480.0), }), titlebar: Some(TitlebarOptions { - title: None, + title: Some("TruyVanSQL".into()), appears_transparent: true, traffic_light_position: Some(point(px(9.0), px(9.0))), }), diff --git a/desktop/src/shared/smart_data_grid/grid_delegate.rs b/desktop/src/shared/smart_data_grid/grid_delegate.rs index c3303e1..3f0f137 100644 --- a/desktop/src/shared/smart_data_grid/grid_delegate.rs +++ b/desktop/src/shared/smart_data_grid/grid_delegate.rs @@ -3,6 +3,7 @@ use crate::shared::smart_data_grid::EditingState; use super::grid_state::GridState; use gpui::prelude::FluentBuilder; use gpui::*; +use gpui_component::ActiveTheme; use gpui_component::input::Input; use gpui_component::input::InputState; use gpui_component::table::{Column as GpuiColumn, TableDelegate, TableState}; @@ -49,12 +50,24 @@ impl TableDelegate for GridDelegate { self.cached_columns[col_ix].clone() } + fn render_tr( + &mut self, + row_ix: usize, + _window: &mut Window, + cx: &mut Context>, + ) -> Stateful
{ + let is_stripe = row_ix % 2 != 0; + div() + .id(("row", row_ix)) + .when(is_stripe, |this| this.bg(cx.theme().table_even)) + } + fn render_td( &mut self, row_ix: usize, col_ix: usize, _window: &mut Window, - _cx: &mut Context>, + cx: &mut Context>, ) -> impl IntoElement { if let Some(EditingState { row, @@ -98,27 +111,24 @@ impl TableDelegate for GridDelegate { .unwrap_or_else(|| "".into()) }; + let outer = div().p_neg_2().w_full().h_full().flex().items_center(); + + let mut inner = div() + .size_full() + .border_r_1() + .border_color(cx.theme().border); + if is_deleted { - div() - .p_neg_2() - .w_full() - .h_full() - .flex() - .items_center() - .child(div().line_through().text_color(gpui::red()).child(text)) - .into_any_element() + inner = inner + .line_through() + .text_color(cx.theme().danger_foreground); } else if is_edited { - div() - .p_neg_2() - .w_full() - .h_full() - .flex() - .items_center() - .child(div().size_full().p_2().bg(gpui::rgb(0xfff085)).child(text)) - .into_any_element() + inner = inner.p_2().bg(cx.theme().warning); } else { - text.into_any_element() + inner = inner.p_2(); } + + outer.child(inner.child(text)).into_any_element() } fn render_empty( diff --git a/desktop/src/shared/smart_data_grid/smart_data_grid.rs b/desktop/src/shared/smart_data_grid/smart_data_grid.rs index a015640..edb9ed7 100644 --- a/desktop/src/shared/smart_data_grid/smart_data_grid.rs +++ b/desktop/src/shared/smart_data_grid/smart_data_grid.rs @@ -39,6 +39,7 @@ impl SmartDataGrid { let delegate = GridDelegate::new(GridState::new(), cell_editor.clone()); let table = cx.new(|cx| { TableState::new(delegate, window, cx) + .row_header(false) .cell_selectable(true) .row_selectable(true) }); @@ -440,9 +441,9 @@ impl Render for SmartDataGrid { .min_w_0() .min_h_0() .overflow_hidden() + .font_family(cx.theme().mono_font_family.clone()) .child( DataTable::new(&self.table) - .stripe(true) .bordered(false) .scrollbar_visible(true, true), ), diff --git a/desktop/src/window/connection_window.rs b/desktop/src/window/connection_window.rs index 6c8243e..cc91735 100644 --- a/desktop/src/window/connection_window.rs +++ b/desktop/src/window/connection_window.rs @@ -469,16 +469,18 @@ impl ConnectionWindow { .gap_2() .flex_1() .child( - h_flex() + div() .flex_1() - .border_1() + .min_w_0() .h_8() + .border_1() + .py_1() .px_3() - .overflow_x_scrollbar() - .items_center() + .items_baseline() .border_color(cx.theme().border) .rounded(cx.theme().radius) .text_sm() + .text_ellipsis_start() .when(self.selected_path.is_none(), |this| { this.text_color(cx.theme().muted_foreground) }) diff --git a/themes/truyvansql.json b/themes/truyvansql.json index 963ef5f..9f2aa5b 100644 --- a/themes/truyvansql.json +++ b/themes/truyvansql.json @@ -17,6 +17,7 @@ "ring": "#0060de", "danger.foreground": "#ffffff", "danger.background": "#E7000B", + "warning.background": "#fff085", "sidebar.background": "#FAFAFA00", "list.active.background": "#0060de15", "list.active.border": "#0060de", From a77734a9a080760b38b53b9fbe6bf500c963ad4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Ho=C3=A0ng=20Hi=E1=BB=87p?= Date: Tue, 26 May 2026 09:34:13 +0700 Subject: [PATCH 02/10] feat(ui): enhance tab component with loading states and error handling - Add loading spinner and close button support to Tab component; loading uses Spinner from gpui-component - Extract close button logic from TabBar into Tab with on_close callback - Add error state field to GridState and wire up TableViewerTab error handling - Add error view to SmartDataGrid with warning icon when errors occur - Add triangle-warning-fill.svg icon asset - Update danger.foreground color in theme to warning yellow (#F0B100) --- assets/icons/triangle-warning-fill.svg | 4 + desktop/src/component/tab.rs | 156 ++++++++---------- desktop/src/panel/tab/tab_bar.rs | 28 +--- desktop/src/panel/tab/tab_item.rs | 5 +- .../tab_content/sql_editor/sql_editor_tab.rs | 3 +- .../table_viewer/table_viewer_tab.rs | 17 +- .../src/shared/smart_data_grid/grid_state.rs | 2 + .../shared/smart_data_grid/smart_data_grid.rs | 46 +++++- themes/truyvansql.json | 2 +- 9 files changed, 140 insertions(+), 123 deletions(-) create mode 100644 assets/icons/triangle-warning-fill.svg diff --git a/assets/icons/triangle-warning-fill.svg b/assets/icons/triangle-warning-fill.svg new file mode 100644 index 0000000..4143324 --- /dev/null +++ b/assets/icons/triangle-warning-fill.svg @@ -0,0 +1,4 @@ + + + + diff --git a/desktop/src/component/tab.rs b/desktop/src/component/tab.rs index ddd5b46..d131b82 100644 --- a/desktop/src/component/tab.rs +++ b/desktop/src/component/tab.rs @@ -1,51 +1,15 @@ use std::rc::Rc; +use assets::AppIcon; use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, App, ClickEvent, Div, Edges, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px, relative, }; -use gpui_component::{ActiveTheme, Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum TabVariant { - Tab, -} - -impl TabVariant { - fn height(self, size: Size) -> Pixels { - match size { - Size::XSmall => px(20.), - Size::Small => px(24.), - Size::Large => px(36.), - _ => px(32.), - } - } - - pub(super) fn inner_height(self, size: Size) -> Pixels { - match size { - Size::XSmall => px(18.), - Size::Small => px(22.), - Size::Large => px(36.), - _ => px(30.), - } - } - - fn inner_paddings(self, size: Size) -> Edges { - let padding_x = match size { - Size::XSmall => px(8.), - Size::Small => px(10.), - Size::Large => px(16.), - _ => px(12.), - }; - Edges { - left: padding_x, - right: padding_x, - ..Default::default() - } - } -} +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::spinner::Spinner; +use gpui_component::{ActiveTheme, Icon, IconName, Selectable, Sizable}; #[allow(dead_code)] struct TabStyle { @@ -132,12 +96,14 @@ pub struct Tab { non_border_l: Option, suffix: Option, children: Vec, - size: Size, disabled: bool, selected: bool, dirtied: bool, + loading: bool, + close_button: bool, indicator_active: bool, on_click: Option>, + on_close: Option>, } impl From<&'static str> for Tab { @@ -181,12 +147,14 @@ impl Default for Tab { non_border_l: None, suffix: None, children: Vec::new(), - size: Size::default(), disabled: false, selected: false, dirtied: false, + loading: false, + close_button: false, indicator_active: false, on_click: None, + on_close: None, } } } @@ -226,6 +194,24 @@ impl Tab { self } + pub fn loading(mut self, loading: bool) -> Self { + self.loading = loading; + self + } + + pub fn close_button(mut self, close_button: bool) -> Self { + self.close_button = close_button; + self + } + + pub fn on_close( + mut self, + on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_close = Some(Rc::new(on_close)); + self + } + pub fn on_click( mut self, on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -281,13 +267,6 @@ impl Styled for Tab { } } -impl Sizable for Tab { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - impl RenderOnce for Tab { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let tab_style = if self.disabled { @@ -321,25 +300,29 @@ impl RenderOnce for Tab { ) }; - let inner_height = TabVariant::Tab.inner_height(self.size); - let inner_paddings = TabVariant::Tab.inner_paddings(self.size); - let height = TabVariant::Tab.height(self.size); + let icon_element = if self.loading { + Some(Spinner::new().with_size(px(14.)).into_any_element()) + } else { + self.icon + .map(|icon| icon.with_size(px(14.)).into_any_element()) + }; self.base .id(self.ix) + .relative() .flex() - .flex_wrap() .gap_1() .items_center() + .justify_center() .flex_shrink_0() - .h(height) + .h_8() + .pl_5() + .when_else(self.close_button, |this| this.pr_7(), |this| this.pr_5()) + .line_height(relative(1.)) + .whitespace_nowrap() .overflow_hidden() .text_color(tab_style.fg) - .map(|this| match self.size { - Size::XSmall => this.text_xs(), - Size::Large => this.text_base(), - _ => this.text_sm(), - }) + .text_sm() .bg(tab_style.bg) .border_l(borders_left) .border_r(tab_style.borders.right) @@ -355,34 +338,35 @@ impl RenderOnce for Tab { }) }) .when_some(self.prefix, |this, prefix| this.child(prefix)) - .child( - h_flex() - .flex_1() - .ml_2() - .relative() - .h(inner_height) - .line_height(relative(1.)) - .whitespace_nowrap() - .items_center() - .justify_center() - .overflow_hidden() - .gap_1() - .flex_shrink_0() - .paddings(inner_paddings) - .when(self.dirtied, |this| { - this.child( - div() - .size_1p5() - .rounded_full() - .bg(cx.theme().blue) - .absolute() - .left_0(), - ) - }) - .when_some(self.icon, |this, icon| this.child(icon).mb_neg_1()) - .when_some(self.label, |this, label| this.child(label)), - ) + .when(self.dirtied, |this| { + this.child( + div() + .absolute() + .left_2() + .size_1p5() + .rounded_full() + .bg(cx.theme().blue), + ) + }) + .when_some(icon_element, |this, icon| this.child(icon)) + .when_some(self.label, |this, label| { + this.child(div().child(label).pb_0p5()) + }) .when_some(self.suffix, |this, suffix| this.child(suffix)) + .when(self.close_button, |this| { + this.child( + Button::new(format!("close-tab-{}", self.ix)) + .absolute() + .right_1() + .ghost() + .xsmall() + .cursor_pointer() + .icon(AppIcon::X) + .when_some(self.on_close.clone(), |this, on_close| { + this.on_click(move |event, window, cx| on_close(event, window, cx)) + }), + ) + }) .when(!self.disabled, |this| { this.when_some(self.on_click.clone(), |this, on_click| { this.on_click(move |event, window, cx| on_click(event, window, cx)) diff --git a/desktop/src/panel/tab/tab_bar.rs b/desktop/src/panel/tab/tab_bar.rs index 71d50b3..ec6bd18 100644 --- a/desktop/src/panel/tab/tab_bar.rs +++ b/desktop/src/panel/tab/tab_bar.rs @@ -1,12 +1,9 @@ use gpui::prelude::*; use gpui::*; use gpui_component::ActiveTheme; -use gpui_component::Sizable; -use gpui_component::button::{Button, ButtonVariants}; use gpui_component::h_flex; use crate::component::tab::Tab; -use assets::AppIcon; use crate::panel::tab::TabManager; @@ -57,6 +54,7 @@ impl Render for TabBar { let info = tab.info(cx); let tab_title = info.title; let is_dirty = info.is_dirty; + let is_loading = info.is_loading; let icon = info.icon; let is_selected = active_index == Some(i); @@ -80,23 +78,15 @@ impl Render for TabBar { }) .icon(icon) .dirtied(is_dirty) - .suffix( - h_flex().child( - Button::new(format!("close-tab-{}", i)) - .ghost() - .xsmall() - .mr_1() - .cursor_pointer() - .icon(AppIcon::X) - .on_click(move |_e, _window, cx| { - cx.stop_propagation(); - tab_manager_for_close - .update(cx, |service, cx| service.close_tab(i, cx)); - }), - ), - ) + .loading(is_loading) + .close_button(true) + .on_close(move |_, _, cx| { + cx.stop_propagation(); + tab_manager_for_close + .update(cx, |service, cx| service.close_tab(i, cx)); + }) .into_any_element() })), ) } -} \ No newline at end of file +} diff --git a/desktop/src/panel/tab/tab_item.rs b/desktop/src/panel/tab/tab_item.rs index 4d85003..b46ddda 100644 --- a/desktop/src/panel/tab/tab_item.rs +++ b/desktop/src/panel/tab/tab_item.rs @@ -9,6 +9,9 @@ pub struct TabInfo { /// Kiểm tra tab có dữ liệu chưa lưu hay không (hiển thị dấu chấm tròn) pub is_dirty: bool, + /// Tab đang tải dữ liệu (hiển thị spinner loading) + pub is_loading: bool, + /// Icon hiển thị trên thanh Tab (ví dụ: biểu tượng database, biểu tượng file) pub icon: AppIcon, } @@ -21,4 +24,4 @@ pub trait TabItem: Render { /// Trả về tham chiếu Any để TabManager có thể quản lý đa hình (Type Erasure) fn as_any(&self) -> &dyn Any; -} \ No newline at end of file +} diff --git a/desktop/src/panel/tab_content/sql_editor/sql_editor_tab.rs b/desktop/src/panel/tab_content/sql_editor/sql_editor_tab.rs index a30be62..ee23874 100644 --- a/desktop/src/panel/tab_content/sql_editor/sql_editor_tab.rs +++ b/desktop/src/panel/tab_content/sql_editor/sql_editor_tab.rs @@ -54,6 +54,7 @@ impl TabItem for SqlEditorTab { TabInfo { title: format!("SQL: {}", conn_name).into(), is_dirty: false, + is_loading: false, icon: AppIcon::FileSql, } } @@ -73,4 +74,4 @@ impl Render for SqlEditorTab { .child(self.toolbar.clone()) .child(self.results.clone()) } -} \ No newline at end of file +} diff --git a/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs b/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs index 6c19dad..d0cac0a 100644 --- a/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs +++ b/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs @@ -60,7 +60,10 @@ impl TableViewerTab { grid.set_data(columns, rows, cx); grid.set_metadata(Some(table_name.clone()), pks, cx); } else if let Err(e) = result { - eprintln!("TableViewerTab Lỗi: {}", e); + grid.table.update(cx, |table, _cx| { + table.delegate_mut().state.error = Some(e.to_string().into()); + }); + eprintln!("{}", e); } grid.table.update(cx, |table, cx| { @@ -75,16 +78,12 @@ impl TableViewerTab { impl TabItem for TableViewerTab { fn tab_info(&self, cx: &App) -> TabInfo { + let state = &self.grid.read(cx).table.read(cx).delegate().state; + TabInfo { title: self.table_name.clone().into(), - is_dirty: self - .grid - .read(cx) - .table - .read(cx) - .delegate() - .state - .has_pending_changes(), + is_dirty: state.has_pending_changes(), + is_loading: state.is_loading, icon: AppIcon::Table, } } diff --git a/desktop/src/shared/smart_data_grid/grid_state.rs b/desktop/src/shared/smart_data_grid/grid_state.rs index 1ed5429..8ebfaa6 100644 --- a/desktop/src/shared/smart_data_grid/grid_state.rs +++ b/desktop/src/shared/smart_data_grid/grid_state.rs @@ -24,6 +24,7 @@ pub struct GridState { pub limit: usize, pub offset: usize, pub total_rows: Option, + pub error: Option, pub is_loading: bool, pub editing_state: Option, @@ -42,6 +43,7 @@ impl GridState { limit: 1000, offset: 0, total_rows: None, + error: None, is_loading: false, editing_state: None, } diff --git a/desktop/src/shared/smart_data_grid/smart_data_grid.rs b/desktop/src/shared/smart_data_grid/smart_data_grid.rs index edb9ed7..bc8c8d5 100644 --- a/desktop/src/shared/smart_data_grid/smart_data_grid.rs +++ b/desktop/src/shared/smart_data_grid/smart_data_grid.rs @@ -1,11 +1,12 @@ use super::{DataChangesetBuilder, EditingState, GridDelegate, GridState, StageError}; use assets::AppIcon; use engine::{Column, Row, SqlClient}; +use gpui::prelude::FluentBuilder; use gpui::*; use gpui_component::button::{Button, ButtonCustomVariant, ButtonVariants}; use gpui_component::input::InputState; use gpui_component::table::{DataTable, TableDelegate, TableEvent, TableState}; -use gpui_component::{ActiveTheme, Disableable, Icon, h_flex, v_flex}; +use gpui_component::{ActiveTheme, Disableable, Icon, Sizable, h_flex, v_flex}; /// TODO: Nên chuyển cái hàm này sang chỗ khác fn validate_sql_type(text: &str, data_type: &str) -> bool { @@ -424,6 +425,8 @@ impl SmartDataGrid { impl Render for SmartDataGrid { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let state = self.table.read(cx).delegate().state.clone(); + v_flex() .key_context("data-grid-container") .on_action(cx.listener(Self::on_commit_changes)) @@ -442,11 +445,42 @@ impl Render for SmartDataGrid { .min_h_0() .overflow_hidden() .font_family(cx.theme().mono_font_family.clone()) - .child( - DataTable::new(&self.table) - .bordered(false) - .scrollbar_visible(true, true), - ), + // Hiển thị error view + .when_some(state.error.as_ref(), |this, error| { + this.child( + v_flex().size_full().items_center().justify_center().child( + v_flex() + .items_center() + .gap_2() + .max_w_128() + .px_12() + .child( + div() + .line_height(px(24.)) + .text_xl() + .text_color(cx.theme().danger_foreground) + .child( + Icon::new(AppIcon::TriangleWarningFill) + .with_size(px(32.)), + ), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(error.clone()), + ), + ), + ) + }) + // Hiển thị data grid chính + .when(state.error.is_none(), |this| { + this.child( + DataTable::new(&self.table) + .bordered(false) + .scrollbar_visible(true, true), + ) + }), ) } } diff --git a/themes/truyvansql.json b/themes/truyvansql.json index 9f2aa5b..c0472eb 100644 --- a/themes/truyvansql.json +++ b/themes/truyvansql.json @@ -15,7 +15,7 @@ "foreground": "#000000", "border": "#e4e4e7", "ring": "#0060de", - "danger.foreground": "#ffffff", + "danger.foreground": "#F0B100", "danger.background": "#E7000B", "warning.background": "#fff085", "sidebar.background": "#FAFAFA00", From b6ba40969eb3add08f8b8f54e2f96e08f6c6d33c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Ho=C3=A0ng=20Hi=E1=BB=87p?= Date: Tue, 26 May 2026 09:45:55 +0700 Subject: [PATCH 03/10] feat(engine): add insert operation to changeset script generation - Add RowInsert struct with values field to schema module and export it from engine lib - Extend DataChangeset struct to include inserts: Vec field - Implement INSERT statement generation in generate_changeset_script method; for each insert, build column list and values from ColumnData entries and format as "INSERT INTO ... VALUES ..." - Add test case for insert operation in SQLite driver to verify changeset script generation --- engine/src/driver/mod.rs | 21 +++++++++++++++++++++ engine/src/driver/sqlite.rs | 7 +++++++ engine/src/lib.rs | 2 +- engine/src/schema/modification.rs | 6 ++++++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/engine/src/driver/mod.rs b/engine/src/driver/mod.rs index b32f0ef..eebb7e6 100644 --- a/engine/src/driver/mod.rs +++ b/engine/src/driver/mod.rs @@ -117,6 +117,27 @@ pub trait DatabaseDriver: SqlDialect + Send + Sync { )); } + for insert in &changeset.inserts { + let columns = insert + .values + .iter() + .map(|c| self.quote_identifier(&c.column_name)) + .collect::>() + .join(", "); + let values = insert + .values + .iter() + .map(|c| self.format_value(&c.value, &c.data_type)) + .collect::>() + .join(", "); + script.push_str(&format!( + "INSERT INTO {} ({}) VALUES ({});\n", + self.quote_identifier(&changeset.table_name), + columns, + values + )); + } + script } diff --git a/engine/src/driver/sqlite.rs b/engine/src/driver/sqlite.rs index fa7a7fc..23ec73c 100644 --- a/engine/src/driver/sqlite.rs +++ b/engine/src/driver/sqlite.rs @@ -912,6 +912,13 @@ mod tests { data_type: "INTEGER".to_string(), }], }], + inserts: vec![RowInsert { + values: vec![ColumnData { + column_name: "id".to_string(), + value: "3".to_string(), + data_type: "INTEGER".to_string(), + }], + }], }; let script = driver.generate_changeset_script(&changeset); diff --git a/engine/src/lib.rs b/engine/src/lib.rs index 5f07cec..1cecbaa 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -11,6 +11,6 @@ pub use error::EngineError; pub use result::{Column, QueryResult, Row, Value}; pub use schema::{ ColumnData, ColumnInfo, DataChangeset, DatabaseBrief, ForeignKeyInfo, IndexInfo, PrimaryKey, - RowDelete, RowUpdate, SchemaBrief, SchemaKind, TableBrief, TableInfo, TableKind, + RowDelete, RowInsert, RowUpdate, SchemaBrief, SchemaKind, TableBrief, TableInfo, TableKind, }; pub use sql_client::SqlClient; diff --git a/engine/src/schema/modification.rs b/engine/src/schema/modification.rs index 872d11c..3a8e641 100644 --- a/engine/src/schema/modification.rs +++ b/engine/src/schema/modification.rs @@ -16,9 +16,15 @@ pub struct RowDelete { pub pk_conditions: Vec, } +#[derive(Debug, Clone)] +pub struct RowInsert { + pub values: Vec, +} + #[derive(Debug, Clone)] pub struct DataChangeset { pub table_name: String, pub updates: Vec, pub deletes: Vec, + pub inserts: Vec, } From 54c63c18e367d72613b1acf4de69fedbe244c6ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Ho=C3=A0ng=20Hi=E1=BB=87p?= Date: Tue, 26 May 2026 16:10:15 +0700 Subject: [PATCH 04/10] feat(datagrid): implement row add/delete with visual feedback - Add AddRow, DeleteRow, and DiscardChanges actions to datagrid module - Implement pending_inserts handling in DataChangesetBuilder to generate RowInsert entries for new rows - Update GridDelegate rows_count to include pending_inserts and modify cell rendering to show inserted rows with green background and deleted rows with red strikethrough styling - Add GridState helper methods: is_inserted_row, is_deleted_row, insert_index, original_len, total_len, cell_value - Implement on_add_row to append empty row to pending_inserts, on_delete_row to remove inserted rows or toggle pending_deletes for original rows - Fix on_confirm_edit to handle edits on inserted rows by updating pending_inserts directly instead of pending_edits - Wire toolbar buttons (Add, Delete, Discard) to their respective action handlers - Update theme colors with success.background and adjusted danger/warning opacity for better visual distinction --- desktop/src/action.rs | 11 +- .../smart_data_grid/data_changeset_builder.rs | 20 ++- .../shared/smart_data_grid/grid_delegate.rs | 48 ++++-- .../src/shared/smart_data_grid/grid_state.rs | 51 ++++++ .../shared/smart_data_grid/smart_data_grid.rs | 152 ++++++++++++++---- themes/truyvansql.json | 7 +- 6 files changed, 241 insertions(+), 48 deletions(-) diff --git a/desktop/src/action.rs b/desktop/src/action.rs index 579d246..a0f9fc2 100644 --- a/desktop/src/action.rs +++ b/desktop/src/action.rs @@ -17,7 +17,16 @@ pub mod datagrid { use gpui::actions; actions!( grid, - [CopyCell, ConfirmEdit, CancelEdit, StartEdit, CommitChanges] + [ + CopyCell, + ConfirmEdit, + CancelEdit, + StartEdit, + CommitChanges, + AddRow, + DeleteRow, + DiscardChanges, + ] ); } diff --git a/desktop/src/shared/smart_data_grid/data_changeset_builder.rs b/desktop/src/shared/smart_data_grid/data_changeset_builder.rs index 5d7ad85..87cfe88 100644 --- a/desktop/src/shared/smart_data_grid/data_changeset_builder.rs +++ b/desktop/src/shared/smart_data_grid/data_changeset_builder.rs @@ -1,5 +1,5 @@ use crate::shared::smart_data_grid::grid_state::GridState; -use engine::{ColumnData, DataChangeset, RowDelete, RowUpdate}; +use engine::{ColumnData, DataChangeset, RowDelete, RowInsert, RowUpdate}; use std::collections::HashMap; use thiserror::Error; @@ -65,10 +65,28 @@ impl<'a> DataChangesetBuilder<'a> { }); } + let mut inserts = Vec::new(); + for row_values in &self.state.pending_inserts { + let values: Vec = row_values + .iter() + .enumerate() + .map(|(col_ix, value)| { + let col = &self.state.columns[col_ix]; + ColumnData { + column_name: col.name.clone(), + value: value.clone(), + data_type: col.declared_type.clone().unwrap_or_default(), + } + }) + .collect(); + inserts.push(RowInsert { values }); + } + Ok(DataChangeset { table_name, updates, deletes, + inserts, }) } diff --git a/desktop/src/shared/smart_data_grid/grid_delegate.rs b/desktop/src/shared/smart_data_grid/grid_delegate.rs index 3f0f137..91b611f 100644 --- a/desktop/src/shared/smart_data_grid/grid_delegate.rs +++ b/desktop/src/shared/smart_data_grid/grid_delegate.rs @@ -43,7 +43,7 @@ impl TableDelegate for GridDelegate { } fn rows_count(&self, _: &App) -> usize { - self.state.original_rows.len() // Tương lai sẽ cộng thêm dòng insert + self.state.original_rows.len() + self.state.pending_inserts.len() } fn column(&self, col_ix: usize, _: &App) -> GpuiColumn { @@ -56,9 +56,21 @@ impl TableDelegate for GridDelegate { _window: &mut Window, cx: &mut Context>, ) -> Stateful
{ + let is_inserted = self.state.is_inserted_row(row_ix); + let is_deleted = self.state.is_deleted_row(row_ix); let is_stripe = row_ix % 2 != 0; + div() .id(("row", row_ix)) + .relative() + .when(is_deleted, |this| { + this.child(div().absolute().inset_0().bg(cx.theme().danger)) + .line_through() + .text_color(cx.theme().danger_foreground) + }) + .when(is_inserted, |this| { + this.child(div().absolute().inset_0().bg(cx.theme().success)) + }) .when(is_stripe, |this| this.bg(cx.theme().table_even)) } @@ -97,18 +109,27 @@ impl TableDelegate for GridDelegate { } let is_edited = self.state.pending_edits.contains_key(&(row_ix, col_ix)); - let is_deleted = self.state.pending_deletes.contains(&row_ix); + let original_len = self.state.original_rows.len(); let text: SharedString = if let Some(new_val) = self.state.pending_edits.get(&(row_ix, col_ix)) { new_val.clone().into() + } else if row_ix >= original_len { + let insert_ix = row_ix - original_len; + self.state + .pending_inserts + .get(insert_ix) + .and_then(|row| row.get(col_ix)) + .cloned() + .map(SharedString::from) + .unwrap_or_default() } else { self.state .original_rows .get(row_ix) .and_then(|row| row.get(col_ix)) .cloned() - .unwrap_or_else(|| "".into()) + .unwrap_or_default() }; let outer = div().p_neg_2().w_full().h_full().flex().items_center(); @@ -118,11 +139,7 @@ impl TableDelegate for GridDelegate { .border_r_1() .border_color(cx.theme().border); - if is_deleted { - inner = inner - .line_through() - .text_color(cx.theme().danger_foreground); - } else if is_edited { + if is_edited { inner = inner.p_2().bg(cx.theme().warning); } else { inner = inner.p_2(); @@ -140,10 +157,23 @@ impl TableDelegate for GridDelegate { } fn cell_text(&self, row_ix: usize, col_ix: usize, _: &App) -> String { + // Kiểm tra pending edits trước (áp dụng cho cả original lẫn inserted) if let Some(new_val) = self.state.pending_edits.get(&(row_ix, col_ix)) { return new_val.clone(); } - + let original_len = self.state.original_rows.len(); + // Nếu là inserted row (nằm ngoài phạm vi original) + if row_ix >= original_len { + let insert_ix = row_ix - original_len; + return self + .state + .pending_inserts + .get(insert_ix) + .and_then(|row| row.get(col_ix)) + .cloned() + .unwrap_or_default(); + } + // Original row self.state .original_rows .get(row_ix) diff --git a/desktop/src/shared/smart_data_grid/grid_state.rs b/desktop/src/shared/smart_data_grid/grid_state.rs index 8ebfaa6..9ab8127 100644 --- a/desktop/src/shared/smart_data_grid/grid_state.rs +++ b/desktop/src/shared/smart_data_grid/grid_state.rs @@ -49,13 +49,64 @@ impl GridState { } } + /// Số lượng original rows (không tính pending_inserts) + pub fn original_len(&self) -> usize { + self.original_rows.len() + } + + /// Tổng số rows hiển thị trên grid + pub fn total_len(&self) -> usize { + self.original_rows.len() + self.pending_inserts.len() + } + + /// Đổi row index toàn cục → index trong pending_inserts + pub fn insert_index(&self, row_ix: usize) -> usize { + row_ix - self.original_len() + } + + /// Kiểm tra xem grid có thể chỉnh sửa không (có source_table và primary_keys) pub fn is_editable(&self) -> bool { self.source_table.is_some() && !self.primary_keys.is_empty() } + /// Row này có bị đánh dấu xóa không? + pub fn is_deleted_row(&self, row_ix: usize) -> bool { + row_ix < self.original_rows.len() && self.pending_deletes.contains(&row_ix) + } + + /// Row này có phải là pending insert không? + pub fn is_inserted_row(&self, row_ix: usize) -> bool { + row_ix >= self.original_rows.len() + } + + /// Kiểm tra xem sự thay đổi vào database không pub fn has_pending_changes(&self) -> bool { !self.pending_edits.is_empty() || !self.pending_deletes.is_empty() || !self.pending_inserts.is_empty() } + + /// Lấy text của một cell (ưu tiên pending_edits > pending_inserts > original) + pub fn cell_value(&self, row_ix: usize, col_ix: usize) -> String { + // Pending edit (cả original lẫn inserted đều có thể có) + if let Some(val) = self.pending_edits.get(&(row_ix, col_ix)) { + return val.clone(); + } + + if self.is_inserted_row(row_ix) { + let ix = row_ix - self.original_len(); + return self + .pending_inserts + .get(ix) + .and_then(|row| row.get(col_ix)) + .cloned() + .unwrap_or_default(); + } + + self.original_rows + .get(row_ix) + .and_then(|row| row.get(col_ix)) + .map(|s| s.to_string()) + .unwrap_or_default() + } } diff --git a/desktop/src/shared/smart_data_grid/smart_data_grid.rs b/desktop/src/shared/smart_data_grid/smart_data_grid.rs index bc8c8d5..bae1e9f 100644 --- a/desktop/src/shared/smart_data_grid/smart_data_grid.rs +++ b/desktop/src/shared/smart_data_grid/smart_data_grid.rs @@ -40,7 +40,7 @@ impl SmartDataGrid { let delegate = GridDelegate::new(GridState::new(), cell_editor.clone()); let table = cx.new(|cx| { TableState::new(delegate, window, cx) - .row_header(false) + // .row_header(false) .cell_selectable(true) .row_selectable(true) }); @@ -152,46 +152,48 @@ impl SmartDataGrid { ) -> Result<(usize, usize), StageError> { self.table.update(cx, |table, cx| { let delegate = table.delegate_mut(); - let (r, c) = match &delegate.state.editing_state { + let state = &mut delegate.state; + + let (r, c) = match &state.editing_state { Some(s) => (s.row, s.col), None => return Err(StageError::NoActiveEdit), }; let value = self.cell_editor.read(cx).value().to_string(); - let col_type = delegate.state.columns[c] - .declared_type - .clone() - .unwrap_or_default(); + let col_type = state.columns[c].declared_type.clone().unwrap_or_default(); - let original_value = delegate - .state - .original_rows - .get(r) - .and_then(|row| row.get(c)) - .map(|s| s.to_string()) - .unwrap_or_default(); - - if value == original_value { - delegate.state.pending_edits.remove(&(r, c)); - delegate.state.editing_state = None; + if !validate_sql_type(&value, &col_type) { + state.editing_state.as_mut().unwrap().has_error = true; + self.cell_editor.update(cx, |ed, cx| ed.focus(window, cx)); cx.notify(); - return Ok((r, c)); + return Err(StageError::InvalidData(format!( + "Giá trị '{}' không đúng định dạng {}", + value, col_type + ))); } - if validate_sql_type(&value, &col_type) { - delegate.state.pending_edits.insert((r, c), value); - delegate.state.editing_state = None; + if state.is_inserted_row(r) { + let insert_index = state.insert_index(r); + state + .pending_inserts + .get_mut(insert_index) + .and_then(|row| row.get_mut(c)) + .map(|cell| *cell = value); + + state.editing_state = None; cx.notify(); - Ok((r, c)) + return Ok((r, c)); + } + + let original = state.cell_value(r, c); + if value == original { + state.pending_edits.remove(&(r, c)); } else { - delegate.state.editing_state.as_mut().unwrap().has_error = true; - self.cell_editor.update(cx, |ed, cx| ed.focus(window, cx)); - cx.notify(); - Err(StageError::InvalidData(format!( - "Giá trị '{}' không đúng định dạng {}", - value, col_type - ))) + state.pending_edits.insert((r, c), value); } + state.editing_state = None; + cx.notify(); + Ok((r, c)) }) } @@ -249,7 +251,7 @@ impl SmartDataGrid { fn on_copy_cell( &mut self, _: &crate::action::datagrid::CopyCell, - _window: &mut Window, + _: &mut Window, cx: &mut Context, ) { let table = self.table.read(cx); @@ -278,7 +280,7 @@ impl SmartDataGrid { fn on_commit_changes( &mut self, _: &crate::action::datagrid::CommitChanges, - _window: &mut Window, + _: &mut Window, cx: &mut Context, ) { let state = &self.table.read(cx).delegate().state; @@ -337,6 +339,75 @@ impl SmartDataGrid { }); } + fn on_delete_row( + &mut self, + _: &crate::action::datagrid::DeleteRow, + _: &mut Window, + cx: &mut Context, + ) { + self.table.update(cx, |table, cx| { + // ✅ Đọc selected_row TRƯỚC, tránh double borrow + let selected = table.selected_row(); + let delegate = table.delegate_mut(); + + match selected { + Some(row_ix) => { + let state = &mut delegate.state; + if state.is_inserted_row(row_ix) { + // Xóa thẳng khỏi pending_inserts + let ix = state.insert_index(row_ix); + state.pending_inserts.remove(ix); + } else { + // Toggle pending_deletes + if state.pending_deletes.contains(&row_ix) { + state.pending_deletes.remove(&row_ix); + } else { + state.pending_deletes.insert(row_ix); + } + } + cx.notify(); + } + None => {} + } + }); + } + + fn on_add_row( + &mut self, + _: &crate::action::datagrid::AddRow, + _: &mut Window, + cx: &mut Context, + ) { + self.table.update(cx, |table, cx| { + let delegate = table.delegate_mut(); + let col_count = delegate.state.columns.len(); + if col_count == 0 { + return; + } + delegate + .state + .pending_inserts + .push(vec![String::new(); col_count]); + cx.notify(); + }); + } + + fn on_discard_changes( + &mut self, + _: &crate::action::datagrid::DiscardChanges, + _: &mut Window, + cx: &mut Context, + ) { + self.table.update(cx, |table, cx| { + let delegate = table.delegate_mut(); + delegate.state.pending_edits.clear(); + delegate.state.pending_deletes.clear(); + delegate.state.pending_inserts.clear(); + delegate.state.editing_state = None; + cx.notify(); + }); + } + fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { let delegate = self.table.read(cx).delegate(); let state = &delegate.state; @@ -378,7 +449,10 @@ impl SmartDataGrid { .size_6() .cursor_pointer() .icon(AppIcon::Plus) - .disabled(!is_editable || is_loading), + .disabled(!is_editable || is_loading) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(crate::action::datagrid::AddRow), cx); + }), ) .child( Button::new("btn-delete-row") @@ -386,7 +460,10 @@ impl SmartDataGrid { .size_6() .cursor_pointer() .icon(AppIcon::Minus) - .disabled(!is_editable || is_loading), + .disabled(!is_editable || is_loading) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(crate::action::datagrid::DeleteRow), cx); + }), ) .child(div().w_px().h_4().mx_px().bg(cx.theme().border)) .child( @@ -411,7 +488,11 @@ impl SmartDataGrid { .size_6() .cursor_pointer() .icon(Icon::new(AppIcon::X)) - .disabled(!has_changes || is_loading), + .disabled(!has_changes || is_loading) + .on_click(|_, window, cx| { + window + .dispatch_action(Box::new(crate::action::datagrid::DiscardChanges), cx); + }), ) .child(div().flex_1()) .child( @@ -431,6 +512,9 @@ impl Render for SmartDataGrid { .key_context("data-grid-container") .on_action(cx.listener(Self::on_commit_changes)) .on_action(cx.listener(Self::on_copy_cell)) + .on_action(cx.listener(Self::on_add_row)) + .on_action(cx.listener(Self::on_delete_row)) + .on_action(cx.listener(Self::on_discard_changes)) .size_full() .child(self.render_toolbar(cx)) .child( diff --git a/themes/truyvansql.json b/themes/truyvansql.json index c0472eb..4fc7147 100644 --- a/themes/truyvansql.json +++ b/themes/truyvansql.json @@ -15,9 +15,10 @@ "foreground": "#000000", "border": "#e4e4e7", "ring": "#0060de", - "danger.foreground": "#F0B100", - "danger.background": "#E7000B", - "warning.background": "#fff085", + "success.background": "#B9F8CF60", + "danger.foreground": "#E7000B", + "danger.background": "#FFC9C9", + "warning.background": "#fff08560", "sidebar.background": "#FAFAFA00", "list.active.background": "#0060de15", "list.active.border": "#0060de", From 5c35ec09344fa852f2fa03385a7312aeb4787ccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Ho=C3=A0ng=20Hi=E1=BB=87p?= Date: Tue, 26 May 2026 21:55:07 +0700 Subject: [PATCH 05/10] refactor(datagrid): improve focus handling and row deletion logic - Add focus_handle field to SmartDataGrid and use it for action dispatching instead of window.dispatch_action - Enable row_header(false) in table configuration - Fix on_delete_row to delete selected cell's row when no row is explicitly selected, improving UX consistency - Add track_focus to main view for proper focus context management - Add RowInsert import to sqlite tests - Update danger.background in theme with alpha channel for consistency with other semi-transparent colors --- .../shared/smart_data_grid/smart_data_grid.rs | 119 +++++++++++------- engine/src/driver/sqlite.rs | 2 +- themes/truyvansql.json | 2 +- 3 files changed, 79 insertions(+), 44 deletions(-) diff --git a/desktop/src/shared/smart_data_grid/smart_data_grid.rs b/desktop/src/shared/smart_data_grid/smart_data_grid.rs index bae1e9f..01ef4d4 100644 --- a/desktop/src/shared/smart_data_grid/smart_data_grid.rs +++ b/desktop/src/shared/smart_data_grid/smart_data_grid.rs @@ -30,6 +30,7 @@ pub struct SmartDataGrid { pub client: SqlClient, pub table: Entity>, pub cell_editor: Entity, + focus_handle: FocusHandle, _blur_subscription: gpui::Subscription, } @@ -40,22 +41,28 @@ impl SmartDataGrid { let delegate = GridDelegate::new(GridState::new(), cell_editor.clone()); let table = cx.new(|cx| { TableState::new(delegate, window, cx) - // .row_header(false) + .row_header(false) .cell_selectable(true) .row_selectable(true) }); - let focus_handle = cell_editor.read(cx).focus_handle(cx); - let blur_sub = cx.on_blur(&focus_handle, window, |this: &mut Self, window, cx| { - match this.stage_cell_edit(window, cx) { - Ok((row_ix, col_ix)) => { - this.table.update(cx, |table, cx| { - table.set_selected_cell(row_ix, col_ix, cx); - }); - } - Err(error) => eprintln!("Blur error: {error}"), - }; - }); + let cell_editor_focus_handle = cell_editor.read(cx).focus_handle(cx); + let blur_sub = cx.on_blur( + &cell_editor_focus_handle, + window, + |this: &mut Self, window, cx| { + match this.stage_cell_edit(window, cx) { + Ok((row_ix, col_ix)) => { + this.table.update(cx, |table, cx| { + table.set_selected_cell(row_ix, col_ix, cx); + }); + } + Err(error) => eprintln!("Blur error: {error}"), + }; + }, + ); + + let focus_handle = cx.focus_handle(); // Bắt sự kiện từ Table (ví dụ: Double Click để Edit) cx.subscribe_in(&table, window, Self::on_table_event) @@ -65,6 +72,7 @@ impl SmartDataGrid { client, table, cell_editor, + focus_handle, _blur_subscription: blur_sub, } } @@ -339,6 +347,7 @@ impl SmartDataGrid { }); } + /// Xóa dòng đã chọn, hoặc dòng chứa ô đã chọn nếu không có dòng nào được chọn fn on_delete_row( &mut self, _: &crate::action::datagrid::DeleteRow, @@ -346,32 +355,31 @@ impl SmartDataGrid { cx: &mut Context, ) { self.table.update(cx, |table, cx| { - // ✅ Đọc selected_row TRƯỚC, tránh double borrow - let selected = table.selected_row(); - let delegate = table.delegate_mut(); + let selected_row = if let Some(row_ix) = table.selected_row() { + row_ix + } else if let Some((row_ix, _)) = table.selected_cell() { + row_ix + } else { + return; // Không làm gì cả nếu không select được row nào + }; - match selected { - Some(row_ix) => { - let state = &mut delegate.state; - if state.is_inserted_row(row_ix) { - // Xóa thẳng khỏi pending_inserts - let ix = state.insert_index(row_ix); - state.pending_inserts.remove(ix); - } else { - // Toggle pending_deletes - if state.pending_deletes.contains(&row_ix) { - state.pending_deletes.remove(&row_ix); - } else { - state.pending_deletes.insert(row_ix); - } - } - cx.notify(); + let delegate = table.delegate_mut(); + let state = &mut delegate.state; + if state.is_inserted_row(selected_row) { + let ix = state.insert_index(selected_row); + state.pending_inserts.remove(ix); + } else { + if state.pending_deletes.contains(&selected_row) { + state.pending_deletes.remove(&selected_row); + } else { + state.pending_deletes.insert(selected_row); } - None => {} } + cx.notify(); }); } + /// Thêm một dòng mới vào cuối bảng fn on_add_row( &mut self, _: &crate::action::datagrid::AddRow, @@ -450,8 +458,15 @@ impl SmartDataGrid { .cursor_pointer() .icon(AppIcon::Plus) .disabled(!is_editable || is_loading) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(crate::action::datagrid::AddRow), cx); + .on_click({ + let focus_handle = self.focus_handle.clone(); + move |_, window, cx| { + focus_handle.dispatch_action( + &crate::action::datagrid::AddRow, + window, + cx, + ); + } }), ) .child( @@ -461,8 +476,15 @@ impl SmartDataGrid { .cursor_pointer() .icon(AppIcon::Minus) .disabled(!is_editable || is_loading) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(crate::action::datagrid::DeleteRow), cx); + .on_click({ + let focus_handle = self.focus_handle.clone(); + move |_, window, cx| { + focus_handle.dispatch_action( + &crate::action::datagrid::DeleteRow, + window, + cx, + ); + } }), ) .child(div().w_px().h_4().mx_px().bg(cx.theme().border)) @@ -474,9 +496,15 @@ impl SmartDataGrid { .size_6() .cursor_pointer() .icon(Icon::new(AppIcon::Check)) - .on_click(|_, window, cx| { - window - .dispatch_action(Box::new(crate::action::datagrid::CommitChanges), cx); + .on_click({ + let focus_handle = self.focus_handle.clone(); + move |_, window, cx| { + focus_handle.dispatch_action( + &crate::action::datagrid::CommitChanges, + window, + cx, + ); + } }) .disabled(!has_changes || is_loading), ) @@ -489,9 +517,15 @@ impl SmartDataGrid { .cursor_pointer() .icon(Icon::new(AppIcon::X)) .disabled(!has_changes || is_loading) - .on_click(|_, window, cx| { - window - .dispatch_action(Box::new(crate::action::datagrid::DiscardChanges), cx); + .on_click({ + let focus_handle = self.focus_handle.clone(); + move |_, window, cx| { + focus_handle.dispatch_action( + &crate::action::datagrid::DiscardChanges, + window, + cx, + ); + } }), ) .child(div().flex_1()) @@ -510,6 +544,7 @@ impl Render for SmartDataGrid { v_flex() .key_context("data-grid-container") + .track_focus(&self.focus_handle) .on_action(cx.listener(Self::on_commit_changes)) .on_action(cx.listener(Self::on_copy_cell)) .on_action(cx.listener(Self::on_add_row)) diff --git a/engine/src/driver/sqlite.rs b/engine/src/driver/sqlite.rs index 23ec73c..e0fd332 100644 --- a/engine/src/driver/sqlite.rs +++ b/engine/src/driver/sqlite.rs @@ -414,7 +414,7 @@ fn convert_value(row: &sqlx::sqlite::SqliteRow, idx: usize) -> Option { mod tests { use super::*; use crate::{ - DataChangeset, FileDbConfig, + DataChangeset, FileDbConfig, RowInsert, schema::{ColumnData, RowDelete, RowUpdate}, }; diff --git a/themes/truyvansql.json b/themes/truyvansql.json index 4fc7147..c30995d 100644 --- a/themes/truyvansql.json +++ b/themes/truyvansql.json @@ -17,7 +17,7 @@ "ring": "#0060de", "success.background": "#B9F8CF60", "danger.foreground": "#E7000B", - "danger.background": "#FFC9C9", + "danger.background": "#FFC9C960", "warning.background": "#fff08560", "sidebar.background": "#FAFAFA00", "list.active.background": "#0060de15", From 1d9f11d1dceab48f693805b41f77c40559e4dd97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Ho=C3=A0ng=20Hi=E1=BB=87p?= Date: Wed, 27 May 2026 12:22:22 +0700 Subject: [PATCH 06/10] feat(grid): add proper NULL handling and data type categories - Refactor grid state types to use Option for nullable cells; pending_edits, pending_inserts, and original_rows now store optional values. - Introduce DataTypeCategory enum with categories (Text, Integer, Float, Boolean, DateTime, Binary, Uuid, Unknown) defining UI behavior, empty input handling, and validation rules. - Update SqlDialect::format_value to accept Option<&str> and DataTypeCategory for proper NULL/empty string serialization per type. - Simplify cell rendering in GridDelegate using match on (is_edited, is_null) with distinct styling for NULL values (italic, muted) versus empty strings. - Remove unused EngineError import and dead validate_sql_type function; add cell_original_value helper method to GridState. --- desktop/src/connection/database_node.rs | 2 +- .../smart_data_grid/data_changeset_builder.rs | 16 ++- .../shared/smart_data_grid/grid_delegate.rs | 84 ++++++--------- .../src/shared/smart_data_grid/grid_state.rs | 23 ++-- .../shared/smart_data_grid/smart_data_grid.rs | 100 ++++++++---------- engine/src/driver/mod.rs | 26 +++-- engine/src/driver/postgres.rs | 63 ++++++----- engine/src/driver/sqlite.rs | 58 ++++++---- engine/src/lib.rs | 5 +- engine/src/schema/data_type_category.rs | 59 +++++++++++ engine/src/schema/mod.rs | 4 +- engine/src/schema/modification.rs | 2 +- 12 files changed, 268 insertions(+), 174 deletions(-) create mode 100644 engine/src/schema/data_type_category.rs diff --git a/desktop/src/connection/database_node.rs b/desktop/src/connection/database_node.rs index a7bbc92..bd9603f 100644 --- a/desktop/src/connection/database_node.rs +++ b/desktop/src/connection/database_node.rs @@ -1,4 +1,4 @@ -use engine::{DatabaseBrief, DatabaseConfig, EngineError, SqlClient}; +use engine::{DatabaseBrief, DatabaseConfig, SqlClient}; use gpui::*; use crate::{connection::SchemaNode, shared::LoadState}; diff --git a/desktop/src/shared/smart_data_grid/data_changeset_builder.rs b/desktop/src/shared/smart_data_grid/data_changeset_builder.rs index 87cfe88..0fcda74 100644 --- a/desktop/src/shared/smart_data_grid/data_changeset_builder.rs +++ b/desktop/src/shared/smart_data_grid/data_changeset_builder.rs @@ -1,5 +1,6 @@ use crate::shared::smart_data_grid::grid_state::GridState; use engine::{ColumnData, DataChangeset, RowDelete, RowInsert, RowUpdate}; +use gpui::SharedString; use std::collections::HashMap; use thiserror::Error; @@ -32,14 +33,15 @@ impl<'a> DataChangesetBuilder<'a> { return Err(DataChangesetError::PrimaryKeyNotFound(table_name)); } + // UPDATE let mut updates = Vec::new(); - let mut edits_by_row: HashMap> = HashMap::new(); + let mut edits_by_row: HashMap)>> = HashMap::new(); for (&(row, col), value) in &self.state.pending_edits { edits_by_row .entry(row) .or_default() - .push((col, value.clone())); + .push((col, value.as_deref().map(|v| v.to_string()))); } for (row_ix, row_edits) in edits_by_row { @@ -48,7 +50,7 @@ impl<'a> DataChangesetBuilder<'a> { let col = &self.state.columns[col_ix]; changes.push(ColumnData { column_name: col.name.clone(), - value, + value: value, data_type: col.declared_type.clone().unwrap_or_default(), }); } @@ -58,6 +60,7 @@ impl<'a> DataChangesetBuilder<'a> { }); } + // DELETE let mut deletes = Vec::new(); for &row_ix in &self.state.pending_deletes { deletes.push(RowDelete { @@ -65,6 +68,7 @@ impl<'a> DataChangesetBuilder<'a> { }); } + // INSERT let mut inserts = Vec::new(); for row_values in &self.state.pending_inserts { let values: Vec = row_values @@ -74,7 +78,7 @@ impl<'a> DataChangesetBuilder<'a> { let col = &self.state.columns[col_ix]; ColumnData { column_name: col.name.clone(), - value: value.clone(), + value: value.as_deref().map(|v| v.to_string()), data_type: col.declared_type.clone().unwrap_or_default(), } }) @@ -101,7 +105,9 @@ impl<'a> DataChangesetBuilder<'a> { .iter() .position(|c| &c.name == pk_name) .unwrap(); - let original_val = self.state.original_rows[row_ix][pk_col_ix].to_string(); + let original_val = self.state.original_rows[row_ix][pk_col_ix] + .as_deref() + .map(|v| v.to_string()); let col_type = self.state.columns[pk_col_ix] .declared_type .clone() diff --git a/desktop/src/shared/smart_data_grid/grid_delegate.rs b/desktop/src/shared/smart_data_grid/grid_delegate.rs index 91b611f..7406f4e 100644 --- a/desktop/src/shared/smart_data_grid/grid_delegate.rs +++ b/desktop/src/shared/smart_data_grid/grid_delegate.rs @@ -108,44 +108,45 @@ impl TableDelegate for GridDelegate { .into_any_element(); } + let cell_val = self.state.cell_value(row_ix, col_ix); + let is_null = cell_val.is_none(); let is_edited = self.state.pending_edits.contains_key(&(row_ix, col_ix)); - let original_len = self.state.original_rows.len(); - let text: SharedString = - if let Some(new_val) = self.state.pending_edits.get(&(row_ix, col_ix)) { - new_val.clone().into() - } else if row_ix >= original_len { - let insert_ix = row_ix - original_len; - self.state - .pending_inserts - .get(insert_ix) - .and_then(|row| row.get(col_ix)) - .cloned() - .map(SharedString::from) - .unwrap_or_default() - } else { - self.state - .original_rows - .get(row_ix) - .and_then(|row| row.get(col_ix)) - .cloned() - .unwrap_or_default() - }; - let outer = div().p_neg_2().w_full().h_full().flex().items_center(); let mut inner = div() + .p_2() .size_full() .border_r_1() .border_color(cx.theme().border); - if is_edited { - inner = inner.p_2().bg(cx.theme().warning); - } else { - inner = inner.p_2(); + match (is_edited, is_null) { + (true, true) => { + // Edited → NULL: yellow bg + italic "NULL" + inner = inner + .bg(cx.theme().warning) + .text_color(cx.theme().muted_foreground) + .italic() + .child("NULL".to_string()); + } + (true, false) => { + // Edited → value: yellow bg + value + inner = inner.bg(cx.theme().warning).child(cell_val.unwrap()); + } + (false, true) => { + // Original NULL: italic "NULL" muted + inner = inner + .text_color(cx.theme().muted_foreground) + .italic() + .child("NULL".to_string()); + } + (false, false) => { + // Original value: normal text + inner = inner.child(cell_val.unwrap()); + } } - outer.child(inner.child(text)).into_any_element() + outer.child(inner).into_any_element() } fn render_empty( @@ -156,29 +157,14 @@ impl TableDelegate for GridDelegate { div().into_any_element() } + /// Lấy text cho clipboard. NULL → "NULL" string. + /// + /// Không khuyến khích sử dụng hàm này vì nó không xử lý giá trị NULL theo đúng cách, thay vào + /// đó hãy sử dụng [`GridState.cell_value`] fn cell_text(&self, row_ix: usize, col_ix: usize, _: &App) -> String { - // Kiểm tra pending edits trước (áp dụng cho cả original lẫn inserted) - if let Some(new_val) = self.state.pending_edits.get(&(row_ix, col_ix)) { - return new_val.clone(); - } - let original_len = self.state.original_rows.len(); - // Nếu là inserted row (nằm ngoài phạm vi original) - if row_ix >= original_len { - let insert_ix = row_ix - original_len; - return self - .state - .pending_inserts - .get(insert_ix) - .and_then(|row| row.get(col_ix)) - .cloned() - .unwrap_or_default(); + match self.state.cell_value(row_ix, col_ix) { + Some(val) => val.to_string(), + None => "NULL".to_string(), } - // Original row - self.state - .original_rows - .get(row_ix) - .and_then(|row| row.get(col_ix)) - .map(|val| val.to_string()) - .unwrap_or_default() } } diff --git a/desktop/src/shared/smart_data_grid/grid_state.rs b/desktop/src/shared/smart_data_grid/grid_state.rs index 9ab8127..88ecbf8 100644 --- a/desktop/src/shared/smart_data_grid/grid_state.rs +++ b/desktop/src/shared/smart_data_grid/grid_state.rs @@ -12,14 +12,14 @@ pub struct EditingState { #[derive(Clone)] pub struct GridState { pub columns: Vec, - pub original_rows: Vec>, + pub original_rows: Vec>>, pub source_table: Option, pub primary_keys: Vec, - pub pending_edits: HashMap<(usize, usize), String>, + pub pending_edits: HashMap<(usize, usize), Option>, pub pending_deletes: HashSet, - pub pending_inserts: Vec>, + pub pending_inserts: Vec>>, pub limit: usize, pub offset: usize, @@ -87,10 +87,10 @@ impl GridState { } /// Lấy text của một cell (ưu tiên pending_edits > pending_inserts > original) - pub fn cell_value(&self, row_ix: usize, col_ix: usize) -> String { + pub fn cell_value(&self, row_ix: usize, col_ix: usize) -> Option { // Pending edit (cả original lẫn inserted đều có thể có) - if let Some(val) = self.pending_edits.get(&(row_ix, col_ix)) { - return val.clone(); + if let Some(editing_value) = self.pending_edits.get(&(row_ix, col_ix)) { + return editing_value.clone(); } if self.is_inserted_row(row_ix) { @@ -100,13 +100,18 @@ impl GridState { .get(ix) .and_then(|row| row.get(col_ix)) .cloned() - .unwrap_or_default(); + .flatten(); } + self.cell_original_value(row_ix, col_ix) + } + + /// Lấy giá trị gốc của một cell (không bao gồm pending edits) + pub fn cell_original_value(&self, row_ix: usize, col_ix: usize) -> Option { self.original_rows .get(row_ix) .and_then(|row| row.get(col_ix)) - .map(|s| s.to_string()) - .unwrap_or_default() + .cloned() + .flatten() } } diff --git a/desktop/src/shared/smart_data_grid/smart_data_grid.rs b/desktop/src/shared/smart_data_grid/smart_data_grid.rs index 01ef4d4..a56bbfa 100644 --- a/desktop/src/shared/smart_data_grid/smart_data_grid.rs +++ b/desktop/src/shared/smart_data_grid/smart_data_grid.rs @@ -8,23 +8,6 @@ use gpui_component::input::InputState; use gpui_component::table::{DataTable, TableDelegate, TableEvent, TableState}; use gpui_component::{ActiveTheme, Disableable, Icon, Sizable, h_flex, v_flex}; -/// TODO: Nên chuyển cái hàm này sang chỗ khác -fn validate_sql_type(text: &str, data_type: &str) -> bool { - let data_type = data_type.to_uppercase(); - if data_type.contains("INT") || data_type.contains("BOOL") { - text.parse::().is_ok() - } else if data_type.contains("REAL") - || data_type.contains("FLOAT") - || data_type.contains("DOUBLE") - || data_type.contains("DECIMAL") - || data_type.contains("NUMERIC") - { - text.parse::().is_ok() - } else { - true - } -} - /// View độc lập quản lý hiển thị và tương tác dữ liệu dạng bảng. pub struct SmartDataGrid { pub client: SqlClient, @@ -134,7 +117,11 @@ impl SmartDataGrid { return; } - let current_value = delegate.cell_text(row_ix, col_ix, cx); + let current_value = delegate.state.cell_value(row_ix, col_ix); + let current_value = match current_value { + Some(val) => val.to_string(), + None => String::new(), + }; self.cell_editor.update(cx, |input, cx| { input.set_value(current_value, window, cx); @@ -167,38 +154,46 @@ impl SmartDataGrid { None => return Err(StageError::NoActiveEdit), }; - let value = self.cell_editor.read(cx).value().to_string(); + let text = self.cell_editor.read(cx).value().to_string(); let col_type = state.columns[c].declared_type.clone().unwrap_or_default(); + let data_type_category = self.client.data_type_categorize(&col_type); + + let new_value: Option = match text.is_empty() { + true => data_type_category + .allows_empty_string() + .then_some(SharedString::from("")), + false => { + if !data_type_category.validate(&text) { + state.editing_state.as_mut().unwrap().has_error = true; + self.cell_editor.update(cx, |ed, cx| ed.focus(window, cx)); + cx.notify(); + return Err(StageError::InvalidData(format!( + "Giá trị '{}' không đúng định dạng {}", + text, col_type + ))); + } + Some(SharedString::from(text)) + } + }; - if !validate_sql_type(&value, &col_type) { - state.editing_state.as_mut().unwrap().has_error = true; - self.cell_editor.update(cx, |ed, cx| ed.focus(window, cx)); - cx.notify(); - return Err(StageError::InvalidData(format!( - "Giá trị '{}' không đúng định dạng {}", - value, col_type - ))); - } - - if state.is_inserted_row(r) { - let insert_index = state.insert_index(r); - state - .pending_inserts - .get_mut(insert_index) - .and_then(|row| row.get_mut(c)) - .map(|cell| *cell = value); - - state.editing_state = None; - cx.notify(); - return Ok((r, c)); + match state.is_inserted_row(r) { + true => { + let insert_index = state.insert_index(r); + state + .pending_inserts + .get_mut(insert_index) + .and_then(|row| row.get_mut(c)) + .map(|cell| *cell = new_value); + } + false => { + let original = state.cell_original_value(r, c); + match new_value == original { + true => state.pending_edits.remove(&(r, c)), + false => state.pending_edits.insert((r, c), new_value), + }; + } } - let original = state.cell_value(r, c); - if value == original { - state.pending_edits.remove(&(r, c)); - } else { - state.pending_edits.insert((r, c), value); - } state.editing_state = None; cx.notify(); Ok((r, c)) @@ -207,14 +202,14 @@ impl SmartDataGrid { /// Cập nhật dữ liệu gốc cho Grid pub fn set_data(&mut self, columns: Vec, rows: Vec, cx: &mut Context) { - let cached_rows: Vec> = rows + let cached_rows: Vec>> = rows .into_iter() .map(|row| { row.values .into_iter() .map(|val| match val { - Some(v) => v.to_string().into(), - None => "NULL".into(), + Some(v) => Some(v.to_string().into()), + None => None, }) .collect() }) @@ -222,8 +217,8 @@ impl SmartDataGrid { self.table.update(cx, |table, cx| { let delegate = table.delegate_mut(); - delegate.state.columns = columns; delegate.state.original_rows = cached_rows; + delegate.state.columns = columns; delegate.state.pending_edits.clear(); delegate.state.pending_deletes.clear(); delegate.state.pending_inserts.clear(); @@ -392,10 +387,7 @@ impl SmartDataGrid { if col_count == 0 { return; } - delegate - .state - .pending_inserts - .push(vec![String::new(); col_count]); + delegate.state.pending_inserts.push(vec![None; col_count]); cx.notify(); }); } diff --git a/engine/src/driver/mod.rs b/engine/src/driver/mod.rs index eebb7e6..e6d68e8 100644 --- a/engine/src/driver/mod.rs +++ b/engine/src/driver/mod.rs @@ -4,7 +4,9 @@ pub mod sqlite; use crate::database_config::{DatabaseConfig, DatabaseKind}; use crate::error::EngineError; use crate::result::QueryResult; -use crate::schema::{DataChangeset, DatabaseBrief, SchemaBrief, TableBrief, TableInfo}; +use crate::schema::{ + DataChangeset, DataTypeCategory, DatabaseBrief, SchemaBrief, TableBrief, TableInfo, +}; use crate::{ColumnInfo, ForeignKeyInfo, IndexInfo, PrimaryKey}; /// Cung cấp các quy tắc định dạng SQL (Dialect) cho từng loại Database @@ -20,7 +22,7 @@ pub trait SqlDialect { fn quote_identifier(&self, identifier: &str) -> String; /// Định dạng giá trị dựa trên kiểu dữ liệu - fn format_value(&self, value: &str, data_type: &str) -> String; + fn format_value(&self, value: Option<&str>, data_type: DataTypeCategory) -> String; } /// Trait đại diện cho một database driver. @@ -66,10 +68,11 @@ pub trait DatabaseDriver: SqlDialect + Send + Sync { .changes .iter() .map(|c| { + let data_type = self.data_type_categorize(&c.data_type); format!( "{} = {}", self.quote_identifier(&c.column_name), - self.format_value(&c.value, &c.data_type) + self.format_value(c.value.as_deref(), data_type) ) }) .collect::>() @@ -79,10 +82,11 @@ pub trait DatabaseDriver: SqlDialect + Send + Sync { .pk_conditions .iter() .map(|c| { + let data_type = self.data_type_categorize(&c.data_type); format!( "{} = {}", self.quote_identifier(&c.column_name), - self.format_value(&c.value, &c.data_type) + self.format_value(c.value.as_deref(), data_type) ) }) .collect::>() @@ -101,10 +105,11 @@ pub trait DatabaseDriver: SqlDialect + Send + Sync { .pk_conditions .iter() .map(|c| { + let data_type = self.data_type_categorize(&c.data_type); format!( "{} = {}", self.quote_identifier(&c.column_name), - self.format_value(&c.value, &c.data_type) + self.format_value(c.value.as_deref(), data_type) ) }) .collect::>() @@ -127,7 +132,10 @@ pub trait DatabaseDriver: SqlDialect + Send + Sync { let values = insert .values .iter() - .map(|c| self.format_value(&c.value, &c.data_type)) + .map(|c| { + let data_type = self.data_type_categorize(&c.data_type); + self.format_value(c.value.as_deref(), data_type) + }) .collect::>() .join(", "); script.push_str(&format!( @@ -240,6 +248,12 @@ pub trait DatabaseDriver: SqlDialect + Send + Sync { } Ok(()) } + + /// Phân loại kiểu dữ liệu SQL → DataTypeCategory. + /// + /// Mỗi driver override để map kiểu riêng của database. + /// Ví dụ: SQLite có "TEXT", PostgreSQL có "VARCHAR" — cả hai → Text. + fn data_type_categorize(&self, data_type: &str) -> DataTypeCategory; } /// Entry point duy nhất để tạo driver mới. diff --git a/engine/src/driver/postgres.rs b/engine/src/driver/postgres.rs index 9c50245..666b333 100644 --- a/engine/src/driver/postgres.rs +++ b/engine/src/driver/postgres.rs @@ -8,8 +8,8 @@ use crate::driver::{DatabaseDriver, SqlDialect}; use crate::error::EngineError; use crate::result::{Column, QueryResult, Row as ResultRow, Value}; use crate::schema::{ - ColumnInfo, DatabaseBrief, ForeignKeyInfo, IndexInfo, PrimaryKey, SchemaBrief, SchemaKind, - TableBrief, TableKind, + ColumnInfo, DataTypeCategory, DatabaseBrief, ForeignKeyInfo, IndexInfo, SchemaBrief, + SchemaKind, TableBrief, TableKind, }; use crate::{DatabaseKind, NetworkDbConfig}; @@ -54,30 +54,17 @@ impl SqlDialect for PostgresDriver { format!("\"{}\"", identifier) } - fn format_value(&self, value: &str, data_type: &str) -> String { - if value == "NULL" { - return "NULL".into(); - } - let dt = data_type.to_uppercase(); - if dt.contains("INT") - || dt.contains("SERIAL") - || dt.contains("REAL") - || dt.contains("FLOAT") - || dt.contains("DOUBLE") - || dt.contains("NUMERIC") - || dt.contains("DECIMAL") - { - if value - .chars() - .all(|c| c.is_digit(10) || c == '.' || c == '-') - { - return value.into(); - } - } - if dt == "BOOLEAN" || dt == "BOOL" { - return value.into(); + fn format_value(&self, value: Option<&str>, data_type: DataTypeCategory) -> String { + match value { + Some(value) => match data_type { + DataTypeCategory::Integer | DataTypeCategory::Float | DataTypeCategory::Boolean => { + value.into() + } + DataTypeCategory::Binary => format!("\\x{}", value), + _ => format!("'{}'", value.replace("'", "''")), + }, + None => "NULL".into(), } - format!("'{}'", value.replace("'", "''")) } } @@ -443,6 +430,32 @@ impl DatabaseDriver for PostgresDriver { Ok(indexes) } + + fn data_type_categorize(&self, data_type: &str) -> DataTypeCategory { + let dt = data_type.to_uppercase(); + if dt.contains("CHAR") || dt.contains("VARCHAR") || dt.contains("TEXT") { + DataTypeCategory::Text + } else if dt.contains("SERIAL") || dt.contains("INT") { + DataTypeCategory::Integer + } else if dt.contains("REAL") + || dt.contains("FLOAT") + || dt.contains("DOUBLE") + || dt.contains("NUMERIC") + || dt.contains("DECIMAL") + { + DataTypeCategory::Float + } else if dt.contains("BOOL") { + DataTypeCategory::Boolean + } else if dt.contains("DATE") || dt.contains("TIME") || dt.contains("INTERVAL") { + DataTypeCategory::DateTime + } else if dt.contains("BYTEA") || dt.contains("BLOB") { + DataTypeCategory::Binary + } else if dt.contains("UUID") { + DataTypeCategory::Uuid + } else { + DataTypeCategory::Unknown + } + } } impl PostgresDriver { diff --git a/engine/src/driver/sqlite.rs b/engine/src/driver/sqlite.rs index e0fd332..5d0d1c0 100644 --- a/engine/src/driver/sqlite.rs +++ b/engine/src/driver/sqlite.rs @@ -3,12 +3,12 @@ use std::time::Duration; use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; use sqlx::{Column as SqlxColumn, Executor, Row, Statement, TypeInfo}; -use crate::driver::{DatabaseDriver, SqlDialect, extract_primary_key}; +use crate::driver::{DatabaseDriver, SqlDialect}; use crate::error::EngineError; use crate::result::{Column, QueryResult, Row as ResultRow, Value}; use crate::schema::{ - ColumnInfo, DatabaseBrief, ForeignKeyInfo, IndexInfo, PrimaryKey, SchemaBrief, SchemaKind, - TableBrief, TableInfo, TableKind, + ColumnInfo, DataTypeCategory, DatabaseBrief, ForeignKeyInfo, IndexInfo, SchemaBrief, + SchemaKind, TableBrief, TableKind, }; use crate::{DatabaseConfig, FileDbConfig}; @@ -56,20 +56,16 @@ impl SqlDialect for SqliteDriver { format!("\"{}\"", identifier) } - fn format_value(&self, value: &str, data_type: &str) -> String { - if value == "NULL" { - "NULL".into() - } else { - let dt = data_type.to_uppercase(); - if (dt.contains("INT") || dt.contains("REAL") || dt.contains("FLOAT")) - && value - .chars() - .all(|c| c.is_digit(10) || c == '.' || c == '-') - { - value.into() - } else { - format!("'{}'", value.replace("'", "''")) - } + fn format_value(&self, value: Option<&str>, data_type: DataTypeCategory) -> String { + match value { + Some(value) => match data_type { + DataTypeCategory::Integer | DataTypeCategory::Float | DataTypeCategory::Boolean => { + value.into() + } + DataTypeCategory::Binary => format!("X'{}", value), + _ => format!("'{}'", value.replace("'", "''")), + }, + None => "NULL".into(), } } } @@ -384,6 +380,26 @@ impl DatabaseDriver for SqliteDriver { Ok(indexes) } + + fn data_type_categorize(&self, data_type: &str) -> DataTypeCategory { + let dt = data_type.to_uppercase(); + // SQLite storage affinities: TEXT, NUMERIC, INTEGER, REAL, BLOB + if dt.contains("CHAR") || dt.contains("CLOB") || dt.contains("TEXT") { + DataTypeCategory::Text + } else if dt.contains("INT") { + DataTypeCategory::Integer + } else if dt.contains("REAL") || dt.contains("FLOA") || dt.contains("DOUB") { + DataTypeCategory::Float + } else if dt.contains("BOOL") { + DataTypeCategory::Boolean + } else if dt.contains("BLOB") { + DataTypeCategory::Binary + } else if dt.contains("DATE") || dt.contains("TIME") { + DataTypeCategory::DateTime + } else { + DataTypeCategory::Unknown + } + } } /// Convert một `sqlx::SqliteRow` thành `result::Row`. @@ -896,26 +912,26 @@ mod tests { updates: vec![RowUpdate { pk_conditions: vec![ColumnData { column_name: "id".to_string(), - value: "1".to_string(), + value: Some("1".to_string()), data_type: "INTEGER".to_string(), }], changes: vec![ColumnData { column_name: "name".to_string(), - value: "Alice O'Neil".to_string(), + value: Some("Alice O'Neil".to_string()), data_type: "TEXT".to_string(), }], }], deletes: vec![RowDelete { pk_conditions: vec![ColumnData { column_name: "id".to_string(), - value: "2".to_string(), + value: Some("2".to_string()), data_type: "INTEGER".to_string(), }], }], inserts: vec![RowInsert { values: vec![ColumnData { column_name: "id".to_string(), - value: "3".to_string(), + value: Some("3".to_string()), data_type: "INTEGER".to_string(), }], }], diff --git a/engine/src/lib.rs b/engine/src/lib.rs index 1cecbaa..90f6999 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -10,7 +10,8 @@ pub use driver::DatabaseDriver; pub use error::EngineError; pub use result::{Column, QueryResult, Row, Value}; pub use schema::{ - ColumnData, ColumnInfo, DataChangeset, DatabaseBrief, ForeignKeyInfo, IndexInfo, PrimaryKey, - RowDelete, RowInsert, RowUpdate, SchemaBrief, SchemaKind, TableBrief, TableInfo, TableKind, + ColumnData, ColumnInfo, DataChangeset, DataTypeCategory, DatabaseBrief, ForeignKeyInfo, + IndexInfo, PrimaryKey, RowDelete, RowInsert, RowUpdate, SchemaBrief, SchemaKind, TableBrief, + TableInfo, TableKind, }; pub use sql_client::SqlClient; diff --git a/engine/src/schema/data_type_category.rs b/engine/src/schema/data_type_category.rs new file mode 100644 index 0000000..c3a1f1e --- /dev/null +++ b/engine/src/schema/data_type_category.rs @@ -0,0 +1,59 @@ +/// Phân loại kiểu dữ liệu SQL theo hành vi trong UI. +/// +/// Mỗi driver implement `categorize()` để map kiểu riêng → category chung. +/// Category quyết định: +/// - Empty input → NULL hay empty string? +/// - Validate input như thế nào? +/// - Format SQL value ra sao? +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DataTypeCategory { + /// Chuỗi ký tự: VARCHAR, TEXT, CHAR, NVARCHAR, CLOB... + /// Empty input → Some("") (chuỗi rỗng hợp lệ) + Text, + /// Số nguyên: INTEGER, BIGINT, SMALLINT, SERIAL... + /// Empty input → None (NULL) + Integer, + /// Số thực: REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC... + /// Empty input → None (NULL) + Float, + /// Boolean: BOOLEAN, BOOL... + /// Empty input → None (NULL) + Boolean, + /// Ngày/giờ: DATE, TIME, TIMESTAMP, INTERVAL... + /// Empty input → None (NULL) + DateTime, + /// Dữ liệu nhị phân: BLOB, BYTEA... + /// Empty input → None (NULL) + Binary, + /// UUID + /// Empty input → None (NULL) + Uuid, + /// Không xác định (computed column, kiểu lạ) + /// Empty input → None (NULL) — an toàn hơn + Unknown, +} + +impl DataTypeCategory { + /// Kiểm tra giá trị text có hợp lệ cho category này không. + pub fn validate(&self, text: &str) -> bool { + match self { + DataTypeCategory::Integer => text.parse::().is_ok(), + DataTypeCategory::Float => text.parse::().is_ok(), + DataTypeCategory::Boolean => { + text.eq_ignore_ascii_case("true") + || text.eq_ignore_ascii_case("false") + || text == "0" + || text == "1" + } + DataTypeCategory::Text | DataTypeCategory::Unknown => true, + DataTypeCategory::DateTime => false, // TODO: Cần triển khai date parsing + DataTypeCategory::Binary => false, // TODO: Cần triển khai hex validation + DataTypeCategory::Uuid => false, // TODO: Cần triển khai UUID parsing + } + } + + /// Empty input có nên trở thành chuỗi rỗng (Some("")) thay vì NULL (None)? + pub fn allows_empty_string(&self) -> bool { + matches!(self, DataTypeCategory::Text) + } +} diff --git a/engine/src/schema/mod.rs b/engine/src/schema/mod.rs index df7d508..4ef9b01 100644 --- a/engine/src/schema/mod.rs +++ b/engine/src/schema/mod.rs @@ -1,4 +1,5 @@ mod column_info; +mod data_type_category; mod database_brief; mod foreign_key_info; mod index_info; @@ -9,6 +10,7 @@ mod table_info; mod table_kind; pub use column_info::*; +pub use data_type_category::*; pub use database_brief::*; pub use foreign_key_info::*; pub use index_info::*; @@ -16,4 +18,4 @@ pub use modification::*; pub use primary_key::*; pub use table_brief::*; pub use table_info::*; -pub use table_kind::*; \ No newline at end of file +pub use table_kind::*; diff --git a/engine/src/schema/modification.rs b/engine/src/schema/modification.rs index 3a8e641..de4e113 100644 --- a/engine/src/schema/modification.rs +++ b/engine/src/schema/modification.rs @@ -1,7 +1,7 @@ #[derive(Debug, Clone)] pub struct ColumnData { pub column_name: String, - pub value: String, + pub value: Option, pub data_type: String, } From 0edf39a183f2cead8a4bfa6cc5d43dea8b2d5528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Ho=C3=A0ng=20Hi=E1=BB=87p?= Date: Wed, 27 May 2026 18:28:11 +0700 Subject: [PATCH 07/10] refactor(grid): refactor error handling with GridError enum - Introduce GridError enum with Fatal, Commit, and None variants to properly categorize grid errors and enable fine-grained error display logic. - Update GridState.error field from Option to GridError type; initialize as GridError::None in constructor. - Add is_fatal() and message() methods to GridError for convenient error state checks and message retrieval. - Refactor SmartDataGrid rendering to use when_else with is_fatal() check: show error overlay only for fatal errors, otherwise render data table. - Apply GridError::Fatal in TableViewerTab when table loading fails; update data_changeset_builder to remove unused SharedString import. --- .../table_viewer/table_viewer_tab.rs | 4 +- .../smart_data_grid/data_changeset_builder.rs | 1 - .../src/shared/smart_data_grid/grid_error.rs | 29 +++++++- .../src/shared/smart_data_grid/grid_state.rs | 5 +- .../shared/smart_data_grid/smart_data_grid.rs | 74 ++++++++++--------- 5 files changed, 72 insertions(+), 41 deletions(-) diff --git a/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs b/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs index d0cac0a..4455ad0 100644 --- a/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs +++ b/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs @@ -5,7 +5,7 @@ use gpui_component::v_flex; use std::any::Any; use crate::panel::{TabInfo, TabItem}; -use crate::shared::smart_data_grid::SmartDataGrid; +use crate::shared::smart_data_grid::{GridError, SmartDataGrid}; /// Tab chuyên dụng để hiển thị toàn màn hình DataGrid (Table Viewer) pub struct TableViewerTab { @@ -61,7 +61,7 @@ impl TableViewerTab { grid.set_metadata(Some(table_name.clone()), pks, cx); } else if let Err(e) = result { grid.table.update(cx, |table, _cx| { - table.delegate_mut().state.error = Some(e.to_string().into()); + table.delegate_mut().state.error = GridError::Fatal(e.to_string().into()); }); eprintln!("{}", e); } diff --git a/desktop/src/shared/smart_data_grid/data_changeset_builder.rs b/desktop/src/shared/smart_data_grid/data_changeset_builder.rs index 0fcda74..9102a33 100644 --- a/desktop/src/shared/smart_data_grid/data_changeset_builder.rs +++ b/desktop/src/shared/smart_data_grid/data_changeset_builder.rs @@ -1,6 +1,5 @@ use crate::shared::smart_data_grid::grid_state::GridState; use engine::{ColumnData, DataChangeset, RowDelete, RowInsert, RowUpdate}; -use gpui::SharedString; use std::collections::HashMap; use thiserror::Error; diff --git a/desktop/src/shared/smart_data_grid/grid_error.rs b/desktop/src/shared/smart_data_grid/grid_error.rs index 0c0772b..ece147d 100644 --- a/desktop/src/shared/smart_data_grid/grid_error.rs +++ b/desktop/src/shared/smart_data_grid/grid_error.rs @@ -1,3 +1,4 @@ +use gpui::SharedString; use thiserror::Error; #[derive(Error, Debug)] @@ -7,4 +8,30 @@ pub enum StageError { #[error("Không có phiên chỉnh sửa nào đang hoạt động")] NoActiveEdit, -} \ No newline at end of file +} + +// GridError: UI state enum — quyết định cách hiển thị lỗi trên grid +#[derive(Clone, Debug)] +pub enum GridError { + /// Lỗi nghiêm trọng (không load được bảng) + Fatal(SharedString), + /// Lỗi commit changes + Commit(SharedString), + /// Không bị gì cả + None, +} + +impl GridError { + /// Kiểm tra lỗi hiện tại có phải lỗi nghiêm trọng không (không thể load được bảng) + pub fn is_fatal(&self) -> bool { + matches!(self, GridError::Fatal(_)) + } + + /// Lấy message từ GridError. Trả về chuỗi rỗng nếu None. + pub fn message(&self) -> SharedString { + match self { + GridError::Fatal(msg) | GridError::Commit(msg) => msg.clone(), + GridError::None => SharedString::new(""), + } + } +} diff --git a/desktop/src/shared/smart_data_grid/grid_state.rs b/desktop/src/shared/smart_data_grid/grid_state.rs index 88ecbf8..c2192de 100644 --- a/desktop/src/shared/smart_data_grid/grid_state.rs +++ b/desktop/src/shared/smart_data_grid/grid_state.rs @@ -1,3 +1,4 @@ +use crate::shared::smart_data_grid::GridError; use engine::Column; use gpui::SharedString; use std::collections::{HashMap, HashSet}; @@ -24,7 +25,7 @@ pub struct GridState { pub limit: usize, pub offset: usize, pub total_rows: Option, - pub error: Option, + pub error: GridError, pub is_loading: bool, pub editing_state: Option, @@ -43,7 +44,7 @@ impl GridState { limit: 1000, offset: 0, total_rows: None, - error: None, + error: GridError::None, is_loading: false, editing_state: None, } diff --git a/desktop/src/shared/smart_data_grid/smart_data_grid.rs b/desktop/src/shared/smart_data_grid/smart_data_grid.rs index a56bbfa..9e1d344 100644 --- a/desktop/src/shared/smart_data_grid/smart_data_grid.rs +++ b/desktop/src/shared/smart_data_grid/smart_data_grid.rs @@ -244,6 +244,7 @@ impl SmartDataGrid { }); } + /// Refresh lại fn on_refresh(&mut self, _: &ClickEvent, _window: &mut Window, _cx: &mut Context) { println!("SmartDataGrid: Đã bấm nút Refresh"); } @@ -280,6 +281,7 @@ impl SmartDataGrid { } } + /// Lưu các thay đổi xuống dưới database thực tế fn on_commit_changes( &mut self, _: &crate::action::datagrid::CommitChanges, @@ -557,41 +559,43 @@ impl Render for SmartDataGrid { .overflow_hidden() .font_family(cx.theme().mono_font_family.clone()) // Hiển thị error view - .when_some(state.error.as_ref(), |this, error| { - this.child( - v_flex().size_full().items_center().justify_center().child( - v_flex() - .items_center() - .gap_2() - .max_w_128() - .px_12() - .child( - div() - .line_height(px(24.)) - .text_xl() - .text_color(cx.theme().danger_foreground) - .child( - Icon::new(AppIcon::TriangleWarningFill) - .with_size(px(32.)), - ), - ) - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(error.clone()), - ), - ), - ) - }) - // Hiển thị data grid chính - .when(state.error.is_none(), |this| { - this.child( - DataTable::new(&self.table) - .bordered(false) - .scrollbar_visible(true, true), - ) - }), + .when_else( + state.error.is_fatal(), + |this| { + this.child( + v_flex().size_full().items_center().justify_center().child( + v_flex() + .items_center() + .gap_2() + .max_w_128() + .px_12() + .child( + div() + .line_height(px(24.)) + .text_xl() + .text_color(cx.theme().danger_foreground) + .child( + Icon::new(AppIcon::TriangleWarningFill) + .with_size(px(32.)), + ), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(state.error.message()), + ), + ), + ) + }, + |this| { + this.child( + DataTable::new(&self.table) + .bordered(false) + .scrollbar_visible(true, true), + ) + }, + ), ) } } From ee8b16e9a5d4e95b115d96e75f9ca26c360a740e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Ho=C3=A0ng=20Hi=E1=BB=87p?= Date: Fri, 29 May 2026 17:44:15 +0700 Subject: [PATCH 08/10] refactor(data-grid): introduce GridDataSource enum and schema caching - Extract data source abstraction into GridDataSource enum with Table and Query variants to support multiple data sources beyond TableViewer - Move data loading logic from TableViewerTab to SmartDataGrid::load() for better encapsulation; TableViewerTab now only handles grid initialization - Add schema caching to SqlClient using Arc> with get_table_info_cached() method to avoid redundant schema queries - Rename GridState::is_editable() to can_edit() and add can_paginate()/can_insert() methods for clearer intent - Add GridError::Refresh variant to distinguish refresh failures from fatal errors; update error message handling accordingly - Update DataChangesetBuilder and SmartDataGrid constructors to accept GridDataSource instead of raw table name - Fix editing activation to check can_edit() and loading state before allowing cell edits --- .../table_viewer/table_viewer_tab.rs | 62 +--- desktop/src/shared/mod.rs | 1 + .../smart_data_grid/data_changeset_builder.rs | 2 +- .../src/shared/smart_data_grid/grid_error.rs | 4 +- .../src/shared/smart_data_grid/grid_state.rs | 64 +++- .../shared/smart_data_grid/smart_data_grid.rs | 308 ++++++++++++------ engine/src/driver/mod.rs | 6 + engine/src/sql_client.rs | 53 ++- 8 files changed, 331 insertions(+), 169 deletions(-) diff --git a/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs b/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs index 4455ad0..68f781e 100644 --- a/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs +++ b/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs @@ -1,11 +1,11 @@ use assets::AppIcon; -use engine::{QueryResult, SqlClient}; +use engine::SqlClient; use gpui::*; use gpui_component::v_flex; use std::any::Any; use crate::panel::{TabInfo, TabItem}; -use crate::shared::smart_data_grid::{GridError, SmartDataGrid}; +use crate::shared::smart_data_grid::{GridDataSource, SmartDataGrid}; /// Tab chuyên dụng để hiển thị toàn màn hình DataGrid (Table Viewer) pub struct TableViewerTab { @@ -21,59 +21,27 @@ impl TableViewerTab { window: &mut Window, cx: &mut Context, ) -> Self { - let grid = cx.new(|cx| SmartDataGrid::new(client.clone(), window, cx)); + let table_name = table_name.into(); + + let grid = cx.new(|cx| { + SmartDataGrid::new( + client.clone(), + GridDataSource::Table { + source_table: table_name.clone(), + }, + window, + cx, + ) + }); let tab = Self { - table_name: table_name.into(), + table_name: table_name, client: client.clone(), grid, }; - tab.load_data(cx); tab } - - fn load_data(&self, cx: &mut Context) { - let table_name = self.table_name.clone(); - let grid_entity = self.grid.clone(); - let client = self.client.clone(); - let query = format!("SELECT * FROM \"{}\" LIMIT 1000", table_name); - - grid_entity.update(cx, |grid, cx| { - grid.table.update(cx, |table, cx| { - table.delegate_mut().state.is_loading = true; - cx.notify(); - }); - }); - - cx.spawn(async move |_, cx| { - let mut pks = Vec::new(); - if let Ok(info) = client.get_table_info(&table_name).await { - pks = info.primary_key.columns; - } - - let result = client.execute(&query).await; - - grid_entity.update(cx, |grid, cx| { - if let Ok(QueryResult::Query { columns, rows }) = result { - println!("Columns: {:?}, Rows: {}", columns, rows.len()); - grid.set_data(columns, rows, cx); - grid.set_metadata(Some(table_name.clone()), pks, cx); - } else if let Err(e) = result { - grid.table.update(cx, |table, _cx| { - table.delegate_mut().state.error = GridError::Fatal(e.to_string().into()); - }); - eprintln!("{}", e); - } - - grid.table.update(cx, |table, cx| { - table.delegate_mut().state.is_loading = false; - cx.notify(); - }); - }); - }) - .detach(); - } } impl TabItem for TableViewerTab { diff --git a/desktop/src/shared/mod.rs b/desktop/src/shared/mod.rs index a8dc8dd..614e5c5 100644 --- a/desktop/src/shared/mod.rs +++ b/desktop/src/shared/mod.rs @@ -1,4 +1,5 @@ mod load_state; + pub mod smart_data_grid; pub use load_state::*; diff --git a/desktop/src/shared/smart_data_grid/data_changeset_builder.rs b/desktop/src/shared/smart_data_grid/data_changeset_builder.rs index 9102a33..70cb735 100644 --- a/desktop/src/shared/smart_data_grid/data_changeset_builder.rs +++ b/desktop/src/shared/smart_data_grid/data_changeset_builder.rs @@ -23,7 +23,7 @@ impl<'a> DataChangesetBuilder<'a> { pub fn build_changeset(&self) -> Result { let table_name = self .state - .source_table + .source_table() .as_deref() .ok_or(DataChangesetError::TableUndefinded)? .to_string(); diff --git a/desktop/src/shared/smart_data_grid/grid_error.rs b/desktop/src/shared/smart_data_grid/grid_error.rs index ece147d..f483a33 100644 --- a/desktop/src/shared/smart_data_grid/grid_error.rs +++ b/desktop/src/shared/smart_data_grid/grid_error.rs @@ -15,6 +15,8 @@ pub enum StageError { pub enum GridError { /// Lỗi nghiêm trọng (không load được bảng) Fatal(SharedString), + /// Lỗi xảy ra khi refresh không thành công + Refresh(SharedString), /// Lỗi commit changes Commit(SharedString), /// Không bị gì cả @@ -30,7 +32,7 @@ impl GridError { /// Lấy message từ GridError. Trả về chuỗi rỗng nếu None. pub fn message(&self) -> SharedString { match self { - GridError::Fatal(msg) | GridError::Commit(msg) => msg.clone(), + GridError::Fatal(msg) | GridError::Refresh(msg) | GridError::Commit(msg) => msg.clone(), GridError::None => SharedString::new(""), } } diff --git a/desktop/src/shared/smart_data_grid/grid_state.rs b/desktop/src/shared/smart_data_grid/grid_state.rs index c2192de..f4dacfd 100644 --- a/desktop/src/shared/smart_data_grid/grid_state.rs +++ b/desktop/src/shared/smart_data_grid/grid_state.rs @@ -3,19 +3,12 @@ use engine::Column; use gpui::SharedString; use std::collections::{HashMap, HashSet}; -#[derive(Clone)] -pub struct EditingState { - pub row: usize, - pub col: usize, - pub has_error: bool, -} - #[derive(Clone)] pub struct GridState { pub columns: Vec, pub original_rows: Vec>>, - pub source_table: Option, + pub data_source: GridDataSource, pub primary_keys: Vec, pub pending_edits: HashMap<(usize, usize), Option>, @@ -32,11 +25,11 @@ pub struct GridState { } impl GridState { - pub fn new() -> Self { + pub fn new(data_source: GridDataSource) -> Self { Self { columns: Vec::new(), original_rows: Vec::new(), - source_table: None, + data_source, primary_keys: Vec::new(), pending_edits: HashMap::new(), pending_deletes: HashSet::new(), @@ -65,9 +58,38 @@ impl GridState { row_ix - self.original_len() } + /// Lấy tên bảng nguồn của grid (nếu có) + pub fn source_table(&self) -> Option { + match &self.data_source { + GridDataSource::Table { source_table } => Some(source_table.clone()), + GridDataSource::Query { source_table, .. } => source_table.clone(), + } + } + + /// Kiểm tra xem có thể phân trang hay không + pub fn can_paginate(&self) -> bool { + matches!(self.data_source, GridDataSource::Table { .. }) + } + + /// Kiểm tra xem có thể thêm dòng mới vào grid hay không + /// + /// TODO: Query hoàn toàn có thể insert theo một trường hợp nào đó + pub fn can_insert(&self) -> bool { + matches!(self.data_source, GridDataSource::Table { .. }) + } + /// Kiểm tra xem grid có thể chỉnh sửa không (có source_table và primary_keys) - pub fn is_editable(&self) -> bool { - self.source_table.is_some() && !self.primary_keys.is_empty() + pub fn can_edit(&self) -> bool { + match &self.data_source { + GridDataSource::Table { .. } => !self.primary_keys.is_empty(), + GridDataSource::Query { + source_table: Some(_), + .. + } => !self.primary_keys.is_empty(), + GridDataSource::Query { + source_table: None, .. + } => false, + } } /// Row này có bị đánh dấu xóa không? @@ -116,3 +138,21 @@ impl GridState { .flatten() } } + +#[derive(Clone)] +pub struct EditingState { + pub row: usize, + pub col: usize, + pub has_error: bool, +} + +#[derive(Clone, Debug)] +pub enum GridDataSource { + /// TableViewer: data từ 1 bảng, có phân trang, thêm dòng + Table { source_table: SharedString }, + /// SQL Editor: data từ query tùy ý, không phân trang + Query { + source_table: Option, + query: SharedString, + }, +} diff --git a/desktop/src/shared/smart_data_grid/smart_data_grid.rs b/desktop/src/shared/smart_data_grid/smart_data_grid.rs index 9e1d344..c197b8f 100644 --- a/desktop/src/shared/smart_data_grid/smart_data_grid.rs +++ b/desktop/src/shared/smart_data_grid/smart_data_grid.rs @@ -1,6 +1,8 @@ +use crate::shared::smart_data_grid::{GridDataSource, GridError}; + use super::{DataChangesetBuilder, EditingState, GridDelegate, GridState, StageError}; use assets::AppIcon; -use engine::{Column, Row, SqlClient}; +use engine::{Column, QueryResult, Row, SqlClient}; use gpui::prelude::FluentBuilder; use gpui::*; use gpui_component::button::{Button, ButtonCustomVariant, ButtonVariants}; @@ -18,10 +20,15 @@ pub struct SmartDataGrid { } impl SmartDataGrid { - pub fn new(client: SqlClient, window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + client: SqlClient, + data_source: GridDataSource, + window: &mut Window, + cx: &mut Context, + ) -> Self { let cell_editor = cx.new(|cx| InputState::new(window, cx)); - let delegate = GridDelegate::new(GridState::new(), cell_editor.clone()); + let delegate = GridDelegate::new(GridState::new(data_source), cell_editor.clone()); let table = cx.new(|cx| { TableState::new(delegate, window, cx) .row_header(false) @@ -47,51 +54,142 @@ impl SmartDataGrid { let focus_handle = cx.focus_handle(); - // Bắt sự kiện từ Table (ví dụ: Double Click để Edit) cx.subscribe_in(&table, window, Self::on_table_event) .detach(); - Self { + let mut this = Self { client, table, cell_editor, focus_handle, _blur_subscription: blur_sub, - } + }; + + this.load(cx); // Initialize data + + this } - fn on_table_event( - &mut self, - table: &Entity>, - event: &TableEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - TableEvent::DoubleClickedCell(row_ix, col_ix) => { - self.activate_editor(*row_ix, *col_ix, window, cx); + /// Load data từ Database, nó sẽ thực thi truy vấn dựa trên + /// `&self.table.read(cx).delegate().state.data_source` và cập nhật lại `table`. + /// + /// - [`GridDataSource::Table`]: Truy vấn dữ liệu từ bảng cơ sở dữ liệu. + /// - [`GridDataSource::Query`]: Truy vấn dữ liệu từ câu lệnh SQL tùy chỉnh. + pub fn load(&mut self, cx: &mut Context) { + self.set_loading(true, cx); + + let client = self.client.clone(); + let data_source = &self.table.read(cx).delegate().state.data_source; + let has_existing_data = !self + .table + .read(cx) + .delegate() + .state + .original_rows + .is_empty(); + + let (query, table_name): (String, Option) = match data_source { + GridDataSource::Table { source_table } => { + let state = &self.table.read(cx).delegate().state; + let table_name = source_table.clone(); + let query = format!( + "SELECT * FROM \"{}\" LIMIT {} OFFSET {}", + table_name, state.limit, state.offset + ); + (query, Some(table_name)) } - TableEvent::SelectCell(row_ix, col_ix) => { - // Focus lại editing cell khi giá trị edit hiện tại không hợp lệ - if let Some(EditingState { - row, - col, - has_error, - }) = self.table.read(cx).delegate().state.editing_state - { - table.update(cx, |table, cx| { - table.clear_selection(cx); - }); + GridDataSource::Query { query, .. } => (query.to_string(), None), + }; - if row == *row_ix && col == *col_ix && !has_error { - self.cell_editor.update(cx, |input, cx| { - input.focus(window, cx); + cx.spawn(async move |this, cx| { + let result = client.execute(&query).await; + let table_info = match &table_name { + Some(table_name) => Some(client.get_table_info_cached(&table_name).await?), + None => None, + }; + + this.update(cx, |grid, cx| { + match result { + Ok(QueryResult::Query { columns, rows }) => { + grid.set_data(columns, rows, cx); + + table_info.inspect(|table_info| { + grid.set_primary_keys(table_info.primary_key.columns.clone(), cx) }); } - } - } - _ => {} - } + Ok(QueryResult::Execution { .. }) => { + panic!("Trường hợp này không thể xảy ra >:(") + } + Err(e) => { + // WARN: Cơ chế đúng sẽ là check initial load thì quăng lỗi Fatal (có thể là + // thêm flag nhưng sẽ bị rườm rà), nên là tạm thời làm thế này cũng được + match has_existing_data { + true => grid.set_error(GridError::Refresh(e.to_string().into()), cx), + false => grid.set_error(GridError::Fatal(e.to_string().into()), cx), + } + eprintln!("{}", e); + } + }; + grid.set_loading(false, cx); + }) + }) + .detach(); + } + + /// Cập nhật dữ liệu gốc cho Grid + pub fn set_data(&mut self, columns: Vec, rows: Vec, cx: &mut Context) { + let cached_rows: Vec>> = rows + .into_iter() + .map(|row| { + row.values + .into_iter() + .map(|val| match val { + Some(v) => Some(v.to_string().into()), + None => None, + }) + .collect() + }) + .collect(); + + self.table.update(cx, |table, cx| { + let delegate = table.delegate_mut(); + delegate.state.original_rows = cached_rows; + delegate.state.columns = columns; + delegate.state.pending_edits.clear(); + delegate.state.pending_deletes.clear(); + delegate.state.pending_inserts.clear(); + + // Cập nhật lại cached_columns trong Delegate + *delegate = GridDelegate::new(delegate.state.clone(), self.cell_editor.clone()); + table.refresh(cx); + }); + + cx.notify(); + } + + /// Cấu hình primary keys cho Grid + pub fn set_primary_keys(&mut self, primary_keys: Vec, cx: &mut Context) { + self.table.update(cx, |table, _| { + let delegate = table.delegate_mut(); + delegate.state.primary_keys = primary_keys; + }); + cx.notify(); + } + + /// Đặt trạng thái loading cho Grid + pub fn set_loading(&mut self, is_loading: bool, cx: &mut Context) { + self.table.update(cx, |table, _| { + table.delegate_mut().state.is_loading = is_loading; + }); + cx.notify(); + } + + /// Đặt lỗi cho Grid + pub fn set_error(&mut self, error: GridError, cx: &mut Context) { + self.table.update(cx, |table, _| { + table.delegate_mut().state.error = error; + }); + cx.notify(); } /// Kích hoạt chế độ chỉnh sửa cho một ô cụ thể @@ -105,7 +203,8 @@ impl SmartDataGrid { let delegate = self.table.read(cx).delegate(); let state = &delegate.state; - if !state.is_editable() + if state.is_loading + || !state.can_edit() || state.editing_state.as_ref().is_some_and( |EditingState { row, @@ -136,6 +235,7 @@ impl SmartDataGrid { has_error: false, }); }); + cx.notify(); } /// Đánh dấu cell hiện tại trong trạng thái chuẩn bị thay đổi, trước khi được lưu chính thức @@ -195,58 +295,48 @@ impl SmartDataGrid { } state.editing_state = None; - cx.notify(); Ok((r, c)) }) } - /// Cập nhật dữ liệu gốc cho Grid - pub fn set_data(&mut self, columns: Vec, rows: Vec, cx: &mut Context) { - let cached_rows: Vec>> = rows - .into_iter() - .map(|row| { - row.values - .into_iter() - .map(|val| match val { - Some(v) => Some(v.to_string().into()), - None => None, - }) - .collect() - }) - .collect(); - - self.table.update(cx, |table, cx| { - let delegate = table.delegate_mut(); - delegate.state.original_rows = cached_rows; - delegate.state.columns = columns; - delegate.state.pending_edits.clear(); - delegate.state.pending_deletes.clear(); - delegate.state.pending_inserts.clear(); - - // Cập nhật lại cached_columns trong Delegate - *delegate = GridDelegate::new(delegate.state.clone(), self.cell_editor.clone()); - table.refresh(cx); - }); - } - - /// Cấu hình siêu dữ liệu để Grid biết nó có thể Edit được không - pub fn set_metadata( + fn on_table_event( &mut self, - source_table: Option, - primary_keys: Vec, + table: &Entity>, + event: &TableEvent, + window: &mut Window, cx: &mut Context, ) { - self.table.update(cx, |table, cx| { - let delegate = table.delegate_mut(); - delegate.state.source_table = source_table; - delegate.state.primary_keys = primary_keys; - cx.notify(); - }); + match event { + TableEvent::DoubleClickedCell(row_ix, col_ix) => { + self.activate_editor(*row_ix, *col_ix, window, cx); + } + TableEvent::SelectCell(row_ix, col_ix) => { + // Focus lại editing cell khi giá trị edit hiện tại không hợp lệ + if let Some(EditingState { + row, + col, + has_error, + }) = self.table.read(cx).delegate().state.editing_state + { + table.update(cx, |table, cx| { + table.clear_selection(cx); + }); + + if row == *row_ix && col == *col_ix && !has_error { + self.cell_editor.update(cx, |input, cx| { + input.focus(window, cx); + }); + } + } + cx.notify(); + } + _ => {} + } } - /// Refresh lại - fn on_refresh(&mut self, _: &ClickEvent, _window: &mut Window, _cx: &mut Context) { - println!("SmartDataGrid: Đã bấm nút Refresh"); + /// Refresh lại data grid + fn on_refresh(&mut self, _: &ClickEvent, _window: &mut Window, cx: &mut Context) { + self.load(cx); } /// Copy cell hoặc row đang selected vào clipboard. @@ -308,6 +398,7 @@ impl SmartDataGrid { ) { if let Some((r, c)) = self.table.read(cx).selected_cell() { self.activate_editor(r, c, window, cx); + cx.notify(); } } @@ -326,6 +417,7 @@ impl SmartDataGrid { } Err(error) => eprintln!("Enter key error: {error}"), }; + cx.notify(); } fn on_cancel_edit( @@ -342,6 +434,7 @@ impl SmartDataGrid { table.focus_handle(cx).focus(window, cx); table.delegate_mut().state.editing_state = None; }); + cx.notify(); } /// Xóa dòng đã chọn, hoặc dòng chứa ô đã chọn nếu không có dòng nào được chọn @@ -351,29 +444,28 @@ impl SmartDataGrid { _: &mut Window, cx: &mut Context, ) { - self.table.update(cx, |table, cx| { - let selected_row = if let Some(row_ix) = table.selected_row() { - row_ix - } else if let Some((row_ix, _)) = table.selected_cell() { - row_ix - } else { - return; // Không làm gì cả nếu không select được row nào + self.table.update(cx, |table, _| { + let selected_row = match (table.selected_row(), table.selected_cell()) { + (Some(row_ix), _) => row_ix, + (_, Some((row_ix, _))) => row_ix, + _ => return, // Không làm gì cả nếu cả hai đều là None }; - let delegate = table.delegate_mut(); - let state = &mut delegate.state; - if state.is_inserted_row(selected_row) { - let ix = state.insert_index(selected_row); - state.pending_inserts.remove(ix); - } else { - if state.pending_deletes.contains(&selected_row) { + let state = &mut table.delegate_mut().state; + match state.is_inserted_row(selected_row) { + true => { + let ix = state.insert_index(selected_row); + state.pending_inserts.remove(ix); + } + false if state.pending_deletes.contains(&selected_row) => { state.pending_deletes.remove(&selected_row); - } else { + } + false => { state.pending_deletes.insert(selected_row); } } - cx.notify(); }); + cx.notify(); } /// Thêm một dòng mới vào cuối bảng @@ -383,15 +475,15 @@ impl SmartDataGrid { _: &mut Window, cx: &mut Context, ) { - self.table.update(cx, |table, cx| { - let delegate = table.delegate_mut(); - let col_count = delegate.state.columns.len(); + self.table.update(cx, |table, _| { + let state = &mut table.delegate_mut().state; + let col_count = state.columns.len(); if col_count == 0 { return; } - delegate.state.pending_inserts.push(vec![None; col_count]); - cx.notify(); + state.pending_inserts.push(vec![None; col_count]); }); + cx.notify(); } fn on_discard_changes( @@ -400,21 +492,21 @@ impl SmartDataGrid { _: &mut Window, cx: &mut Context, ) { - self.table.update(cx, |table, cx| { - let delegate = table.delegate_mut(); - delegate.state.pending_edits.clear(); - delegate.state.pending_deletes.clear(); - delegate.state.pending_inserts.clear(); - delegate.state.editing_state = None; - cx.notify(); + self.table.update(cx, |table, _| { + let state = &mut table.delegate_mut().state; + state.pending_edits.clear(); + state.pending_deletes.clear(); + state.pending_inserts.clear(); + state.editing_state = None; }); + cx.notify(); } fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { let delegate = self.table.read(cx).delegate(); let state = &delegate.state; - let is_editable = state.is_editable(); + let is_editable = state.can_edit(); let has_changes = state.has_pending_changes(); let is_loading = state.is_loading; @@ -535,6 +627,7 @@ impl SmartDataGrid { impl Render for SmartDataGrid { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let state = self.table.read(cx).delegate().state.clone(); + let is_loading = state.is_loading; v_flex() .key_context("data-grid-container") @@ -558,6 +651,7 @@ impl Render for SmartDataGrid { .min_h_0() .overflow_hidden() .font_family(cx.theme().mono_font_family.clone()) + .when(is_loading, |this| this.opacity(0.5)) // Hiển thị error view .when_else( state.error.is_fatal(), diff --git a/engine/src/driver/mod.rs b/engine/src/driver/mod.rs index e6d68e8..9c6cd0b 100644 --- a/engine/src/driver/mod.rs +++ b/engine/src/driver/mod.rs @@ -212,6 +212,7 @@ pub trait DatabaseDriver: SqlDialect + Send + Sync { let primary_key = extract_primary_key(&columns); let foreign_keys = self.get_foreign_keys(table_name).await?; let indexes = self.get_indexes(table_name).await?; + Ok(TableInfo { name: table_name.to_string(), columns, @@ -221,6 +222,11 @@ pub trait DatabaseDriver: SqlDialect + Send + Sync { }) } + async fn get_table_primary_keys(&self, table_name: &str) -> Result { + let columns = self.get_columns(table_name).await?; + Ok(extract_primary_key(&columns)) + } + /// Đếm số dòng trong table. async fn get_table_row_count(&self, table_name: &str) -> Result; diff --git a/engine/src/sql_client.rs b/engine/src/sql_client.rs index fc03e5d..d9e00de 100644 --- a/engine/src/sql_client.rs +++ b/engine/src/sql_client.rs @@ -1,6 +1,10 @@ +use std::collections::HashMap; use std::ops::Deref; use std::sync::Arc; +use tokio::sync::RwLock; + +use crate::TableInfo; use crate::database_config::DatabaseConfig; use crate::driver::{self, DatabaseDriver}; use crate::error::EngineError; @@ -19,7 +23,7 @@ use crate::error::EngineError; /// so `.clone()` only increments an atomic reference count (~1ns). The underlying /// database connection, socket, and driver state are **shared**, not duplicated. /// -/// ```text +/// ``` /// let client1 = SqlClient::connect(config).await?; // 1-10ms (real I/O) /// let client2 = client1.clone(); // ~1ns (Arc clone) /// let client3 = client1.clone(); // ~1ns (Arc clone) @@ -53,7 +57,11 @@ use crate::error::EngineError; /// ``` #[derive(Clone)] pub struct SqlClient { + /// Driver của database, cung cấp các phương thức để tương tác với database. driver: Arc, + + /// Cache của schema, lưu trữ thông tin về các table đã được lấy từ database. + schema_cache: Arc>>>, } impl Deref for SqlClient { @@ -78,6 +86,7 @@ impl SqlClient { let driver = driver::create(&config).await?; Ok(Self { driver: Arc::from(driver), + schema_cache: Arc::new(RwLock::new(HashMap::new())), }) } @@ -94,6 +103,48 @@ impl SqlClient { pub fn do_nothing(&self) {} } +/// Triển khai các phương thức liên quan tới cache +impl SqlClient { + /// Lấy TableInfo với cache. Cache hit → return ngay không query DB. + pub async fn get_table_info_cached( + &self, + table_name: &str, + ) -> Result, EngineError> { + // 1. Check cache (read lock — cheap) + { + let cache = self.schema_cache.read().await; + if let Some(info) = cache.get(table_name) { + println!("Cache hit: {}", table_name); + return Ok(Arc::clone(info)); + } + } + + // 2. Cache miss → fetch from DB + println!("Cache miss: {}", table_name); + let table_info = Arc::new(self.get_table_info(table_name).await?); + + // 3. Store in cache (write lock) + { + let mut cache = self.schema_cache.write().await; + cache + .entry(table_name.to_string()) + .or_insert_with(|| Arc::clone(&table_info)); + } + Ok(table_info) + } + + /// Invalidate cache cho 1 table + pub async fn invalidate_cache(&self, table_name: &str) { + let mut cache = self.schema_cache.write().await; + cache.remove(table_name); + } + /// Invalidate toàn bộ cache + pub async fn invalidate_cache_all(&self) { + let mut cache = self.schema_cache.write().await; + cache.clear(); + } +} + #[cfg(test)] mod tests { use super::*; From d024d51b9b04f169440197bf82c40d5fbba2d643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Ho=C3=A0ng=20Hi=E1=BB=87p?= Date: Sat, 30 May 2026 08:25:51 +0700 Subject: [PATCH 09/10] refactor(smart_data_grid): improve fetch state with GridFetchState - Introduce GridFetchState enum (Idle, Loading, Loaded, Error) to replace separate is_loading and error fields in GridState for better state tracking - Remove GridError::None variant and update GridError message() to only handle actual error cases - Consolidate set_loading and set_error methods into single set_fetch_state method for unified state management - Update SmartDataGrid load() to use has_displayable_data() helper for determining if refresh error should be treated as fatal or recoverable - Add helper methods to GridFetchState (is_loading, is_idle, is_loaded, is_error, is_fatal, has_displayable_data, as_error) for convenient state checks - Update TableViewerTab and SmartDataGrid render to use fetch_state.is_loading() and fetch_state methods for error display --- .../table_viewer/table_viewer_tab.rs | 2 +- .../src/shared/smart_data_grid/grid_error.rs | 7 +-- .../smart_data_grid/grid_fetch_state.rs | 53 ++++++++++++++++ .../src/shared/smart_data_grid/grid_state.rs | 15 +++-- desktop/src/shared/smart_data_grid/mod.rs | 4 +- .../shared/smart_data_grid/smart_data_grid.rs | 62 ++++++++++--------- 6 files changed, 102 insertions(+), 41 deletions(-) create mode 100644 desktop/src/shared/smart_data_grid/grid_fetch_state.rs diff --git a/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs b/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs index 68f781e..cd44de6 100644 --- a/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs +++ b/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs @@ -51,7 +51,7 @@ impl TabItem for TableViewerTab { TabInfo { title: self.table_name.clone().into(), is_dirty: state.has_pending_changes(), - is_loading: state.is_loading, + is_loading: state.fetch_state.is_loading(), icon: AppIcon::Table, } } diff --git a/desktop/src/shared/smart_data_grid/grid_error.rs b/desktop/src/shared/smart_data_grid/grid_error.rs index f483a33..ea08756 100644 --- a/desktop/src/shared/smart_data_grid/grid_error.rs +++ b/desktop/src/shared/smart_data_grid/grid_error.rs @@ -10,17 +10,15 @@ pub enum StageError { NoActiveEdit, } -// GridError: UI state enum — quyết định cách hiển thị lỗi trên grid #[derive(Clone, Debug)] pub enum GridError { - /// Lỗi nghiêm trọng (không load được bảng) + /// Lỗi nghiêm trọng (không load được bảng), thường sử dụng khi bảng không thể load được lúc + /// khởi tạo Fatal(SharedString), /// Lỗi xảy ra khi refresh không thành công Refresh(SharedString), /// Lỗi commit changes Commit(SharedString), - /// Không bị gì cả - None, } impl GridError { @@ -33,7 +31,6 @@ impl GridError { pub fn message(&self) -> SharedString { match self { GridError::Fatal(msg) | GridError::Refresh(msg) | GridError::Commit(msg) => msg.clone(), - GridError::None => SharedString::new(""), } } } diff --git a/desktop/src/shared/smart_data_grid/grid_fetch_state.rs b/desktop/src/shared/smart_data_grid/grid_fetch_state.rs new file mode 100644 index 0000000..db8c26c --- /dev/null +++ b/desktop/src/shared/smart_data_grid/grid_fetch_state.rs @@ -0,0 +1,53 @@ +use super::GridError; + +#[derive(Debug, Clone)] +pub enum GridFetchState { + Idle, + Loading, + Loaded, + Error(GridError), +} + +impl GridFetchState { + pub fn is_loading(&self) -> bool { + matches!(self, GridFetchState::Loading) + } + + pub fn is_idle(&self) -> bool { + matches!(self, GridFetchState::Idle) + } + + pub fn is_loaded(&self) -> bool { + matches!(self, GridFetchState::Loaded) + } + + pub fn is_error(&self) -> bool { + matches!(self, GridFetchState::Error(_)) + } + + pub fn is_fatal(&self) -> bool { + matches!(self, GridFetchState::Error(GridError::Fatal(_))) + } + + /// Kiểm tra đã có dữ liệu fetch hay chưa (đã từng hoặc đang có), dùng để xác định có thể hiển + /// thị data từ GridState không? + /// + /// - `Loaded`: data vừa fetch xong, hiển thị bình thường + /// - `Error(Refresh)`: data cũ vẫn còn, hiển thị kèm thông báo lỗi + /// - `Error(Commit)`: data hợp lệ, chỉ commit thất bại + /// - Các state khác: không có data tin cậy để hiển thị + pub fn has_displayable_data(&self) -> bool { + matches!( + self, + Self::Loaded | Self::Error(GridError::Refresh(_)) | Self::Error(GridError::Commit(_)) + ) + } + + /// Trả về lỗi nếu có, None nếu không có + pub fn as_error(&self) -> Option<&GridError> { + match self { + GridFetchState::Error(e) => Some(e), + _ => None, + } + } +} diff --git a/desktop/src/shared/smart_data_grid/grid_state.rs b/desktop/src/shared/smart_data_grid/grid_state.rs index f4dacfd..b2469b4 100644 --- a/desktop/src/shared/smart_data_grid/grid_state.rs +++ b/desktop/src/shared/smart_data_grid/grid_state.rs @@ -1,4 +1,4 @@ -use crate::shared::smart_data_grid::GridError; +use super::GridFetchState; use engine::Column; use gpui::SharedString; use std::collections::{HashMap, HashSet}; @@ -18,8 +18,14 @@ pub struct GridState { pub limit: usize, pub offset: usize, pub total_rows: Option, - pub error: GridError, - pub is_loading: bool, + + /// Trạng thái fetch data, dùng để xác định có thể hiển thị data từ GridState không? + /// + /// - [`GridFetchState::Idle`]: Không có data đang được fetch + /// - [`GridFetchState::Loading`]: Đang fetch data + /// - [`GridFetchState::Loaded`]: Fetch data thành công + /// - [`GridFetchState::Error`]: Fetch data thất bại + pub fetch_state: GridFetchState, pub editing_state: Option, } @@ -37,8 +43,7 @@ impl GridState { limit: 1000, offset: 0, total_rows: None, - error: GridError::None, - is_loading: false, + fetch_state: GridFetchState::Idle, editing_state: None, } } diff --git a/desktop/src/shared/smart_data_grid/mod.rs b/desktop/src/shared/smart_data_grid/mod.rs index 8b51feb..caf6fdf 100644 --- a/desktop/src/shared/smart_data_grid/mod.rs +++ b/desktop/src/shared/smart_data_grid/mod.rs @@ -1,11 +1,13 @@ mod data_changeset_builder; mod grid_delegate; mod grid_error; +mod grid_fetch_state; mod grid_state; mod smart_data_grid; pub use data_changeset_builder::*; pub use grid_delegate::*; pub use grid_error::*; +pub use grid_fetch_state::*; pub use grid_state::*; -pub use smart_data_grid::*; \ No newline at end of file +pub use smart_data_grid::*; diff --git a/desktop/src/shared/smart_data_grid/smart_data_grid.rs b/desktop/src/shared/smart_data_grid/smart_data_grid.rs index c197b8f..262589b 100644 --- a/desktop/src/shared/smart_data_grid/smart_data_grid.rs +++ b/desktop/src/shared/smart_data_grid/smart_data_grid.rs @@ -1,6 +1,7 @@ -use crate::shared::smart_data_grid::{GridDataSource, GridError}; - -use super::{DataChangesetBuilder, EditingState, GridDelegate, GridState, StageError}; +use super::{ + DataChangesetBuilder, EditingState, GridDataSource, GridDelegate, GridError, GridFetchState, + GridState, StageError, +}; use assets::AppIcon; use engine::{Column, QueryResult, Row, SqlClient}; use gpui::prelude::FluentBuilder; @@ -76,17 +77,17 @@ impl SmartDataGrid { /// - [`GridDataSource::Table`]: Truy vấn dữ liệu từ bảng cơ sở dữ liệu. /// - [`GridDataSource::Query`]: Truy vấn dữ liệu từ câu lệnh SQL tùy chỉnh. pub fn load(&mut self, cx: &mut Context) { - self.set_loading(true, cx); + self.set_fetch_state(GridFetchState::Loading, cx); let client = self.client.clone(); let data_source = &self.table.read(cx).delegate().state.data_source; - let has_existing_data = !self + let had_data = self .table .read(cx) .delegate() .state - .original_rows - .is_empty(); + .fetch_state + .has_displayable_data(); let (query, table_name): (String, Option) = match data_source { GridDataSource::Table { source_table } => { @@ -116,21 +117,26 @@ impl SmartDataGrid { table_info.inspect(|table_info| { grid.set_primary_keys(table_info.primary_key.columns.clone(), cx) }); + + grid.set_fetch_state(GridFetchState::Loaded, cx); } Ok(QueryResult::Execution { .. }) => { panic!("Trường hợp này không thể xảy ra >:(") } Err(e) => { - // WARN: Cơ chế đúng sẽ là check initial load thì quăng lỗi Fatal (có thể là - // thêm flag nhưng sẽ bị rườm rà), nên là tạm thời làm thế này cũng được - match has_existing_data { - true => grid.set_error(GridError::Refresh(e.to_string().into()), cx), - false => grid.set_error(GridError::Fatal(e.to_string().into()), cx), + match had_data { + true => grid.set_fetch_state( + GridFetchState::Error(GridError::Refresh(e.to_string().into())), + cx, + ), + false => grid.set_fetch_state( + GridFetchState::Error(GridError::Fatal(e.to_string().into())), + cx, + ), } eprintln!("{}", e); } }; - grid.set_loading(false, cx); }) }) .detach(); @@ -176,18 +182,10 @@ impl SmartDataGrid { cx.notify(); } - /// Đặt trạng thái loading cho Grid - pub fn set_loading(&mut self, is_loading: bool, cx: &mut Context) { + /// Đặt trạng thái fetch cho Grid (Idle, Loading, Loaded, Error) + pub fn set_fetch_state(&mut self, state: GridFetchState, cx: &mut Context) { self.table.update(cx, |table, _| { - table.delegate_mut().state.is_loading = is_loading; - }); - cx.notify(); - } - - /// Đặt lỗi cho Grid - pub fn set_error(&mut self, error: GridError, cx: &mut Context) { - self.table.update(cx, |table, _| { - table.delegate_mut().state.error = error; + table.delegate_mut().state.fetch_state = state; }); cx.notify(); } @@ -203,7 +201,7 @@ impl SmartDataGrid { let delegate = self.table.read(cx).delegate(); let state = &delegate.state; - if state.is_loading + if state.fetch_state.is_loading() || !state.can_edit() || state.editing_state.as_ref().is_some_and( |EditingState { @@ -508,7 +506,7 @@ impl SmartDataGrid { let is_editable = state.can_edit(); let has_changes = state.has_pending_changes(); - let is_loading = state.is_loading; + let is_loading = state.fetch_state.is_loading(); let commit_changes_button = ButtonCustomVariant::new(cx) .color(cx.theme().green) @@ -627,7 +625,7 @@ impl SmartDataGrid { impl Render for SmartDataGrid { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let state = self.table.read(cx).delegate().state.clone(); - let is_loading = state.is_loading; + let is_loading = state.fetch_state.is_loading(); v_flex() .key_context("data-grid-container") @@ -654,7 +652,7 @@ impl Render for SmartDataGrid { .when(is_loading, |this| this.opacity(0.5)) // Hiển thị error view .when_else( - state.error.is_fatal(), + state.fetch_state.is_fatal(), |this| { this.child( v_flex().size_full().items_center().justify_center().child( @@ -677,7 +675,13 @@ impl Render for SmartDataGrid { div() .text_sm() .text_color(cx.theme().muted_foreground) - .child(state.error.message()), + .child( + state + .fetch_state + .as_error() + .map(|e| e.message()) + .unwrap_or_default(), + ), ), ), ) From 5535e67be65093b38c4c16ee7cd2284f44c7b099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Ho=C3=A0ng=20Hi=E1=BB=87p?= Date: Sat, 30 May 2026 08:32:25 +0700 Subject: [PATCH 10/10] refactor(grid): move limit and offset from GridState to GridDataSource - Remove limit and offset fields from GridState struct and its default implementation to reduce state duplication - Add limit and offset fields directly to GridDataSource::Table enum variant to keep pagination config close to its data source - Update TableViewerTab to pass limit: 1000 and offset: 0 when creating GridDataSource::Table - Refactor SmartDataGrid to read limit and offset directly from the data source instead of accessing GridState - Simplify source_table() method using `..` pattern for GridDataSource::Table destructuring --- .../tab_content/table_viewer/table_viewer_tab.rs | 2 ++ desktop/src/shared/smart_data_grid/grid_state.rs | 12 ++++++------ .../src/shared/smart_data_grid/smart_data_grid.rs | 9 ++++++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs b/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs index cd44de6..acef4ee 100644 --- a/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs +++ b/desktop/src/panel/tab_content/table_viewer/table_viewer_tab.rs @@ -28,6 +28,8 @@ impl TableViewerTab { client.clone(), GridDataSource::Table { source_table: table_name.clone(), + limit: 1000, + offset: 0, }, window, cx, diff --git a/desktop/src/shared/smart_data_grid/grid_state.rs b/desktop/src/shared/smart_data_grid/grid_state.rs index b2469b4..16dad8b 100644 --- a/desktop/src/shared/smart_data_grid/grid_state.rs +++ b/desktop/src/shared/smart_data_grid/grid_state.rs @@ -15,8 +15,6 @@ pub struct GridState { pub pending_deletes: HashSet, pub pending_inserts: Vec>>, - pub limit: usize, - pub offset: usize, pub total_rows: Option, /// Trạng thái fetch data, dùng để xác định có thể hiển thị data từ GridState không? @@ -40,8 +38,6 @@ impl GridState { pending_edits: HashMap::new(), pending_deletes: HashSet::new(), pending_inserts: Vec::new(), - limit: 1000, - offset: 0, total_rows: None, fetch_state: GridFetchState::Idle, editing_state: None, @@ -66,7 +62,7 @@ impl GridState { /// Lấy tên bảng nguồn của grid (nếu có) pub fn source_table(&self) -> Option { match &self.data_source { - GridDataSource::Table { source_table } => Some(source_table.clone()), + GridDataSource::Table { source_table, .. } => Some(source_table.clone()), GridDataSource::Query { source_table, .. } => source_table.clone(), } } @@ -154,7 +150,11 @@ pub struct EditingState { #[derive(Clone, Debug)] pub enum GridDataSource { /// TableViewer: data từ 1 bảng, có phân trang, thêm dòng - Table { source_table: SharedString }, + Table { + source_table: SharedString, + limit: usize, + offset: usize, + }, /// SQL Editor: data từ query tùy ý, không phân trang Query { source_table: Option, diff --git a/desktop/src/shared/smart_data_grid/smart_data_grid.rs b/desktop/src/shared/smart_data_grid/smart_data_grid.rs index 262589b..8d8a27c 100644 --- a/desktop/src/shared/smart_data_grid/smart_data_grid.rs +++ b/desktop/src/shared/smart_data_grid/smart_data_grid.rs @@ -90,12 +90,15 @@ impl SmartDataGrid { .has_displayable_data(); let (query, table_name): (String, Option) = match data_source { - GridDataSource::Table { source_table } => { - let state = &self.table.read(cx).delegate().state; + GridDataSource::Table { + source_table, + limit, + offset, + } => { let table_name = source_table.clone(); let query = format!( "SELECT * FROM \"{}\" LIMIT {} OFFSET {}", - table_name, state.limit, state.offset + table_name, limit, offset ); (query, Some(table_name)) }