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/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/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/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/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/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..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 @@ -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::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,70 +21,39 @@ 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(), + limit: 1000, + offset: 0, + }, + 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 { - eprintln!("TableViewerTab Lỗi: {}", e); - } - - grid.table.update(cx, |table, cx| { - table.delegate_mut().state.is_loading = false; - cx.notify(); - }); - }); - }) - .detach(); - } } 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.fetch_state.is_loading(), icon: AppIcon::Table, } } 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 5d7ad85..70cb735 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; @@ -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(); @@ -32,14 +32,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 +49,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 +59,7 @@ impl<'a> DataChangesetBuilder<'a> { }); } + // DELETE let mut deletes = Vec::new(); for &row_ix in &self.state.pending_deletes { deletes.push(RowDelete { @@ -65,10 +67,29 @@ impl<'a> DataChangesetBuilder<'a> { }); } + // INSERT + 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.as_deref().map(|v| v.to_string()), + data_type: col.declared_type.clone().unwrap_or_default(), + } + }) + .collect(); + inserts.push(RowInsert { values }); + } + Ok(DataChangeset { table_name, updates, deletes, + inserts, }) } @@ -83,7 +104,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 c3303e1..7406f4e 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}; @@ -42,19 +43,43 @@ 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 { self.cached_columns[col_ix].clone() } + fn render_tr( + &mut self, + row_ix: usize, + _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)) + } + 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, @@ -83,42 +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 is_deleted = self.state.pending_deletes.contains(&row_ix); - - let text: SharedString = - if let Some(new_val) = self.state.pending_edits.get(&(row_ix, col_ix)) { - new_val.clone().into() - } else { - self.state - .original_rows - .get(row_ix) - .and_then(|row| row.get(col_ix)) - .cloned() - .unwrap_or_else(|| "".into()) - }; - - 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() - } 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() - } else { - text.into_any_element() + + 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); + + 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).into_any_element() } fn render_empty( @@ -129,16 +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 { - if let Some(new_val) = self.state.pending_edits.get(&(row_ix, col_ix)) { - return new_val.clone(); + match self.state.cell_value(row_ix, col_ix) { + Some(val) => val.to_string(), + None => "NULL".to_string(), } - - 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_error.rs b/desktop/src/shared/smart_data_grid/grid_error.rs index 0c0772b..ea08756 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,29 @@ 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 +} + +#[derive(Clone, Debug)] +pub enum GridError { + /// 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), +} + +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::Refresh(msg) | GridError::Commit(msg) => msg.clone(), + } + } +} 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 1ed5429..16dad8b 100644 --- a/desktop/src/shared/smart_data_grid/grid_state.rs +++ b/desktop/src/shared/smart_data_grid/grid_state.rs @@ -1,59 +1,163 @@ +use super::GridFetchState; 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 original_rows: Vec>>, - pub source_table: Option, + pub data_source: GridDataSource, 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, pub total_rows: Option, - 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, } 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(), pending_inserts: Vec::new(), - limit: 1000, - offset: 0, total_rows: None, - is_loading: false, + fetch_state: GridFetchState::Idle, editing_state: None, } } - pub fn is_editable(&self) -> bool { - self.source_table.is_some() && !self.primary_keys.is_empty() + /// 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() + } + + /// 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 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? + 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) -> Option { + // Pending edit (cả original lẫn inserted đều có thể có) + if let Some(editing_value) = self.pending_edits.get(&(row_ix, col_ix)) { + return editing_value.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() + .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)) + .cloned() + .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, + limit: usize, + offset: usize, + }, + /// 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/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 a015640..8d8a27c 100644 --- a/desktop/src/shared/smart_data_grid/smart_data_grid.rs +++ b/desktop/src/shared/smart_data_grid/smart_data_grid.rs @@ -1,104 +1,196 @@ -use super::{DataChangesetBuilder, EditingState, GridDelegate, GridState, StageError}; +use super::{ + DataChangesetBuilder, EditingState, GridDataSource, GridDelegate, GridError, GridFetchState, + 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}; use gpui_component::input::InputState; use gpui_component::table::{DataTable, TableDelegate, TableEvent, TableState}; -use gpui_component::{ActiveTheme, Disableable, Icon, 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 - } -} +use gpui_component::{ActiveTheme, Disableable, Icon, Sizable, h_flex, v_flex}; /// 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, pub table: Entity>, pub cell_editor: Entity, + focus_handle: FocusHandle, _blur_subscription: gpui::Subscription, } 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) .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) .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_fetch_state(GridFetchState::Loading, cx); + + let client = self.client.clone(); + let data_source = &self.table.read(cx).delegate().state.data_source; + let had_data = self + .table + .read(cx) + .delegate() + .state + .fetch_state + .has_displayable_data(); + + let (query, table_name): (String, Option) = match data_source { + GridDataSource::Table { + source_table, + limit, + offset, + } => { + let table_name = source_table.clone(); + let query = format!( + "SELECT * FROM \"{}\" LIMIT {} OFFSET {}", + table_name, limit, 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) }); + + 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) => { + 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); + } + }; + }) + }) + .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 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.fetch_state = state; + }); + cx.notify(); } /// Kích hoạt chế độ chỉnh sửa cho một ô cụ thể @@ -112,7 +204,8 @@ impl SmartDataGrid { let delegate = self.table.read(cx).delegate(); let state = &delegate.state; - if !state.is_editable() + if state.fetch_state.is_loading() + || !state.can_edit() || state.editing_state.as_ref().is_some_and( |EditingState { row, @@ -124,7 +217,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); @@ -139,6 +236,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 @@ -150,95 +248,96 @@ 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 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; - cx.notify(); - return Ok((r, c)); - } + 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) { - delegate.state.pending_edits.insert((r, c), value); - delegate.state.editing_state = None; - cx.notify(); - Ok((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 - ))) + 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), + }; + } } - }) - } - - /// 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) => v.to_string().into(), - None => "NULL".into(), - }) - .collect() - }) - .collect(); - self.table.update(cx, |table, cx| { - let delegate = table.delegate_mut(); - delegate.state.columns = columns; - delegate.state.original_rows = cached_rows; - 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); - }); + state.editing_state = None; + Ok((r, c)) + }) } - /// 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(); + } + _ => {} + } } - 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. @@ -247,7 +346,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); @@ -273,10 +372,11 @@ 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, - _window: &mut Window, + _: &mut Window, cx: &mut Context, ) { let state = &self.table.read(cx).delegate().state; @@ -299,6 +399,7 @@ impl SmartDataGrid { ) { if let Some((r, c)) = self.table.read(cx).selected_cell() { self.activate_editor(r, c, window, cx); + cx.notify(); } } @@ -317,6 +418,7 @@ impl SmartDataGrid { } Err(error) => eprintln!("Enter key error: {error}"), }; + cx.notify(); } fn on_cancel_edit( @@ -333,15 +435,81 @@ 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 + fn on_delete_row( + &mut self, + _: &crate::action::datagrid::DeleteRow, + _: &mut Window, + cx: &mut Context, + ) { + 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 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); + } + false => { + state.pending_deletes.insert(selected_row); + } + } + }); + 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, + _: &mut Window, + cx: &mut Context, + ) { + self.table.update(cx, |table, _| { + let state = &mut table.delegate_mut().state; + let col_count = state.columns.len(); + if col_count == 0 { + return; + } + state.pending_inserts.push(vec![None; col_count]); + }); + cx.notify(); + } + + fn on_discard_changes( + &mut self, + _: &crate::action::datagrid::DiscardChanges, + _: &mut Window, + cx: &mut Context, + ) { + 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; + let is_loading = state.fetch_state.is_loading(); let commit_changes_button = ButtonCustomVariant::new(cx) .color(cx.theme().green) @@ -376,7 +544,17 @@ impl SmartDataGrid { .size_6() .cursor_pointer() .icon(AppIcon::Plus) - .disabled(!is_editable || is_loading), + .disabled(!is_editable || is_loading) + .on_click({ + let focus_handle = self.focus_handle.clone(); + move |_, window, cx| { + focus_handle.dispatch_action( + &crate::action::datagrid::AddRow, + window, + cx, + ); + } + }), ) .child( Button::new("btn-delete-row") @@ -384,7 +562,17 @@ impl SmartDataGrid { .size_6() .cursor_pointer() .icon(AppIcon::Minus) - .disabled(!is_editable || is_loading), + .disabled(!is_editable || is_loading) + .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)) .child( @@ -395,9 +583,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), ) @@ -409,7 +603,17 @@ impl SmartDataGrid { .size_6() .cursor_pointer() .icon(Icon::new(AppIcon::X)) - .disabled(!has_changes || is_loading), + .disabled(!has_changes || is_loading) + .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()) .child( @@ -423,10 +627,17 @@ 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.fetch_state.is_loading(); + 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)) + .on_action(cx.listener(Self::on_delete_row)) + .on_action(cx.listener(Self::on_discard_changes)) .size_full() .child(self.render_toolbar(cx)) .child( @@ -440,11 +651,51 @@ impl Render for SmartDataGrid { .min_w_0() .min_h_0() .overflow_hidden() - .child( - DataTable::new(&self.table) - .stripe(true) - .bordered(false) - .scrollbar_visible(true, true), + .font_family(cx.theme().mono_font_family.clone()) + .when(is_loading, |this| this.opacity(0.5)) + // Hiển thị error view + .when_else( + state.fetch_state.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 + .fetch_state + .as_error() + .map(|e| e.message()) + .unwrap_or_default(), + ), + ), + ), + ) + }, + |this| { + this.child( + DataTable::new(&self.table) + .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/engine/src/driver/mod.rs b/engine/src/driver/mod.rs index b32f0ef..9c6cd0b 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::>() @@ -117,6 +122,30 @@ 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| { + 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!( + "INSERT INTO {} ({}) VALUES ({});\n", + self.quote_identifier(&changeset.table_name), + columns, + values + )); + } + script } @@ -183,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, @@ -192,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; @@ -219,6 +254,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 fa7a7fc..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`. @@ -414,7 +430,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}, }; @@ -896,19 +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: Some("3".to_string()), data_type: "INTEGER".to_string(), }], }], diff --git a/engine/src/lib.rs b/engine/src/lib.rs index 5f07cec..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, 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 872d11c..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, } @@ -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, } 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::*; diff --git a/themes/truyvansql.json b/themes/truyvansql.json index 963ef5f..c30995d 100644 --- a/themes/truyvansql.json +++ b/themes/truyvansql.json @@ -15,8 +15,10 @@ "foreground": "#000000", "border": "#e4e4e7", "ring": "#0060de", - "danger.foreground": "#ffffff", - "danger.background": "#E7000B", + "success.background": "#B9F8CF60", + "danger.foreground": "#E7000B", + "danger.background": "#FFC9C960", + "warning.background": "#fff08560", "sidebar.background": "#FAFAFA00", "list.active.background": "#0060de15", "list.active.border": "#0060de",