From 82f1f77bf63333413f40ed29811d0c15a843b76c Mon Sep 17 00:00:00 2001 From: Davide Cazzetta Date: Sat, 20 Jun 2026 17:41:30 +0200 Subject: [PATCH 1/7] feat(views): add PostgreSQL materialized views support --- src-tauri/src/commands.rs | 140 ++++++++++++++++ src-tauri/src/drivers/driver_trait.rs | 44 +++++ src-tauri/src/drivers/mysql/mod.rs | 1 + src-tauri/src/drivers/postgres/mod.rs | 152 +++++++++++++++++- src-tauri/src/drivers/sqlite/mod.rs | 1 + src-tauri/src/lib.rs | 4 + src/components/layout/ExplorerSidebar.tsx | 61 +++++++ .../layout/sidebar/SidebarSchemaItem.tsx | 27 ++++ .../layout/sidebar/SidebarViewItem.tsx | 93 +++++++++-- src/contexts/DatabaseContext.ts | 1 + src/contexts/DatabaseProvider.tsx | 12 +- src/i18n/locales/en.json | 8 +- src/types/plugins.ts | 2 + .../layout/sidebar/SidebarViewItem.test.tsx | 50 ++++++ 14 files changed, 582 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index cc353745..e4bb5cf0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3703,6 +3703,146 @@ pub async fn get_view_columns( result } +#[tauri::command] +pub async fn get_materialized_views( + app: AppHandle, + connection_id: String, + schema: Option, +) -> Result, String> { + log::info!("Fetching materialized views for connection: {}", connection_id); + + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; + let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; + let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + + let drv = driver_for(&saved_conn.params.driver).await?; + let result = drv.get_materialized_views(¶ms, schema.as_deref()).await; + + match &result { + Ok(views) => log::info!( + "Retrieved {} materialized views from {}", + views.len(), + params.database + ), + Err(e) => log::error!( + "Failed to get materialized views from {}: {}", + params.database, + e + ), + } + + result +} + +#[tauri::command] +pub async fn get_materialized_view_columns( + app: AppHandle, + connection_id: String, + view_name: String, + schema: Option, +) -> Result, String> { + log::info!( + "Fetching materialized view columns for: {} on connection: {}", + view_name, + connection_id + ); + + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; + let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; + let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + + let drv = driver_for(&saved_conn.params.driver).await?; + let result = drv + .get_materialized_view_columns(¶ms, &view_name, schema.as_deref()) + .await; + + match &result { + Ok(columns) => log::info!( + "Retrieved {} columns for materialized view {}", + columns.len(), + view_name + ), + Err(e) => log::error!( + "Failed to get materialized view columns for {}: {}", + view_name, + e + ), + } + + result +} + +#[tauri::command] +pub async fn get_materialized_view_definition( + app: AppHandle, + connection_id: String, + view_name: String, + schema: Option, +) -> Result { + log::info!( + "Fetching materialized view definition for: {} on connection: {}", + view_name, + connection_id + ); + + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; + let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; + let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + + let drv = driver_for(&saved_conn.params.driver).await?; + let result = drv + .get_materialized_view_definition(¶ms, &view_name, schema.as_deref()) + .await; + + match &result { + Ok(_) => log::info!( + "Successfully retrieved materialized view definition for {}", + view_name + ), + Err(e) => log::error!( + "Failed to get materialized view definition for {}: {}", + view_name, + e + ), + } + + result +} + +#[tauri::command] +pub async fn refresh_materialized_view( + app: AppHandle, + connection_id: String, + view_name: String, + schema: Option, +) -> Result<(), String> { + log::info!( + "Refreshing materialized view: {} on connection: {}", + view_name, + connection_id + ); + + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; + let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; + let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + + let drv = driver_for(&saved_conn.params.driver).await?; + let result = drv + .refresh_materialized_view(¶ms, &view_name, schema.as_deref()) + .await; + + match &result { + Ok(_) => log::info!("Successfully refreshed materialized view: {}", view_name), + Err(e) => log::error!("Failed to refresh materialized view {}: {}", view_name, e), + } + + result +} + #[tauri::command] pub async fn get_triggers( app: AppHandle, diff --git a/src-tauri/src/drivers/driver_trait.rs b/src-tauri/src/drivers/driver_trait.rs index f92245a3..8c2b67eb 100644 --- a/src-tauri/src/drivers/driver_trait.rs +++ b/src-tauri/src/drivers/driver_trait.rs @@ -56,6 +56,10 @@ pub struct DriverCapabilities { pub schemas: bool, /// Supports views. pub views: bool, + /// Supports materialized views (e.g. PostgreSQL). Gates the + /// "Materialized Views" tree group in the UI. Defaults to `false`. + #[serde(default)] + pub materialized_views: bool, /// Supports stored procedures and functions. pub routines: bool, /// File-based database (e.g. SQLite); no host/port required. @@ -336,6 +340,46 @@ pub trait DatabaseDriver: Send + Sync { schema: Option<&str>, ) -> Result<(), String>; + // --- Materialized views ------------------------------------------------- + // Default impls return empty / unsupported so drivers without materialized + // views (MySQL, SQLite, plugins) need no changes; the UI hides the group + // unless `DriverCapabilities::materialized_views` is set. + + async fn get_materialized_views( + &self, + _params: &ConnectionParams, + _schema: Option<&str>, + ) -> Result, String> { + Ok(Vec::new()) + } + + async fn get_materialized_view_columns( + &self, + _params: &ConnectionParams, + _view_name: &str, + _schema: Option<&str>, + ) -> Result, String> { + Ok(Vec::new()) + } + + async fn get_materialized_view_definition( + &self, + _params: &ConnectionParams, + _view_name: &str, + _schema: Option<&str>, + ) -> Result { + Err("Materialized views are not supported by this driver".to_string()) + } + + async fn refresh_materialized_view( + &self, + _params: &ConnectionParams, + _view_name: &str, + _schema: Option<&str>, + ) -> Result<(), String> { + Err("Materialized views are not supported by this driver".to_string()) + } + // --- Routines ----------------------------------------------------------- async fn get_routines( diff --git a/src-tauri/src/drivers/mysql/mod.rs b/src-tauri/src/drivers/mysql/mod.rs index a01e5591..7029fbbc 100644 --- a/src-tauri/src/drivers/mysql/mod.rs +++ b/src-tauri/src/drivers/mysql/mod.rs @@ -1208,6 +1208,7 @@ impl MysqlDriver { capabilities: DriverCapabilities { schemas: false, views: true, + materialized_views: false, routines: true, file_based: false, folder_based: false, diff --git a/src-tauri/src/drivers/postgres/mod.rs b/src-tauri/src/drivers/postgres/mod.rs index 50ba7480..4c1511a0 100644 --- a/src-tauri/src/drivers/postgres/mod.rs +++ b/src-tauri/src/drivers/postgres/mod.rs @@ -388,7 +388,7 @@ pub async fn get_indexes( JOIN pg_class i ON i.oid = ix.indexrelid JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) WHERE - t.relkind = 'r' + t.relkind IN ('r', 'm') AND n.nspname = $1 AND t.relname = $2 ORDER BY @@ -1113,6 +1113,120 @@ pub async fn get_view_columns( .collect()) } +pub async fn get_materialized_views( + params: &ConnectionParams, + schema: &str, +) -> Result, String> { + log::debug!( + "PostgreSQL: Fetching materialized views for database: {} schema: {}", + params.database, + schema + ); + let pool = get_postgres_pool(params).await?; + let rows = query_all( + &pool, + "SELECT matviewname as name FROM pg_matviews WHERE schemaname = $1 ORDER BY matviewname ASC", + &[&schema], + ) + .await?; + + let views: Vec = rows + .iter() + .map(|r| ViewInfo { + name: r.try_get("name").unwrap_or_default(), + definition: None, + }) + .collect(); + Ok(views) +} + +/// Materialized views are not exposed via `information_schema.columns`, so their +/// columns must be read from the system catalog (`pg_attribute`/`pg_class`). +pub async fn get_materialized_view_columns( + params: &ConnectionParams, + view_name: &str, + schema: &str, +) -> Result, String> { + let pool = get_postgres_pool(params).await?; + let query = r#" + SELECT + a.attname AS column_name, + format_type(a.atttypid, a.atttypmod) AS data_type, + a.attnotnull AS not_null + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind = 'm' + AND a.attnum > 0 AND NOT a.attisdropped + ORDER BY a.attnum + "#; + + let rows = query_all(&pool, &query, &[&schema, &view_name]).await?; + + Ok(rows + .iter() + .map(|r| TableColumn { + name: r.try_get("column_name").unwrap_or_default(), + data_type: r.try_get("data_type").unwrap_or_default(), + is_pk: false, + is_nullable: !r.try_get::<_, bool>("not_null").unwrap_or(false), + is_auto_increment: false, + default_value: None, + character_maximum_length: None, + }) + .collect()) +} + +pub async fn get_materialized_view_definition( + params: &ConnectionParams, + view_name: &str, + schema: &str, +) -> Result { + let pool = get_postgres_pool(params).await?; + let qualified = format!( + "\"{}\".\"{}\"", + escape_identifier(schema), + escape_identifier(view_name) + ); + + let client = pool.get().await.map_err(|e| e.to_string())?; + + let row = client + .query_one( + "SELECT pg_get_viewdef($1::regclass, true) as definition", + &[&qualified], + ) + .await + .map_err(|e| format!("Failed to get materialized view definition: {}", e))?; + + let definition: String = row.try_get("definition").unwrap_or_default(); + Ok(format!( + "CREATE MATERIALIZED VIEW {} AS\n{}", + qualified, definition + )) +} + +pub async fn refresh_materialized_view( + params: &ConnectionParams, + view_name: &str, + schema: &str, +) -> Result<(), String> { + let pool = get_postgres_pool(params).await?; + let query = format!( + "REFRESH MATERIALIZED VIEW \"{}\".\"{}\"", + escape_identifier(schema), + escape_identifier(view_name) + ); + + let client = pool.get().await.map_err(|e| e.to_string())?; + client + .execute(&query, &[]) + .await + .map_err(|e| format!("Failed to refresh materialized view: {}", e))?; + + Ok(()) +} + pub async fn get_routines( params: &ConnectionParams, schema: &str, @@ -1353,6 +1467,7 @@ impl PostgresDriver { capabilities: DriverCapabilities { schemas: true, views: true, + materialized_views: true, routines: true, file_based: false, folder_based: false, @@ -1545,6 +1660,41 @@ impl DatabaseDriver for PostgresDriver { drop_view(params, view_name, self.resolve_schema(schema)).await } + async fn get_materialized_views( + &self, + params: &crate::models::ConnectionParams, + schema: Option<&str>, + ) -> Result, String> { + get_materialized_views(params, self.resolve_schema(schema)).await + } + + async fn get_materialized_view_columns( + &self, + params: &crate::models::ConnectionParams, + view_name: &str, + schema: Option<&str>, + ) -> Result, String> { + get_materialized_view_columns(params, view_name, self.resolve_schema(schema)).await + } + + async fn get_materialized_view_definition( + &self, + params: &crate::models::ConnectionParams, + view_name: &str, + schema: Option<&str>, + ) -> Result { + get_materialized_view_definition(params, view_name, self.resolve_schema(schema)).await + } + + async fn refresh_materialized_view( + &self, + params: &crate::models::ConnectionParams, + view_name: &str, + schema: Option<&str>, + ) -> Result<(), String> { + refresh_materialized_view(params, view_name, self.resolve_schema(schema)).await + } + async fn get_routines( &self, params: &crate::models::ConnectionParams, diff --git a/src-tauri/src/drivers/sqlite/mod.rs b/src-tauri/src/drivers/sqlite/mod.rs index 2d0d405c..61bb8cc0 100644 --- a/src-tauri/src/drivers/sqlite/mod.rs +++ b/src-tauri/src/drivers/sqlite/mod.rs @@ -878,6 +878,7 @@ impl SqliteDriver { capabilities: DriverCapabilities { schemas: false, views: true, + materialized_views: false, routines: false, file_based: true, folder_based: false, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e42c1003..7a4e8a91 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -318,6 +318,10 @@ pub fn run() { commands::alter_view, commands::drop_view, commands::get_view_columns, + commands::get_materialized_views, + commands::get_materialized_view_columns, + commands::get_materialized_view_definition, + commands::refresh_materialized_view, commands::set_window_title, commands::open_er_diagram_window, explain_import::load_explain_from_file, diff --git a/src/components/layout/ExplorerSidebar.tsx b/src/components/layout/ExplorerSidebar.tsx index 63b29787..0103147f 100644 --- a/src/components/layout/ExplorerSidebar.tsx +++ b/src/components/layout/ExplorerSidebar.tsx @@ -1990,6 +1990,67 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar }, ]; })() + : contextMenu.type === "materialized_view" + ? (() => { + const mvCtxSchema = contextMenu.data && "schema" in contextMenu.data ? contextMenu.data.schema : undefined; + return [ + { + label: t("sidebar.showData"), + icon: PlaySquare, + action: () => { + const quotedView = quoteTableRef(contextMenu.id, activeDriver, mvCtxSchema); + runQuery(`SELECT * FROM ${quotedView}`, undefined, contextMenu.id); + }, + }, + { + label: t("sidebar.countRows"), + icon: Hash, + action: () => { + const quotedView = quoteTableRef(contextMenu.id, activeDriver, mvCtxSchema); + runQuery(`SELECT COUNT(*) as count FROM ${quotedView}`); + }, + }, + { + label: t("sidebar.refreshMaterializedView"), + icon: RefreshCw, + action: async () => { + try { + await invoke("refresh_materialized_view", { + connectionId: activeConnectionId, + viewName: contextMenu.id, + ...(mvCtxSchema ? { schema: mvCtxSchema } : {}), + }); + showAlert(t("views.refreshSuccess", { view: contextMenu.id }), { kind: "info" }); + } catch (e) { + console.error(e); + showAlert(t("views.refreshError") + String(e), { kind: "error" }); + } + }, + }, + { + label: t("sidebar.showDefinition"), + icon: FileText, + action: async () => { + try { + const definition = await invoke("get_materialized_view_definition", { + connectionId: activeConnectionId, + viewName: contextMenu.id, + ...(mvCtxSchema ? { schema: mvCtxSchema } : {}), + }); + runQuery(definition, `${contextMenu.id} Definition`, undefined, true, mvCtxSchema, true); + } catch (e) { + console.error(e); + showAlert(t("views.failGetDefinition") + String(e), { kind: "error" }); + } + }, + }, + { + label: t("sidebar.copyName"), + icon: Copy, + action: () => navigator.clipboard.writeText(contextMenu.id), + }, + ]; + })() : contextMenu.type === "routine" ? [ { diff --git a/src/components/layout/sidebar/SidebarSchemaItem.tsx b/src/components/layout/sidebar/SidebarSchemaItem.tsx index d461ff71..10b1976c 100644 --- a/src/components/layout/sidebar/SidebarSchemaItem.tsx +++ b/src/components/layout/sidebar/SidebarSchemaItem.tsx @@ -92,6 +92,7 @@ export const SidebarSchemaItem = ({ const [prevActiveSchema, setPrevActiveSchema] = useState(activeSchema); const [tablesOpen, setTablesOpen] = useState(true); const [viewsOpen, setViewsOpen] = useState(true); + const [materializedViewsOpen, setMaterializedViewsOpen] = useState(true); const [routinesOpen, setRoutinesOpen] = useState(false); const [triggersOpen, setTriggersOpen] = useState(false); const [functionsOpen, setFunctionsOpen] = useState(true); @@ -112,6 +113,7 @@ export const SidebarSchemaItem = ({ ? tables.filter((t) => t.name.toLowerCase().includes(tableFilter.toLowerCase())) : tables; const views = schemaData?.views ?? []; + const materializedViews = schemaData?.materializedViews ?? []; const routines = schemaData?.routines ?? []; const triggers = schemaData?.triggers ?? []; const filteredTriggers = triggerFilter @@ -305,6 +307,31 @@ export const SidebarSchemaItem = ({ )} + {materializedViews.length > 0 && ( + setMaterializedViewsOpen(!materializedViewsOpen)} + > +
+ {materializedViews.map((view) => ( + onViewDoubleClick(name, schemaName)} + onContextMenu={onContextMenu} + connectionId={connectionId} + driver={driver} + schema={schemaName} + materialized + /> + ))} +
+
+ )} + {/* Triggers */} {showTriggers && ( { const { t } = useTranslation(); + const ViewIcon = materialized ? Layers : Eye; const [isExpanded, setIsExpanded] = useState(false); const [columns, setColumns] = useState([]); + const [indexes, setIndexes] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [expandIndexes, setExpandIndexes] = useState(false); const refreshColumns = React.useCallback(async () => { if (!connectionId) return; setIsLoading(true); try { - const cols = await invoke("get_view_columns", { - connectionId, - viewName: view.name, - ...(schema ? { schema } : {}), - }); + const [cols, idxs] = await Promise.all([ + invoke( + materialized ? "get_materialized_view_columns" : "get_view_columns", + { + connectionId, + viewName: view.name, + ...(schema ? { schema } : {}), + }, + ), + // Materialized views can carry indexes (regular views cannot). + materialized + ? invoke("get_indexes", { + connectionId, + tableName: view.name, + ...(schema ? { schema } : {}), + }) + : Promise.resolve([] as Index[]), + ]); setColumns(cols); + setIndexes(idxs); } catch (err) { console.error("Failed to load view columns:", err); } finally { setIsLoading(false); } - }, [connectionId, view.name, schema]); + }, [connectionId, view.name, schema, materialized]); useEffect(() => { if (isExpanded) { @@ -77,9 +98,19 @@ export const SidebarViewItem = ({ const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - onContextMenu(e, "view", view.name, view.name, { tableName: view.name, schema }); + onContextMenu(e, materialized ? "materialized_view" : "view", view.name, view.name, { tableName: view.name, schema }); }; + // API returns one row per index column; group them by index name. + const groupedIndexes = React.useMemo(() => { + const groups: Record = {}; + indexes.forEach((idx) => { + if (!groups[idx.name]) groups[idx.name] = { ...idx, columns: [] }; + groups[idx.name].columns.push(idx.column_name); + }); + return Object.values(groups); + }, [indexes]); + return (
{isExpanded ? : } - ))}
+ {materialized && ( +
+
{ + e.stopPropagation(); + setExpandIndexes(!expandIndexes); + }} + > + + {t("sidebar.indexes")} + + {groupedIndexes.length} + +
+ {expandIndexes && ( +
+ {groupedIndexes.map((idx) => ( +
+ + + {idx.name}{" "} + + ({idx.columns.join(", ")}) + + + {idx.is_unique && ( + + UNIQUE + + )} +
+ ))} +
+ )} +
+ )}
)} diff --git a/src/contexts/DatabaseContext.ts b/src/contexts/DatabaseContext.ts index c4235b3c..b0c8991e 100644 --- a/src/contexts/DatabaseContext.ts +++ b/src/contexts/DatabaseContext.ts @@ -72,6 +72,7 @@ export interface ConnectionsFile { export interface SchemaData { tables: TableInfo[]; views: ViewInfo[]; + materializedViews?: ViewInfo[]; routines: RoutineInfo[]; triggers: TriggerInfo[]; isLoading: boolean; diff --git a/src/contexts/DatabaseProvider.tsx b/src/contexts/DatabaseProvider.tsx index a6620839..b32135f3 100644 --- a/src/contexts/DatabaseProvider.tsx +++ b/src/contexts/DatabaseProvider.tsx @@ -190,9 +190,10 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { }); try { - const [tablesResult, viewsResult, routinesResult, triggersResult] = await Promise.all([ + const [tablesResult, viewsResult, materializedViewsResult, routinesResult, triggersResult] = await Promise.all([ invoke('get_tables', { connectionId: connId, schema }), invoke('get_views', { connectionId: connId, schema }), + invoke('get_materialized_views', { connectionId: connId, schema }).catch(() => [] as ViewInfo[]), invoke('get_routines', { connectionId: connId, schema }), invoke('get_triggers', { connectionId: connId, schema }).catch(() => [] as TriggerInfo[]), ]); @@ -205,6 +206,7 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { [schema]: { tables: tablesResult, views: viewsResult, + materializedViews: materializedViewsResult, routines: routinesResult, triggers: triggersResult, isLoading: false, @@ -245,9 +247,10 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { }); try { - const [tablesResult, viewsResult, routinesResult, triggersResult] = await Promise.all([ + const [tablesResult, viewsResult, materializedViewsResult, routinesResult, triggersResult] = await Promise.all([ invoke('get_tables', { connectionId: connId, schema }), invoke('get_views', { connectionId: connId, schema }), + invoke('get_materialized_views', { connectionId: connId, schema }).catch(() => [] as ViewInfo[]), invoke('get_routines', { connectionId: connId, schema }), invoke('get_triggers', { connectionId: connId, schema }).catch(() => [] as TriggerInfo[]), ]); @@ -260,6 +263,7 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { [schema]: { tables: tablesResult, views: viewsResult, + materializedViews: materializedViewsResult, routines: routinesResult, triggers: triggersResult, isLoading: false, @@ -595,9 +599,10 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { // Ignore - no saved preference exists yet } - const [tablesResult, viewsResult, routinesResult, triggersResult] = await Promise.all([ + const [tablesResult, viewsResult, materializedViewsResult, routinesResult, triggersResult] = await Promise.all([ invoke('get_tables', { connectionId, schema: preferredSchema }), invoke('get_views', { connectionId, schema: preferredSchema }), + invoke('get_materialized_views', { connectionId, schema: preferredSchema }).catch(() => [] as ViewInfo[]), invoke('get_routines', { connectionId, schema: preferredSchema }), invoke('get_triggers', { connectionId, schema: preferredSchema }).catch(() => [] as TriggerInfo[]), ]); @@ -610,6 +615,7 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { [preferredSchema]: { tables: tablesResult, views: viewsResult, + materializedViews: materializedViewsResult, routines: routinesResult, triggers: triggersResult, isLoading: false, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 47b30cab..da8448be 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -52,6 +52,9 @@ "createView": "Create New View", "views": "Views", "noViews": "No views found", + "materializedViews": "Materialized Views", + "refreshMaterializedView": "Refresh", + "showDefinition": "Show Definition", "editView": "Edit View", "viewDefinition": "View Definition", "dropView": "Drop View", @@ -1196,7 +1199,10 @@ "createSuccess": "View created successfully", "alterSuccess": "View updated successfully", "saveError": "Failed to save view: ", - "confirmAlter": "Are you sure you want to modify view \"{{view}}\"?" + "confirmAlter": "Are you sure you want to modify view \"{{view}}\"?", + "refreshSuccess": "Materialized view \"{{view}}\" refreshed", + "refreshError": "Failed to refresh materialized view: ", + "failGetDefinition": "Failed to get definition: " }, "triggers": { "createTrigger": "Create Trigger", diff --git a/src/types/plugins.ts b/src/types/plugins.ts index ca13ad89..9873a2e1 100644 --- a/src/types/plugins.ts +++ b/src/types/plugins.ts @@ -31,6 +31,8 @@ export interface DriverCapabilities { readonly?: boolean; /** Supports listing and managing database triggers. Defaults to false. */ triggers?: boolean; + /** Supports materialized views (e.g. PostgreSQL). Gates the "Materialized Views" tree group. Defaults to false. */ + materialized_views?: boolean; /** Shows the SSL/TLS configuration tab (mode + CA/client cert/key) in the connection modal. * Built-in network drivers (postgres, mysql) set this; plugins opt in via their manifest. Defaults to false. */ supports_ssl?: boolean; diff --git a/tests/components/layout/sidebar/SidebarViewItem.test.tsx b/tests/components/layout/sidebar/SidebarViewItem.test.tsx index a9a90385..125600eb 100644 --- a/tests/components/layout/sidebar/SidebarViewItem.test.tsx +++ b/tests/components/layout/sidebar/SidebarViewItem.test.tsx @@ -168,4 +168,54 @@ describe("SidebarViewItem", () => { consoleSpy.mockRestore(); }); + + describe("when materialized", () => { + const mockMaterializedInvoke = (cmd: string) => { + if (cmd === "get_materialized_view_columns") return Promise.resolve(mockColumns); + if (cmd === "get_indexes") return Promise.resolve([]); + return Promise.reject(new Error(`Unexpected command: ${cmd}`)); + }; + + it("fetches columns via get_materialized_view_columns", async () => { + vi.mocked(invoke).mockImplementation(mockMaterializedInvoke); + + render(); + fireEvent.click(screen.getByRole("button")); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("get_materialized_view_columns", { + connectionId: "conn-123", + viewName: "active_users", + }); + }); + }); + + it("also fetches indexes via get_indexes", async () => { + vi.mocked(invoke).mockImplementation(mockMaterializedInvoke); + + render(); + fireEvent.click(screen.getByRole("button")); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("get_indexes", { + connectionId: "conn-123", + tableName: "active_users", + }); + }); + }); + + it("emits a materialized_view context menu type", () => { + const onContextMenu = vi.fn(); + render(); + + fireEvent.contextMenu(screen.getByText("active_users")); + expect(onContextMenu).toHaveBeenCalledWith( + expect.anything(), + "materialized_view", + "active_users", + "active_users", + expect.objectContaining({ tableName: "active_users" }), + ); + }); + }); }); From 66d7d677e4fc863620bf9e62b835c1792ffe9d25 Mon Sep 17 00:00:00 2001 From: Davide Cazzetta Date: Tue, 23 Jun 2026 21:47:16 +0200 Subject: [PATCH 2/7] chore(i18n): add materialized views translations for all locales --- src/i18n/locales/de.json | 6 ++++++ src/i18n/locales/es.json | 6 ++++++ src/i18n/locales/fr.json | 6 ++++++ src/i18n/locales/it.json | 6 ++++++ src/i18n/locales/ja.json | 6 ++++++ src/i18n/locales/ru.json | 6 ++++++ src/i18n/locales/zh.json | 6 ++++++ 7 files changed, 42 insertions(+) diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index f02407c4..cf3738ee 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -52,6 +52,9 @@ "createView": "Neue Ansicht erstellen", "views": "Ansichten", "noViews": "Keine Ansichten gefunden", + "materializedViews": "Materialisierte Ansichten", + "refreshMaterializedView": "Aktualisieren", + "showDefinition": "Definition anzeigen", "editView": "Ansicht bearbeiten", "viewDefinition": "Ansichtsdefinition", "dropView": "Ansicht löschen", @@ -1175,6 +1178,9 @@ "createSuccess": "Ansicht erfolgreich erstellt", "alterSuccess": "Ansicht erfolgreich aktualisiert", "saveError": "Speichern der Ansicht fehlgeschlagen: ", + "refreshSuccess": "Materialisierte Ansicht \"{{view}}\" aktualisiert", + "refreshError": "Aktualisieren der materialisierten Ansicht fehlgeschlagen: ", + "failGetDefinition": "Abrufen der Definition fehlgeschlagen: ", "confirmAlter": "Möchtest du die Ansicht \"{{view}}\" wirklich ändern?" }, "community": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index a4c4b600..141116c9 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -52,6 +52,9 @@ "createView": "Crear Nueva Vista", "views": "Vistas", "noViews": "No se encontraron vistas", + "materializedViews": "Vistas Materializadas", + "refreshMaterializedView": "Actualizar", + "showDefinition": "Mostrar Definición", "editView": "Editar Vista", "viewDefinition": "Definición de Vista", "dropView": "Eliminar Vista", @@ -1170,6 +1173,9 @@ "createSuccess": "Vista creada correctamente", "alterSuccess": "Vista actualizada correctamente", "saveError": "Error al guardar la vista: ", + "refreshSuccess": "Vista materializada \"{{view}}\" actualizada", + "refreshError": "Error al actualizar la vista materializada: ", + "failGetDefinition": "Error al obtener la definición: ", "confirmAlter": "¿Estás seguro de que deseas modificar la vista \"{{view}}\"?" }, "dump": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index acd8c9da..3c42d383 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -52,6 +52,9 @@ "createView": "Créer une nouvelle vue", "views": "Vues", "noViews": "Aucune vue trouvée", + "materializedViews": "Vues matérialisées", + "refreshMaterializedView": "Actualiser", + "showDefinition": "Afficher la définition", "editView": "Modifier la vue", "viewDefinition": "Définition de la vue", "dropView": "Supprimer la vue", @@ -1175,6 +1178,9 @@ "createSuccess": "Vue créée avec succès", "alterSuccess": "Vue mise à jour avec succès", "saveError": "Échec de l’enregistrement de la vue : ", + "refreshSuccess": "Vue matérialisée \"{{view}}\" actualisée", + "refreshError": "Échec de l’actualisation de la vue matérialisée : ", + "failGetDefinition": "Échec de la récupération de la définition : ", "confirmAlter": "Voulez-vous vraiment modifier la vue \"{{view}}\" ?" }, "community": { diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index bc178b56..737972aa 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -52,6 +52,9 @@ "createView": "Crea Nuova Vista", "views": "Viste", "noViews": "Nessuna vista trovata", + "materializedViews": "Viste Materializzate", + "refreshMaterializedView": "Aggiorna", + "showDefinition": "Mostra Definizione", "editView": "Modifica Vista", "viewDefinition": "Definizione Vista", "dropView": "Elimina Vista", @@ -1177,6 +1180,9 @@ "createSuccess": "Vista creata con successo", "alterSuccess": "Vista aggiornata con successo", "saveError": "Salvataggio vista fallito: ", + "refreshSuccess": "Vista materializzata \"{{view}}\" aggiornata", + "refreshError": "Aggiornamento vista materializzata fallito: ", + "failGetDefinition": "Recupero della definizione fallito: ", "confirmAlter": "Sei sicuro di voler modificare la vista \"{{view}}\"?" }, "community": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 313fb88b..44dd6035 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -52,6 +52,9 @@ "createView": "ビューを新規作成", "views": "ビュー", "noViews": "ビューが見つかりません", + "materializedViews": "マテリアライズドビュー", + "refreshMaterializedView": "更新", + "showDefinition": "定義を表示", "editView": "ビューを編集", "viewDefinition": "ビュー定義", "dropView": "ビューを削除", @@ -1187,6 +1190,9 @@ "createSuccess": "ビューを正常に作成しました", "alterSuccess": "ビューを正常に更新しました", "saveError": "ビューの保存に失敗しました: ", + "refreshSuccess": "マテリアライズドビュー「{{view}}」を更新しました", + "refreshError": "マテリアライズドビューの更新に失敗しました: ", + "failGetDefinition": "定義の取得に失敗しました: ", "confirmAlter": "ビュー「{{view}}」を変更してもよろしいですか?" }, "triggers": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 3d2f3520..28c2cbc9 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -52,6 +52,9 @@ "createView": "Создать новое представление", "views": "Представления", "noViews": "Представления не найдены", + "materializedViews": "Материализованные представления", + "refreshMaterializedView": "Обновить", + "showDefinition": "Показать определение", "editView": "Изменить представление", "viewDefinition": "Определение представления", "dropView": "Удалить представление", @@ -1181,6 +1184,9 @@ "createSuccess": "Представление создано", "alterSuccess": "Представление обновлено", "saveError": "Не удалось сохранить представление: ", + "refreshSuccess": "Материализованное представление \"{{view}}\" обновлено", + "refreshError": "Не удалось обновить материализованное представление: ", + "failGetDefinition": "Не удалось получить определение: ", "confirmAlter": "Изменить представление \"{{view}}\"?" }, "triggers": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index ac0a38a1..03486cb2 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -51,6 +51,9 @@ "createView": "创建新视图", "views": "视图", "noViews": "未找到视图", + "materializedViews": "物化视图", + "refreshMaterializedView": "刷新", + "showDefinition": "显示定义", "editView": "编辑视图", "viewDefinition": "视图定义", "dropView": "删除视图", @@ -1120,6 +1123,9 @@ "createSuccess": "视图创建成功", "alterSuccess": "视图更新成功", "saveError": "保存视图失败:", + "refreshSuccess": "物化视图 \"{{view}}\" 已刷新", + "refreshError": "刷新物化视图失败:", + "failGetDefinition": "获取定义失败:", "confirmAlter": "确定要修改视图 \"{{view}}\" 吗?" }, "community": { From bf6256d10a4ced89b3794e06c33bbb33d3450b86 Mon Sep 17 00:00:00 2001 From: Davide Cazzetta Date: Tue, 23 Jun 2026 22:14:02 +0200 Subject: [PATCH 3/7] refactor(views): gate materialized-view fetch on driver capability --- src-tauri/src/drivers/driver_trait.rs | 5 +++-- src/contexts/DatabaseProvider.tsx | 12 +++++++++--- src/types/plugins.ts | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/drivers/driver_trait.rs b/src-tauri/src/drivers/driver_trait.rs index 8c2b67eb..19187900 100644 --- a/src-tauri/src/drivers/driver_trait.rs +++ b/src-tauri/src/drivers/driver_trait.rs @@ -56,8 +56,9 @@ pub struct DriverCapabilities { pub schemas: bool, /// Supports views. pub views: bool, - /// Supports materialized views (e.g. PostgreSQL). Gates the - /// "Materialized Views" tree group in the UI. Defaults to `false`. + /// Supports materialized views (e.g. PostgreSQL). When `false`, the + /// frontend skips the materialized-view metadata fetch entirely (so + /// other drivers don't pay for an empty round-trip). Defaults to `false`. #[serde(default)] pub materialized_views: bool, /// Supports stored procedures and functions. diff --git a/src/contexts/DatabaseProvider.tsx b/src/contexts/DatabaseProvider.tsx index b32135f3..ad7a91f2 100644 --- a/src/contexts/DatabaseProvider.tsx +++ b/src/contexts/DatabaseProvider.tsx @@ -193,7 +193,9 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { const [tablesResult, viewsResult, materializedViewsResult, routinesResult, triggersResult] = await Promise.all([ invoke('get_tables', { connectionId: connId, schema }), invoke('get_views', { connectionId: connId, schema }), - invoke('get_materialized_views', { connectionId: connId, schema }).catch(() => [] as ViewInfo[]), + (currentData.capabilities?.materialized_views + ? invoke('get_materialized_views', { connectionId: connId, schema }).catch(() => [] as ViewInfo[]) + : Promise.resolve([] as ViewInfo[])), invoke('get_routines', { connectionId: connId, schema }), invoke('get_triggers', { connectionId: connId, schema }).catch(() => [] as TriggerInfo[]), ]); @@ -250,7 +252,9 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { const [tablesResult, viewsResult, materializedViewsResult, routinesResult, triggersResult] = await Promise.all([ invoke('get_tables', { connectionId: connId, schema }), invoke('get_views', { connectionId: connId, schema }), - invoke('get_materialized_views', { connectionId: connId, schema }).catch(() => [] as ViewInfo[]), + (currentData.capabilities?.materialized_views + ? invoke('get_materialized_views', { connectionId: connId, schema }).catch(() => [] as ViewInfo[]) + : Promise.resolve([] as ViewInfo[])), invoke('get_routines', { connectionId: connId, schema }), invoke('get_triggers', { connectionId: connId, schema }).catch(() => [] as TriggerInfo[]), ]); @@ -602,7 +606,9 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { const [tablesResult, viewsResult, materializedViewsResult, routinesResult, triggersResult] = await Promise.all([ invoke('get_tables', { connectionId, schema: preferredSchema }), invoke('get_views', { connectionId, schema: preferredSchema }), - invoke('get_materialized_views', { connectionId, schema: preferredSchema }).catch(() => [] as ViewInfo[]), + (capabilities?.materialized_views + ? invoke('get_materialized_views', { connectionId, schema: preferredSchema }).catch(() => [] as ViewInfo[]) + : Promise.resolve([] as ViewInfo[])), invoke('get_routines', { connectionId, schema: preferredSchema }), invoke('get_triggers', { connectionId, schema: preferredSchema }).catch(() => [] as TriggerInfo[]), ]); diff --git a/src/types/plugins.ts b/src/types/plugins.ts index 9873a2e1..0b46a2dd 100644 --- a/src/types/plugins.ts +++ b/src/types/plugins.ts @@ -31,7 +31,7 @@ export interface DriverCapabilities { readonly?: boolean; /** Supports listing and managing database triggers. Defaults to false. */ triggers?: boolean; - /** Supports materialized views (e.g. PostgreSQL). Gates the "Materialized Views" tree group. Defaults to false. */ + /** Supports materialized views (e.g. PostgreSQL). When false, the frontend skips fetching materialized views entirely. Defaults to false. */ materialized_views?: boolean; /** Shows the SSL/TLS configuration tab (mode + CA/client cert/key) in the connection modal. * Built-in network drivers (postgres, mysql) set this; plugins opt in via their manifest. Defaults to false. */ From 5ba2bfccde685b3bfc181c418c396d9680aac541 Mon Sep 17 00:00:00 2001 From: Davide Cazzetta Date: Tue, 23 Jun 2026 22:44:13 +0200 Subject: [PATCH 4/7] feat(views): show in-flight spinner while refreshing a materialized view --- src/components/layout/ExplorerSidebar.tsx | 10 +++++-- .../layout/sidebar/SidebarSchemaItem.tsx | 3 ++ .../layout/sidebar/SidebarViewItem.tsx | 22 +++++++++----- .../layout/sidebar/SidebarViewItem.test.tsx | 29 +++++++++++++++++++ 4 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/components/layout/ExplorerSidebar.tsx b/src/components/layout/ExplorerSidebar.tsx index 0103147f..d2722d7a 100644 --- a/src/components/layout/ExplorerSidebar.tsx +++ b/src/components/layout/ExplorerSidebar.tsx @@ -194,6 +194,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar const [favoriteDeleteConfirm, setFavoriteDeleteConfirm] = useState(null); const [tableFilter, setTableFilter] = useState(""); const [favoritesFilter, setFavoritesFilter] = useState(""); + const [refreshingMatView, setRefreshingMatView] = useState(null); const [selectedFavoriteId, setSelectedFavoriteId] = useState(null); const [tablesOpen, setTablesOpen] = useState(true); const [viewsOpen, setViewsOpen] = useState(true); @@ -1102,6 +1103,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar setTriggerEditorModal({ isOpen: true, isNewTrigger: true, schema }) } showTriggers={activeCapabilities?.triggers === true} + refreshingMatView={refreshingMatView} /> ))} @@ -2014,16 +2016,20 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar label: t("sidebar.refreshMaterializedView"), icon: RefreshCw, action: async () => { + const mvName = contextMenu.id; + setRefreshingMatView(mvName); try { await invoke("refresh_materialized_view", { connectionId: activeConnectionId, - viewName: contextMenu.id, + viewName: mvName, ...(mvCtxSchema ? { schema: mvCtxSchema } : {}), }); - showAlert(t("views.refreshSuccess", { view: contextMenu.id }), { kind: "info" }); + showAlert(t("views.refreshSuccess", { view: mvName }), { kind: "info" }); } catch (e) { console.error(e); showAlert(t("views.refreshError") + String(e), { kind: "error" }); + } finally { + setRefreshingMatView(null); } }, }, diff --git a/src/components/layout/sidebar/SidebarSchemaItem.tsx b/src/components/layout/sidebar/SidebarSchemaItem.tsx index 10b1976c..eb3459e0 100644 --- a/src/components/layout/sidebar/SidebarSchemaItem.tsx +++ b/src/components/layout/sidebar/SidebarSchemaItem.tsx @@ -54,6 +54,7 @@ interface SidebarSchemaItemProps { onCreateView: () => void; onCreateTrigger: (schema: string) => void; showTriggers?: boolean; + refreshingMatView?: string | null; } export const SidebarSchemaItem = ({ @@ -83,6 +84,7 @@ export const SidebarSchemaItem = ({ onCreateView, onCreateTrigger, showTriggers = false, + refreshingMatView = null, }: SidebarSchemaItemProps) => { const { t } = useTranslation(); @@ -326,6 +328,7 @@ export const SidebarSchemaItem = ({ driver={driver} schema={schemaName} materialized + isRefreshing={refreshingMatView === view.name} /> ))} diff --git a/src/components/layout/sidebar/SidebarViewItem.tsx b/src/components/layout/sidebar/SidebarViewItem.tsx index a3aebfcc..411d2183 100644 --- a/src/components/layout/sidebar/SidebarViewItem.tsx +++ b/src/components/layout/sidebar/SidebarViewItem.tsx @@ -31,6 +31,7 @@ interface SidebarViewItemProps { driver: string; schema?: string; materialized?: boolean; + isRefreshing?: boolean; } export const SidebarViewItem = ({ @@ -43,6 +44,7 @@ export const SidebarViewItem = ({ driver, schema, materialized = false, + isRefreshing = false, }: SidebarViewItemProps) => { const { t } = useTranslation(); const ViewIcon = materialized ? Layers : Eye; @@ -130,14 +132,18 @@ export const SidebarViewItem = ({ > {isExpanded ? : } - + {isRefreshing ? ( + + ) : ( + + )} {view.name} {isExpanded && ( diff --git a/tests/components/layout/sidebar/SidebarViewItem.test.tsx b/tests/components/layout/sidebar/SidebarViewItem.test.tsx index 125600eb..a5e753f1 100644 --- a/tests/components/layout/sidebar/SidebarViewItem.test.tsx +++ b/tests/components/layout/sidebar/SidebarViewItem.test.tsx @@ -16,6 +16,23 @@ vi.mock("react-i18next", () => ({ }), })); +// Render lucide icons as identifiable stubs so tests can assert which icon +// is showing (e.g. Loader2 while refreshing vs Layers otherwise). +vi.mock("lucide-react", () => ({ + Eye: () => , + Layers: () => , + List: () => , + Loader2: () => , + Folder: () => , + ChevronDown: () => , + ChevronRight: () => , + Key: () => , + Columns: () => , + Edit: () => , + Copy: () => , + Trash2: () => , +})); + describe("SidebarViewItem", () => { const mockView = { name: "active_users" }; const mockColumns = [ @@ -217,5 +234,17 @@ describe("SidebarViewItem", () => { expect.objectContaining({ tableName: "active_users" }), ); }); + + it("shows the spinner (Loader2) instead of the view icon while refreshing", () => { + const { rerender } = render( + , + ); + expect(screen.queryByTestId("icon-Loader2")).toBeNull(); + expect(screen.queryByTestId("icon-Layers")).not.toBeNull(); + + rerender(); + expect(screen.queryByTestId("icon-Loader2")).not.toBeNull(); + expect(screen.queryByTestId("icon-Layers")).toBeNull(); + }); }); }); From 0ffb555d0142fc1982c89a70e7072b0d355e76cc Mon Sep 17 00:00:00 2001 From: Davide Cazzetta Date: Wed, 24 Jun 2026 17:51:36 +0200 Subject: [PATCH 5/7] refactor(sidebar): share index list rendering between tables and materialized views --- .../layout/sidebar/SidebarIndexList.tsx | 64 +++++++++++++++ .../layout/sidebar/SidebarTableItem.tsx | 80 +++++-------------- .../layout/sidebar/SidebarViewItem.tsx | 60 ++------------ src/utils/indexes.ts | 13 +++ 4 files changed, 103 insertions(+), 114 deletions(-) create mode 100644 src/components/layout/sidebar/SidebarIndexList.tsx create mode 100644 src/utils/indexes.ts diff --git a/src/components/layout/sidebar/SidebarIndexList.tsx b/src/components/layout/sidebar/SidebarIndexList.tsx new file mode 100644 index 00000000..32ca2d6e --- /dev/null +++ b/src/components/layout/sidebar/SidebarIndexList.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Folder, List } from "lucide-react"; +import type { GroupedIndex } from "../../../utils/indexes"; + +interface SidebarIndexListProps { + indexes: GroupedIndex[]; + isOpen: boolean; + onToggle: () => void; + onIndexContextMenu?: (e: React.MouseEvent, indexName: string) => void; + onFolderContextMenu?: (e: React.MouseEvent) => void; +} + +export const SidebarIndexList = ({ + indexes, + isOpen, + onToggle, + onIndexContextMenu, + onFolderContextMenu, +}: SidebarIndexListProps) => { + const { t } = useTranslation(); + + return ( +
+
{ + e.stopPropagation(); + onToggle(); + }} + onContextMenu={onFolderContextMenu} + > + + {t("sidebar.indexes")} + {indexes.length} +
+ {isOpen && ( +
+ {indexes.map((idx) => ( +
onIndexContextMenu(e, idx.name) : undefined + } + > + + + {idx.name}{" "} + ({idx.columns.join(", ")}) + + {idx.is_unique && ( + + UNIQUE + + )} +
+ ))} +
+ )} +
+ ); +}; diff --git a/src/components/layout/sidebar/SidebarTableItem.tsx b/src/components/layout/sidebar/SidebarTableItem.tsx index 42be1035..22be2f85 100644 --- a/src/components/layout/sidebar/SidebarTableItem.tsx +++ b/src/components/layout/sidebar/SidebarTableItem.tsx @@ -7,14 +7,15 @@ import { Folder, Link as LinkIcon, Key, - List, ChevronDown, ChevronRight, } from "lucide-react"; import clsx from "clsx"; import { SidebarColumnItem } from "./SidebarColumnItem"; +import { SidebarIndexList } from "./SidebarIndexList"; import { dragState } from "../../../utils/dragState"; import { areTableItemPropsEqual } from "../../../utils/sidebarTableItem"; +import { groupIndexes } from "../../../utils/indexes"; import type { TableColumn, ForeignKey, Index } from "../../../types/schema"; import type { ContextMenuData } from "../../../types/sidebar"; @@ -130,17 +131,7 @@ const SidebarTableItemImpl = ({ onContextMenu(e, type, name, name, { tableName: table.name, schema }); }; - // Group indexes by name since API returns one row per column - const groupedIndexes = React.useMemo(() => { - const groups: Record = {}; - indexes.forEach((idx) => { - if (!groups[idx.name]) { - groups[idx.name] = { ...idx, columns: [] }; - } - groups[idx.name].columns.push(idx.column_name); - }); - return Object.values(groups); - }, [indexes]); + const groupedIndexes = React.useMemo(() => groupIndexes(indexes), [indexes]); const keys = groupedIndexes.filter((i) => i.is_primary || i.is_unique); const indexesList = groupedIndexes; @@ -321,56 +312,21 @@ const SidebarTableItemImpl = ({ {/* Indexes Folder */} -
-
{ - e.stopPropagation(); - setExpandIndexes(!expandIndexes); - }} - onContextMenu={canManage !== false ? (e) => - handleContextMenu(e, "folder_indexes", "indexes") - : undefined} - > - - {t("sidebar.indexes")} - - {indexesList.length} - -
- {expandIndexes && ( -
- {indexesList.map((idx) => ( -
- handleContextMenu(e, "index", idx.name) - : undefined} - > - - - {idx.name}{" "} - - ({idx.columns.join(", ")}) - - - {idx.is_unique && ( - - UNIQUE - - )} -
- ))} -
- )} -
+ setExpandIndexes(!expandIndexes)} + onFolderContextMenu={ + canManage !== false + ? (e) => handleContextMenu(e, "folder_indexes", "indexes") + : undefined + } + onIndexContextMenu={ + canManage !== false + ? (e, name) => handleContextMenu(e, "index", name) + : undefined + } + /> )} diff --git a/src/components/layout/sidebar/SidebarViewItem.tsx b/src/components/layout/sidebar/SidebarViewItem.tsx index 411d2183..12671d4a 100644 --- a/src/components/layout/sidebar/SidebarViewItem.tsx +++ b/src/components/layout/sidebar/SidebarViewItem.tsx @@ -4,7 +4,6 @@ import { invoke } from "@tauri-apps/api/core"; import { Eye, Layers, - List, Loader2, Folder, ChevronDown, @@ -12,6 +11,8 @@ import { } from "lucide-react"; import clsx from "clsx"; import { SidebarColumnItem } from "./SidebarColumnItem"; +import { SidebarIndexList } from "./SidebarIndexList"; +import { groupIndexes } from "../../../utils/indexes"; import type { TableColumn, Index } from "../../../types/schema"; import type { ContextMenuData } from "../../../types/sidebar"; @@ -103,15 +104,7 @@ export const SidebarViewItem = ({ onContextMenu(e, materialized ? "materialized_view" : "view", view.name, view.name, { tableName: view.name, schema }); }; - // API returns one row per index column; group them by index name. - const groupedIndexes = React.useMemo(() => { - const groups: Record = {}; - indexes.forEach((idx) => { - if (!groups[idx.name]) groups[idx.name] = { ...idx, columns: [] }; - groups[idx.name].columns.push(idx.column_name); - }); - return Object.values(groups); - }, [indexes]); + const groupedIndexes = React.useMemo(() => groupIndexes(indexes), [indexes]); return (
@@ -179,48 +172,11 @@ export const SidebarViewItem = ({ ))}
{materialized && ( -
-
{ - e.stopPropagation(); - setExpandIndexes(!expandIndexes); - }} - > - - {t("sidebar.indexes")} - - {groupedIndexes.length} - -
- {expandIndexes && ( -
- {groupedIndexes.map((idx) => ( -
- - - {idx.name}{" "} - - ({idx.columns.join(", ")}) - - - {idx.is_unique && ( - - UNIQUE - - )} -
- ))} -
- )} -
+ setExpandIndexes(!expandIndexes)} + /> )} )} diff --git a/src/utils/indexes.ts b/src/utils/indexes.ts new file mode 100644 index 00000000..9002e2a8 --- /dev/null +++ b/src/utils/indexes.ts @@ -0,0 +1,13 @@ +import type { Index } from "../types/schema"; + +export type GroupedIndex = Index & { columns: string[] }; + +// The backend returns one row per indexed column; collapse to one entry per index. +export function groupIndexes(indexes: Index[]): GroupedIndex[] { + const groups: Record = {}; + indexes.forEach((idx) => { + if (!groups[idx.name]) groups[idx.name] = { ...idx, columns: [] }; + groups[idx.name].columns.push(idx.column_name); + }); + return Object.values(groups); +} From a46407614951606b86730bc1a6be452bee0da703 Mon Sep 17 00:00:00 2001 From: Davide Cazzetta Date: Wed, 24 Jun 2026 18:06:03 +0200 Subject: [PATCH 6/7] fix(views): collapse the materialized views group by default --- src/components/layout/sidebar/SidebarSchemaItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/sidebar/SidebarSchemaItem.tsx b/src/components/layout/sidebar/SidebarSchemaItem.tsx index eb3459e0..af76973f 100644 --- a/src/components/layout/sidebar/SidebarSchemaItem.tsx +++ b/src/components/layout/sidebar/SidebarSchemaItem.tsx @@ -94,7 +94,7 @@ export const SidebarSchemaItem = ({ const [prevActiveSchema, setPrevActiveSchema] = useState(activeSchema); const [tablesOpen, setTablesOpen] = useState(true); const [viewsOpen, setViewsOpen] = useState(true); - const [materializedViewsOpen, setMaterializedViewsOpen] = useState(true); + const [materializedViewsOpen, setMaterializedViewsOpen] = useState(false); const [routinesOpen, setRoutinesOpen] = useState(false); const [triggersOpen, setTriggersOpen] = useState(false); const [functionsOpen, setFunctionsOpen] = useState(true); From af110d2215249e4f5191755d4cf7c7259acd9ee6 Mon Sep 17 00:00:00 2001 From: Davide Cazzetta Date: Wed, 24 Jun 2026 21:56:27 +0200 Subject: [PATCH 7/7] refactor(views): drop redundant reference in get_materialized_view_columns --- src-tauri/src/drivers/postgres/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/drivers/postgres/mod.rs b/src-tauri/src/drivers/postgres/mod.rs index 4c1511a0..8a17875d 100644 --- a/src-tauri/src/drivers/postgres/mod.rs +++ b/src-tauri/src/drivers/postgres/mod.rs @@ -1161,7 +1161,7 @@ pub async fn get_materialized_view_columns( ORDER BY a.attnum "#; - let rows = query_all(&pool, &query, &[&schema, &view_name]).await?; + let rows = query_all(&pool, query, &[&schema, &view_name]).await?; Ok(rows .iter()