From 4071882cb108ab69a97ba2866bdb375f88b83d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Mon, 21 Sep 2026 16:24:44 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(db):=20PostgreSQL=20=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E8=A1=A8=E4=B8=8E=E7=89=A9=E5=8C=96=E8=A7=86=E5=9B=BE=E7=BA=B3?= =?UTF-8?q?=E5=85=A5=E6=A0=91=E3=80=81=E5=8A=A8=E4=BD=9C=E4=B8=8E=E8=BD=AC?= =?UTF-8?q?=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PG 连接的表目录写死 `c.relkind = 'r'`,外部表('f')和分区表('p')根本列不出来, 物化视图也找不到入口(视图走 `information_schema.views`,它不含物化视图)——与 Navicat 的行为不一致,用户已经建好的外部表在 Navop 里等于不存在。 - 列表:`list_tables` / `list_tables_view` 放开 relkind 到 `'r','p','f'`,按 relkind 映射对象类型('f' → ForeignTable,'p'/'r' → Table),对象列表「类型」列分别显示 Table / Partitioned Table / Foreign Table;新增 `list_materialized_views`(走 `pg_class` relkind='m' + `pg_get_viewdef`),物化视图从普通视图里彻底分离。 - 树:新增 `DbNodeType::{ForeignTable, MaterializedViewsFolder, MaterializedView}`, 外部表与普通表/分区表同处「表」目录(对齐 Navicat),物化视图单独一个目录;目录由 `supports_materialized_views` 能力位控制,MySQL 等其它驱动不受影响。 - 动作:外部表与物化视图按 node_type 各自 scope 出菜单(Open / Rename / Truncate / Drop / Dump / Import / Export 等),删除与重命名按类型分派到 `drop_foreign_table` / `rename_foreign_table` / `drop_materialized_view`——不能把 `DROP TABLE` 打到外部表 上。设计表 / 复制表 / 结构转储等尚无实现的动作不注册,因此不会出现点了没反应的项。 - 结构比较与结构转储:新增 `TableObjectType::ForeignTable`,`is_ddl_comparable()` 只让 普通表参与,外部表自动从结构比较、数据比较、ER 图、整库 DDL 转储中排除(数据转储 仍可用);`resolve_sql_dump_target` 补上新类型的映射,避免 Dump 静默报错。 - schema 限定:`rename_foreign_table` / `drop_materialized_view` 接受 schema 并生成 schema 限定名——执行会话不会切 search_path,非 public schema 下的 DDL 原先会打错 对象(或直接报错)。 验证: - `cargo test -p db --lib` 1305 passed / 0 failed;`cargo test -p db_view --lib` 703 passed / 0 failed / 1 ignored;`cargo check --workspace --all-targets` 无 error/warning;对新增行做 clippy 比对,无新增告警。 - 新增真实库集成测试 `crates/db/tests/real_databases/postgres/foreign_matview.rs` (设置 `ONETCLI_TEST_POSTGRES_PASSWORD` 后才运行):在非 public schema 下真建外部表 (postgres_fdw)、分区表与物化视图,断言列表类型、对象面板类型与树的归属,并真实 执行 rename / drop 外部表和 drop 物化视图,最后校验 `list_views` 不混入物化视图。 `cargo test -p db --test real_postgres` 5 passed;测试自带清理,不在库里留 schema、 外部服务器或扩展。 - 未验证:GUI 端到端(右键菜单实际点击路径)没有真机回归,只覆盖到事件与 SQL 生成层。 --- crates/db/locales/db.yml | 4 + crates/db/src/compare/orchestrator.rs | 3 + crates/db/src/manager.rs | 113 +++- crates/db/src/mysql/plugin.rs | 1 + crates/db/src/plugin.rs | 148 +++++- crates/db/src/plugin_manifest.rs | 3 + crates/db/src/postgresql/plugin.rs | 500 +++++++++++++++++- crates/db/src/types.rs | 26 +- .../postgres/foreign_matview.rs | 386 ++++++++++++++ .../db/tests/real_databases/postgres/mod.rs | 1 + crates/db_view/locales/db_view.yml | 8 + crates/db_view/src/database_objects_tab.rs | 151 +++++- crates/db_view/src/database_view_plugin.rs | 39 +- crates/db_view/src/db_tree_event.rs | 87 ++- crates/db_view/src/db_tree_view.rs | 31 +- .../src/import_export/sql_dump_target.rs | 30 +- .../src/import_export/sql_dump_view.rs | 17 +- crates/db_view/src/sql_editor_view.rs | 4 + 18 files changed, 1489 insertions(+), 63 deletions(-) create mode 100644 crates/db/tests/real_databases/postgres/foreign_matview.rs diff --git a/crates/db/locales/db.yml b/crates/db/locales/db.yml index 9fe62a9c6..8059d97cb 100644 --- a/crates/db/locales/db.yml +++ b/crates/db/locales/db.yml @@ -683,6 +683,10 @@ ObjectView: en: "%{count} view(s)" zh-CN: "%{count} 个视图" zh-HK: "%{count} 個檢視" + materialized_views: + en: "%{count} materialized view(s)" + zh-CN: "%{count} 个物化视图" + zh-HK: "%{count} 個實體化檢視" functions: en: "%{count} function(s)" zh-CN: "%{count} 个函数" diff --git a/crates/db/src/compare/orchestrator.rs b/crates/db/src/compare/orchestrator.rs index 893f36ad3..dd4a5f425 100644 --- a/crates/db/src/compare/orchestrator.rs +++ b/crates/db/src/compare/orchestrator.rs @@ -541,6 +541,9 @@ pub fn table_schema_from_metadata( object_type: match table.object_type { TableObjectType::Table => SchemaObjectType::Table, TableObjectType::View => SchemaObjectType::View, + // 外部表不在结构比较范围内(调用方按 `is_ddl_comparable` 过滤), + // 万一传入也按表处理,避免凭空生成新对象类型。 + TableObjectType::ForeignTable => SchemaObjectType::Table, }, columns: columns .into_iter() diff --git a/crates/db/src/manager.rs b/crates/db/src/manager.rs index 94585c198..c60c5c603 100644 --- a/crates/db/src/manager.rs +++ b/crates/db/src/manager.rs @@ -20,7 +20,7 @@ use crate::runtime_contract::require_tokio_runtime; use crate::sqlite::SqlitePlugin; use crate::{ DbNode, DbNodeType, ExecOptions, SqlErrorInfo, SqlResult, SqlSource, TableDesign, - TableSaveResponse, + TableObjectType, TableSaveResponse, }; use dashmap::DashMap; use gpui::{AppContext, AsyncApp, Global, Task}; @@ -1431,12 +1431,40 @@ impl GlobalDbState { database: String, schema: Option, table_name: String, + ) -> anyhow::Result { + self.drop_table_like( + cx, + config_id, + database, + schema, + table_name, + TableObjectType::Table, + ) + .await + } + + /// 删除「表」目录下的对象。 + /// + /// 外部表必须使用 `DROP FOREIGN TABLE`,否则 PostgreSQL 会拒绝执行。 + pub async fn drop_table_like( + &self, + cx: &mut AsyncApp, + config_id: String, + database: String, + schema: Option, + table_name: String, + object_type: TableObjectType, ) -> anyhow::Result { let mut config = self .get_config(&config_id) .ok_or_else(|| anyhow::anyhow!("Connection not found: {}", config_id))?; let plugin = self.get_plugin(&config.database_type)?; - let sql = plugin.drop_table(&database, schema.as_deref(), &table_name); + let sql = match object_type { + TableObjectType::ForeignTable => { + plugin.drop_foreign_table(&database, schema.as_deref(), &table_name) + } + _ => plugin.drop_table(&database, schema.as_deref(), &table_name), + }; // For non-Oracle databases, modify config.database to switch database if config.database_type != DatabaseType::Oracle { @@ -1497,12 +1525,64 @@ impl GlobalDbState { database: String, old_name: String, new_name: String, + ) -> anyhow::Result { + self.rename_table_like( + cx, + config_id, + database, + None, + old_name, + new_name, + TableObjectType::Table, + ) + .await + } + + /// 重命名「表」目录下的对象(外部表需 `ALTER FOREIGN TABLE`)。 + pub async fn rename_table_like( + &self, + cx: &mut AsyncApp, + config_id: String, + database: String, + schema: Option, + old_name: String, + new_name: String, + object_type: TableObjectType, ) -> anyhow::Result { let mut config = self .get_config(&config_id) .ok_or_else(|| anyhow::anyhow!("Connection not found: {}", config_id))?; let plugin = self.get_plugin(&config.database_type)?; - let sql = plugin.rename_table(&database, &old_name, &new_name); + let sql = match object_type { + TableObjectType::ForeignTable => { + plugin.rename_foreign_table(&database, schema.as_deref(), &old_name, &new_name) + } + _ => plugin.rename_table(&database, &old_name, &new_name), + }; + + if config.database_type != DatabaseType::Oracle { + config.database = Some(database); + } + + let result = self.execute_with_session(cx, config, sql, None).await?; + + Self::wrapper_operation_result(result) + } + + /// 删除物化视图(`DROP MATERIALIZED VIEW`)。 + pub async fn drop_materialized_view( + &self, + cx: &mut AsyncApp, + config_id: String, + database: String, + schema: Option, + view_name: String, + ) -> anyhow::Result { + let mut config = self + .get_config(&config_id) + .ok_or_else(|| anyhow::anyhow!("Connection not found: {}", config_id))?; + let plugin = self.get_plugin(&config.database_type)?; + let sql = plugin.drop_materialized_view(&database, schema.as_deref(), &view_name); if config.database_type != DatabaseType::Oracle { config.database = Some(database); @@ -2868,6 +2948,21 @@ impl GlobalDbState { }) } + /// List materialized views(仅 PostgreSQL 等支持物化视图的数据库) + pub async fn list_materialized_views_view( + &self, + cx: &mut AsyncApp, + connection_id: String, + database: String, + schema: Option, + ) -> anyhow::Result { + with_plugin_session_db!(self, cx, connection_id, database.clone(), |plugin, conn| { + plugin + .list_materialized_views_view(&*conn, &database, schema) + .await + }) + } + /// List functions view pub async fn list_functions_view( &self, @@ -3124,11 +3219,17 @@ impl GlobalDbState { .list_tables_view(&*conn, &database, schema) .await .ok(), - DbNodeType::Table | DbNodeType::ColumnsFolder => plugin - .list_columns_view(&*conn, &database, schema, &table) + DbNodeType::Table | DbNodeType::ForeignTable | DbNodeType::ColumnsFolder => { + plugin + .list_columns_view(&*conn, &database, schema, &table) + .await + .ok() + } + DbNodeType::ViewsFolder => plugin.list_views_view(&*conn, &database).await.ok(), + DbNodeType::MaterializedViewsFolder => plugin + .list_materialized_views_view(&*conn, &database, schema) .await .ok(), - DbNodeType::ViewsFolder => plugin.list_views_view(&*conn, &database).await.ok(), DbNodeType::FunctionsFolder => plugin .list_functions_view_in_schema(&*conn, &database, schema) .await diff --git a/crates/db/src/mysql/plugin.rs b/crates/db/src/mysql/plugin.rs index 861d24958..e896e50fd 100644 --- a/crates/db/src/mysql/plugin.rs +++ b/crates/db/src/mysql/plugin.rs @@ -244,6 +244,7 @@ fn build_mysql_ui_manifest() -> DatabaseUiManifest { supports_schema: false, uses_schema_as_database: false, supports_views: true, + supports_materialized_views: false, supports_indexes: true, supports_users: true, supports_user_create: true, diff --git a/crates/db/src/plugin.rs b/crates/db/src/plugin.rs index 839775301..ca53a7a9c 100644 --- a/crates/db/src/plugin.rs +++ b/crates/db/src/plugin.rs @@ -291,6 +291,58 @@ impl SqlCompletionInfo { } } +fn table_like_node_type(object_type: TableObjectType) -> DbNodeType { + match object_type { + TableObjectType::ForeignTable => DbNodeType::ForeignTable, + _ => DbNodeType::Table, + } +} + +/// 构建「物化视图」目录节点及其子节点。 +/// +/// `folder_id` 同时作为子节点 id 前缀,与其他目录(Views/Functions)保持一致。 +fn build_materialized_views_folder( + node: &DbNode, + base_metadata: &HashMap, + views: Vec, + folder_id: String, +) -> DbNode { + let mut folder = DbNode::new( + folder_id.clone(), + "DbTree.MaterializedViews".to_string(), + DbNodeType::MaterializedViewsFolder, + node.connection_id.clone(), + node.database_type.clone(), + ) + .with_parent_context(node.id.clone()) + .with_metadata(base_metadata.clone()); + + if !views.is_empty() { + let mut children: Vec = views + .into_iter() + .map(|view| { + let mut metadata = base_metadata.clone(); + if let Some(comment) = view.comment.filter(|comment| !comment.is_empty()) { + metadata.insert("comment".to_string(), comment); + } + DbNode::new( + format!("{}:{}", folder_id, view.name), + view.name.clone(), + DbNodeType::MaterializedView, + node.connection_id.clone(), + node.database_type.clone(), + ) + .with_parent_context(folder_id.clone()) + .with_metadata(metadata) + }) + .collect(); + children.sort(); + folder.set_children(children); + } + + folder +} + fn routine_node( routine: FunctionInfo, node_type: DbNodeType, @@ -685,6 +737,30 @@ pub trait DatabasePlugin: Send + Sync { database: &str, ) -> Result; + // === Materialized View Operations === + /// 列举物化视图。 + /// + /// 默认返回空集:只有支持物化视图的数据库(如 PostgreSQL)才需要重写, + /// 并需同时打开 `DatabaseUiCapabilities::supports_materialized_views`。 + async fn list_materialized_views( + &self, + _connection: &dyn DbConnection, + _database: &str, + _schema: Option, + ) -> Result> { + Ok(Vec::new()) + } + + /// 物化视图对象列表视图(表格形式)。 + async fn list_materialized_views_view( + &self, + _connection: &dyn DbConnection, + _database: &str, + _schema: Option, + ) -> Result { + Ok(ObjectView::default()) + } + // === Function Operations === async fn list_functions( @@ -1104,7 +1180,7 @@ pub trait DatabasePlugin: Send + Sync { DbNode::new( format!("{}:table_folder:{}", id, table_info.name), table_info.name.clone(), - DbNodeType::Table, + table_like_node_type(table_info.object_type), node.connection_id.clone(), node.database_type.clone(), ) @@ -1162,6 +1238,20 @@ pub trait DatabasePlugin: Send + Sync { nodes.push(views_folder); } + // Materialized views folder(仅支持物化视图的数据库,如 PostgreSQL) + if capabilities.supports_materialized_views { + let matviews = self + .list_materialized_views(connection, database, schema.clone()) + .await + .unwrap_or_default(); + nodes.push(build_materialized_views_folder( + node, + &metadata, + matviews, + format!("{}:matviews_folder", id), + )); + } + // Functions folder if capabilities.supports_functions { let functions = self @@ -1341,6 +1431,7 @@ pub trait DatabasePlugin: Send + Sync { } DbNodeType::TablesFolder | DbNodeType::ViewsFolder + | DbNodeType::MaterializedViewsFolder | DbNodeType::FunctionsFolder | DbNodeType::ProceduresFolder | DbNodeType::SequencesFolder => { @@ -1393,7 +1484,35 @@ pub trait DatabasePlugin: Send + Sync { DbNode::new( format!("{}:{}", id, t.name), t.name.clone(), - DbNodeType::Table, + table_like_node_type(t.object_type), + node.connection_id.clone(), + node.database_type.clone(), + ) + .with_parent_context(id) + .with_metadata(meta) + }) + .collect(); + children.sort(); + Ok(children) + } + DbNodeType::MaterializedViewsFolder => { + if !self.capabilities().supports_materialized_views { + return Ok(Vec::new()); + } + let views = self + .list_materialized_views(connection, database, schema) + .await?; + let mut children: Vec = views + .into_iter() + .map(|v| { + let mut meta = node.metadata.clone(); + if let Some(comment) = v.comment.filter(|comment| !comment.is_empty()) { + meta.insert("comment".to_string(), comment); + } + DbNode::new( + format!("{}:{}", id, v.name), + v.name.clone(), + DbNodeType::MaterializedView, node.connection_id.clone(), node.database_type.clone(), ) @@ -2826,6 +2945,31 @@ pub trait DatabasePlugin: Send + Sync { /// Rename table fn rename_table(&self, database: &str, old_name: &str, new_name: &str) -> String; + /// 删除外部表(`DROP FOREIGN TABLE`)。 + /// + /// 默认退化为 `drop_table`:没有外部表概念的数据库不会走到这个分支。 + fn drop_foreign_table(&self, database: &str, schema: Option<&str>, table: &str) -> String { + self.drop_table(database, schema, table) + } + + /// 重命名外部表(`ALTER FOREIGN TABLE ... RENAME TO ...`)。 + /// + /// `schema` 非空时限定旧表名,避免依赖会话 `search_path` 而改错 schema 下的同名表。 + fn rename_foreign_table( + &self, + database: &str, + _schema: Option<&str>, + old_name: &str, + new_name: &str, + ) -> String { + self.rename_table(database, old_name, new_name) + } + + /// 删除物化视图(`DROP MATERIALIZED VIEW`)。 + fn drop_materialized_view(&self, database: &str, _schema: Option<&str>, view: &str) -> String { + self.drop_view(database, view) + } + /// Build native backup-table SQL. /// 默认实现使用 `CREATE TABLE ... AS SELECT ...`,数据库插件可按方言覆盖。 fn build_backup_table_sql( diff --git a/crates/db/src/plugin_manifest.rs b/crates/db/src/plugin_manifest.rs index 5aeff0a5a..dd3069e80 100644 --- a/crates/db/src/plugin_manifest.rs +++ b/crates/db/src/plugin_manifest.rs @@ -31,6 +31,8 @@ pub struct DatabaseUiCapabilities { pub supports_schema: bool, pub uses_schema_as_database: bool, pub supports_views: bool, + /// 是否支持物化视图(PostgreSQL 等)。默认关闭,避免在树中凭空出现空目录。 + pub supports_materialized_views: bool, pub supports_indexes: bool, pub supports_users: bool, pub supports_user_create: bool, @@ -59,6 +61,7 @@ impl Default for DatabaseUiCapabilities { supports_schema: false, uses_schema_as_database: false, supports_views: true, + supports_materialized_views: false, supports_indexes: true, supports_users: false, supports_user_create: false, diff --git a/crates/db/src/postgresql/plugin.rs b/crates/db/src/postgresql/plugin.rs index 3463af49f..6f6d7918a 100644 --- a/crates/db/src/postgresql/plugin.rs +++ b/crates/db/src/postgresql/plugin.rs @@ -91,6 +91,20 @@ impl PostgresPlugin { Self } + /// `schema.table` 引用;无 schema 时仅限定表名。 + /// + /// 固有方法(非 trait 方法),供本插件构造外部表等 SQL 时复用。 + fn qualify_table_reference(&self, schema: Option<&str>, table: &str) -> String { + match schema { + Some(schema) => format!( + "{}.{}", + self.quote_identifier(schema), + self.quote_identifier(table) + ), + None => self.quote_identifier(table), + } + } + fn comment_literal(comment: &str) -> String { if comment.is_empty() { "NULL".to_string() @@ -322,6 +336,7 @@ fn build_postgresql_ui_manifest() -> DatabaseUiManifest { supports_table_charset: true, supports_table_collation: true, supports_tablespace: true, + supports_materialized_views: true, ..DatabaseUiCapabilities::default() }, forms, @@ -344,6 +359,31 @@ fn postgres_metadata_row_value(row: &[Option], index: usize) -> String { .unwrap_or_default() } +/// 「表」目录覆盖的 `pg_class.relkind`:普通表 / 分区表 / 外部表。 +const POSTGRES_TABLE_RELKINDS: &str = "'r', 'p', 'f'"; + +/// 把 `pg_class.relkind` 映射为表对象类型。 +/// +/// 分区表按普通表处理(`DROP/ALTER TABLE` 均适用);外部表必须单独区分, +/// 否则会生成适配普通表的 `DROP/ALTER TABLE` DDL 并报错。 +fn postgres_table_object_type(relkind: Option<&str>) -> TableObjectType { + match relkind { + Some("f") => TableObjectType::ForeignTable, + _ => TableObjectType::Table, + } +} + +/// 对象列表「类型」列的展示文案。 +fn postgres_relation_type_label(relkind: Option<&str>) -> &'static str { + match relkind { + Some("v") => "View", + Some("m") => "Materialized View", + Some("p") => "Partitioned Table", + Some("f") => "Foreign Table", + _ => "Table", + } +} + fn parse_postgres_foreign_keys(rows: Vec>>) -> Vec { let mut foreign_keys = Vec::new(); let mut positions = HashMap::new(); @@ -1018,6 +1058,111 @@ fn postgresql_action_manifest() -> DatabaseActionManifest { vec![DbNodeType::Table], DatabaseActionPlacement::ContextMenu, ), + // === 外部表 === + // 外部表与普通表并列在「表」目录,动作 id 复用 Table/View 系列, + // 由 db_tree_event 按节点类型改走 DROP/ALTER FOREIGN TABLE; + // 设计表、复制表、结构转储对 PostgreSQL 外部表均不适用。 + action_with_scope( + DatabaseActionId::OpenTableData, + "Table.view_data", + vec![DbNodeType::ForeignTable], + DatabaseActionPlacement::Both, + true, + Some(DatabaseActionToolbarScope::SelectedRow), + ), + action_with_scope( + DatabaseActionId::OpenTableData, + "Table.view_data", + vec![DbNodeType::ForeignTable], + DatabaseActionPlacement::Toolbar, + true, + Some(DatabaseActionToolbarScope::CurrentNode), + ), + action( + DatabaseActionId::RenameTable, + "Table.rename_table", + vec![DbNodeType::ForeignTable], + DatabaseActionPlacement::ContextMenu, + ), + action( + DatabaseActionId::TruncateTable, + "Table.truncate_table", + vec![DbNodeType::ForeignTable], + DatabaseActionPlacement::ContextMenu, + ), + action_with_scope( + DatabaseActionId::DeleteTable, + "Table.delete_table", + vec![DbNodeType::ForeignTable], + DatabaseActionPlacement::Both, + true, + Some(DatabaseActionToolbarScope::SelectedRow), + ), + action_with_scope( + DatabaseActionId::DeleteTable, + "Table.delete_table", + vec![DbNodeType::ForeignTable], + DatabaseActionPlacement::Toolbar, + true, + Some(DatabaseActionToolbarScope::CurrentNode), + ), + action( + DatabaseActionId::DumpSqlData, + "ImportExport.export_data", + vec![DbNodeType::ForeignTable], + DatabaseActionPlacement::ContextMenu, + ), + action( + DatabaseActionId::ImportData, + "ImportExport.import_data", + vec![DbNodeType::ForeignTable], + DatabaseActionPlacement::ContextMenu, + ), + action( + DatabaseActionId::ExportData, + "ImportExport.export_table", + vec![DbNodeType::ForeignTable], + DatabaseActionPlacement::ContextMenu, + ), + // === 物化视图 === + action_with_scope( + DatabaseActionId::OpenViewData, + "View.view_data", + vec![DbNodeType::MaterializedView], + DatabaseActionPlacement::Both, + true, + Some(DatabaseActionToolbarScope::SelectedRow), + ), + action_with_scope( + DatabaseActionId::OpenViewData, + "View.view_data", + vec![DbNodeType::MaterializedView], + DatabaseActionPlacement::Toolbar, + true, + Some(DatabaseActionToolbarScope::CurrentNode), + ), + action_with_scope( + DatabaseActionId::DeleteView, + "View.delete_view", + vec![DbNodeType::MaterializedView], + DatabaseActionPlacement::Both, + true, + Some(DatabaseActionToolbarScope::SelectedRow), + ), + action_with_scope( + DatabaseActionId::DeleteView, + "View.delete_view", + vec![DbNodeType::MaterializedView], + DatabaseActionPlacement::Toolbar, + true, + Some(DatabaseActionToolbarScope::CurrentNode), + ), + action( + DatabaseActionId::DumpSqlData, + "ImportExport.export_data", + vec![DbNodeType::MaterializedView], + DatabaseActionPlacement::ContextMenu, + ), action_with_scope( DatabaseActionId::OpenViewData, "View.view_data", @@ -1139,6 +1284,7 @@ impl DatabasePlugin for PostgresPlugin { supports_table_charset: true, supports_table_collation: true, supports_tablespace: true, + supports_materialized_views: true, ..DatabaseUiCapabilities::default() } } @@ -1582,15 +1728,19 @@ impl DatabasePlugin for PostgresPlugin { schema: Option, ) -> Result> { let schema_val = schema.unwrap_or_else(|| "public".to_string()); + // 普通表 r / 分区表 p / 外部表 f 都属于「表」目录;视图与物化视图另有目录。 let sql = format!( "SELECT c.relname AS tablename, n.nspname AS schemaname, + c.relkind AS relation_kind, obj_description(c.oid, 'pg_class') AS table_comment FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid - WHERE n.nspname = '{}' AND c.relkind = 'r'", - schema_val.replace("'", "''") + WHERE n.nspname = '{}' AND c.relkind IN ({}) + ORDER BY c.relname", + schema_val.replace("'", "''"), + POSTGRES_TABLE_RELKINDS ); let result = connection @@ -1609,7 +1759,15 @@ impl DatabasePlugin for PostgresPlugin { "utf8mb4", )? .unwrap_or_default(), - object_type: TableObjectType::Table, + object_type: postgres_table_object_type( + crate::metadata_read::metadata_text( + &query_result, + row_index, + 2, + "utf8mb4", + )? + .as_deref(), + ), schema: crate::metadata_read::metadata_text( &query_result, row_index, @@ -1619,7 +1777,7 @@ impl DatabasePlugin for PostgresPlugin { comment: crate::metadata_read::metadata_text( &query_result, row_index, - 2, + 3, "utf8mb4", )? .filter(|s| !s.is_empty()), @@ -1680,9 +1838,9 @@ impl DatabasePlugin for PostgresPlugin { JOIN pg_namespace n ON c.relnamespace = n.oid LEFT JOIN pg_tablespace ts ON c.reltablespace = ts.oid WHERE n.nspname = '{}' - AND c.relkind = 'r' + AND c.relkind IN ({}) ORDER BY c.relname", - safe_schema + safe_schema, POSTGRES_TABLE_RELKINDS ); let result = connection @@ -1710,12 +1868,7 @@ impl DatabasePlugin for PostgresPlugin { .unwrap_or_else(|| "-".to_string()); // 类型转换 - let object_type = match cell(2)?.as_deref() { - Some("v") => "View", - Some("m") => "Materialized View", - Some("p") => "Partitioned Table", - _ => "Table", - }; + let object_type = postgres_relation_type_label(cell(2)?.as_deref()); object_view_rows.push(vec![ cell(0)?.unwrap_or_default(), // Name (index 0) @@ -2169,6 +2322,88 @@ impl DatabasePlugin for PostgresPlugin { }) } + async fn list_materialized_views( + &self, + connection: &dyn DbConnection, + _database: &str, + schema: Option, + ) -> Result> { + let schema_val = schema.unwrap_or_else(|| "public".to_string()); + // information_schema 不包含物化视图,必须回退到 pg_class(relkind = 'm')。 + let sql = format!( + "SELECT c.relname AS matview_name, \ + n.nspname AS schemaname, \ + pg_get_viewdef(c.oid, true) AS view_definition, \ + obj_description(c.oid, 'pg_class') AS view_comment \ + FROM pg_class c \ + JOIN pg_namespace n ON c.relnamespace = n.oid \ + WHERE n.nspname = '{}' AND c.relkind = 'm' \ + ORDER BY c.relname", + schema_val.replace("'", "''") + ); + + let result = connection + .query(&sql) + .await + .map_err(|e| anyhow::anyhow!("Failed to list materialized views: {}", e))?; + + if let SqlResult::Query(query_result) = result { + let mut views = Vec::new(); + for row_index in 0..query_result.rows.len() { + let cell = |column_index| { + crate::metadata_read::metadata_text( + &query_result, + row_index, + column_index, + "utf8mb4", + ) + }; + views.push(ViewInfo { + name: cell(0)?.unwrap_or_default(), + schema: cell(1)?, + definition: cell(2)?, + comment: cell(3)?.filter(|comment| !comment.is_empty()), + }); + } + Ok(views) + } else { + Err(anyhow::anyhow!("Unexpected result type")) + } + } + + async fn list_materialized_views_view( + &self, + connection: &dyn DbConnection, + database: &str, + schema: Option, + ) -> Result { + let views = self + .list_materialized_views(connection, database, schema) + .await?; + + let columns = vec![ + Column::localized("name", "ObjectView.columns.name").width(200.0), + Column::localized("definition", "ObjectView.columns.definition").width(400.0), + ]; + + let rows: Vec> = views + .iter() + .map(|view| { + vec![ + view.name.clone(), + view.definition.as_deref().unwrap_or("").to_string(), + ] + }) + .collect(); + + Ok(ObjectView { + db_node_type: DbNodeType::MaterializedView, + title: t!("ObjectView.counts.materialized_views", count = views.len()).to_string(), + columns, + rows, + }) + } + // === Function Operations === async fn list_functions( @@ -3044,6 +3279,36 @@ ORDER BY rolname;"# ) } + fn drop_foreign_table(&self, _database: &str, schema: Option<&str>, table: &str) -> String { + format!( + "DROP FOREIGN TABLE IF EXISTS {}", + self.qualify_table_reference(schema, table) + ) + } + + fn rename_foreign_table( + &self, + _database: &str, + schema: Option<&str>, + old_name: &str, + new_name: &str, + ) -> String { + // `ALTER ... RENAME TO` 只能命名一个对象,旧名必须限定 schema。 + format!( + "ALTER FOREIGN TABLE {} RENAME TO {}", + self.qualify_table_reference(schema, old_name), + self.quote_identifier(new_name) + ) + } + + fn drop_materialized_view(&self, _database: &str, schema: Option<&str>, view: &str) -> String { + // 与外部表同理,限定 schema 才能不依赖会话 search_path。 + format!( + "DROP MATERIALIZED VIEW IF EXISTS {}", + self.qualify_table_reference(schema, view) + ) + } + fn build_backup_table_sql( &self, _database: &str, @@ -3648,10 +3913,26 @@ mod tests { .push(query.to_string()); let rows = if query.contains("table_comment") { + vec![ + vec![ + Some("users".to_string()), + Some("public".to_string()), + Some("r".to_string()), + Some("Application users".to_string()), + ], + vec![ + Some("remote_orders".to_string()), + Some("public".to_string()), + Some("f".to_string()), + Some("Orders on the remote server".to_string()), + ], + ] + } else if query.contains("matview_name") { vec![vec![ - Some("users".to_string()), + Some("mv_orders".to_string()), Some("public".to_string()), - Some("Application users".to_string()), + Some("SELECT order_id FROM orders".to_string()), + Some("Orders rollup".to_string()), ]] } else if query.contains("column_name") { vec![vec![ @@ -4033,17 +4314,202 @@ mod tests { .await .expect("list tables"); - assert_eq!(1, tables.len()); + assert_eq!(2, tables.len()); assert_eq!(crate::TableObjectType::Table, tables[0].object_type); assert_eq!(Some("Application users"), tables[0].comment.as_deref()); + // 外部表(relkind = 'f')列入「表」目录,但必须带独立的对象类型。 + assert_eq!("remote_orders", tables[1].name); + assert_eq!(crate::TableObjectType::ForeignTable, tables[1].object_type); + assert_eq!( + Some("Orders on the remote server"), + tables[1].comment.as_deref() + ); + let queries = connection.queries(); let table_query = queries .iter() .find(|query| query.contains("table_comment")) .expect("table metadata query"); assert!(table_query.contains("obj_description(c.oid, 'pg_class')")); - assert!(table_query.contains("c.relkind = 'r'")); - assert!(!table_query.contains("c.relkind IN")); + assert!(table_query.contains("c.relkind IN ('r', 'p', 'f')")); + } + + #[tokio::test] + async fn test_postgres_materialized_views_use_pg_class() { + let plugin = create_plugin(); + let connection = CommentMetadataConnection::new(); + + // 测试用伪连接只识别部分 SQL,这里同时验证能力位与查询文本。 + let views = plugin + .list_materialized_views(&connection, "app", Some("public".to_string())) + .await + .expect("list materialized views"); + assert_eq!(1, views.len()); + assert_eq!("mv_orders", views[0].name); + assert_eq!(Some("public"), views[0].schema.as_deref()); + assert_eq!( + Some("SELECT order_id FROM orders"), + views[0].definition.as_deref() + ); + assert_eq!(Some("Orders rollup"), views[0].comment.as_deref()); + assert!(plugin.capabilities().supports_materialized_views); + + let queries = connection.queries(); + let matview_query = queries + .iter() + .find(|query| query.contains("matview_name")) + .expect("materialized view query"); + assert!(matview_query.contains("c.relkind = 'm'")); + assert!(matview_query.contains("pg_get_viewdef")); + } + + #[test] + fn test_postgres_foreign_table_ddl_uses_foreign_table_statements() { + let plugin = create_plugin(); + + assert_eq!( + "DROP FOREIGN TABLE IF EXISTS \"public\".\"remote_orders\"", + plugin.drop_foreign_table("app", Some("public"), "remote_orders") + ); + assert_eq!( + "ALTER FOREIGN TABLE \"public\".\"remote_orders\" RENAME TO \"orders_2024\"", + plugin.rename_foreign_table("app", Some("public"), "remote_orders", "orders_2024") + ); + assert_eq!( + "DROP MATERIALIZED VIEW IF EXISTS \"public\".\"mv_orders\"", + plugin.drop_materialized_view("app", Some("public"), "mv_orders") + ); + // 没有 schema 时退化为仅限定对象名(与其它 DDL 生成方法一致)。 + assert_eq!( + "ALTER FOREIGN TABLE \"remote_orders\" RENAME TO \"orders_2024\"", + plugin.rename_foreign_table("app", None, "remote_orders", "orders_2024") + ); + } + + #[test] + fn test_postgres_manifest_scopes_table_actions_per_node_type() { + let manifest = create_plugin().ui_manifest(); + let supports = |node_type: DbNodeType, action_id: DatabaseActionId| { + manifest.actions.actions.iter().any(|action| { + action.id == action_id + && action + .targets + .iter() + .any(|target| target.node_type == node_type) + }) + }; + + // 外部表:只能查看数据 / 重命名 / 删除 / 数据转储 / 导入导出; + // 不能当作普通表设计、复制或做结构转储。 + assert!(supports( + DbNodeType::ForeignTable, + DatabaseActionId::OpenTableData + )); + assert!(supports( + DbNodeType::ForeignTable, + DatabaseActionId::RenameTable + )); + assert!(supports( + DbNodeType::ForeignTable, + DatabaseActionId::DeleteTable + )); + assert!(supports( + DbNodeType::ForeignTable, + DatabaseActionId::DumpSqlData + )); + assert!(supports( + DbNodeType::ForeignTable, + DatabaseActionId::ExportData + )); + assert!(!supports( + DbNodeType::ForeignTable, + DatabaseActionId::DesignTable + )); + assert!(!supports( + DbNodeType::ForeignTable, + DatabaseActionId::CopyTable + )); + assert!(!supports( + DbNodeType::ForeignTable, + DatabaseActionId::DumpSqlStructure + )); + assert!(!supports( + DbNodeType::ForeignTable, + DatabaseActionId::DumpSqlStructureAndData + )); + + // 物化视图:查看数据 / 删除 / 数据转储。 + assert!(supports( + DbNodeType::MaterializedView, + DatabaseActionId::OpenViewData + )); + assert!(supports( + DbNodeType::MaterializedView, + DatabaseActionId::DeleteView + )); + assert!(supports( + DbNodeType::MaterializedView, + DatabaseActionId::DumpSqlData + )); + assert!(!supports( + DbNodeType::MaterializedView, + DatabaseActionId::DesignTable + )); + } + + #[tokio::test] + async fn test_postgres_schema_tree_separates_foreign_tables_and_materialized_views() { + let plugin = create_plugin(); + let connection = CommentMetadataConnection::new(); + let schema_node = DbNode::new( + "conn:app:public", + "public", + DbNodeType::Schema, + "conn".to_string(), + DatabaseType::PostgreSQL, + ) + .with_metadata(HashMap::from([("database".to_string(), "app".to_string())])); + + let children = plugin + .build_database_or_schema_children( + &connection, + &schema_node, + Some("public".to_string()), + ) + .await + .expect("build schema children"); + + let tables = children + .iter() + .find(|node| node.node_type == DbNodeType::TablesFolder) + .expect("tables folder"); + let table_nodes: Vec<(&str, DbNodeType)> = tables + .children + .iter() + .map(|node| (node.name.as_str(), node.node_type)) + .collect(); + assert_eq!( + // `DbNode::Ord` 先按节点类型再按名称排序:普通表在前,外部表紧随其后。 + vec![ + ("users", DbNodeType::Table), + ("remote_orders", DbNodeType::ForeignTable), + ], + table_nodes + ); + + let matviews = children + .iter() + .find(|node| node.node_type == DbNodeType::MaterializedViewsFolder) + .expect("materialized views folder"); + assert_eq!("DbTree.MaterializedViews", matviews.name); + assert_eq!( + vec![("mv_orders", DbNodeType::MaterializedView)], + matviews + .children + .iter() + .map(|node| (node.name.as_str(), node.node_type)) + .collect::>() + ); } #[tokio::test] diff --git a/crates/db/src/types.rs b/crates/db/src/types.rs index 2c286bcb7..241a75725 100644 --- a/crates/db/src/types.rs +++ b/crates/db/src/types.rs @@ -36,6 +36,11 @@ pub enum DbNodeType { Schema, TablesFolder, Table, + /// PostgreSQL 外部表(`pg_class.relkind = 'f'`)。 + /// + /// 与 `Table` 并列显示在「表」目录中,但拥有独立的动作范围: + /// 删除/重命名必须使用 `DROP/ALTER FOREIGN TABLE`,设计表等不适用。 + ForeignTable, ColumnsFolder, Column, IndexesFolder, @@ -48,6 +53,8 @@ pub enum DbNodeType { Check, ViewsFolder, View, + MaterializedViewsFolder, + MaterializedView, FunctionsFolder, Function, ProceduresFolder, @@ -67,6 +74,7 @@ impl fmt::Display for DbNodeType { DbNodeType::Schema => write!(f, "Schema"), DbNodeType::TablesFolder => write!(f, "Tables"), DbNodeType::Table => write!(f, "Table"), + DbNodeType::ForeignTable => write!(f, "Foreign Table"), DbNodeType::ColumnsFolder => write!(f, "Columns"), DbNodeType::Column => write!(f, "Column"), DbNodeType::IndexesFolder => write!(f, "Indexes"), @@ -79,6 +87,8 @@ impl fmt::Display for DbNodeType { DbNodeType::Check => write!(f, "Check"), DbNodeType::ViewsFolder => write!(f, "Views"), DbNodeType::View => write!(f, "View"), + DbNodeType::MaterializedViewsFolder => write!(f, "Materialized Views"), + DbNodeType::MaterializedView => write!(f, "Materialized View"), DbNodeType::FunctionsFolder => write!(f, "Functions"), DbNodeType::Function => write!(f, "Function"), DbNodeType::ProceduresFolder => write!(f, "Procedures"), @@ -203,12 +213,17 @@ impl DbNode { } pub fn get_table_name(&self) -> Option { - if self.node_type == DbNodeType::Table { + if matches!(self.node_type, DbNodeType::Table | DbNodeType::ForeignTable) { Some(self.name.clone()) } else { self.metadata.get("table").cloned() } } + + /// 是否属于「表」目录下的对象(普通表 / 外部表 / 分区表)。 + pub fn is_table_like(&self) -> bool { + matches!(self.node_type, DbNodeType::Table | DbNodeType::ForeignTable) + } } /// Database information @@ -275,6 +290,15 @@ pub enum TableObjectType { #[default] Table, View, + /// 外部表属于「表」目录,但结构比较、结构转储不能按普通表生成 DDL。 + ForeignTable, +} + +impl TableObjectType { + /// 是否可作为普通表参与 DDL 生成(结构比较、结构转储、ER 图)。 + pub fn is_ddl_comparable(self) -> bool { + matches!(self, TableObjectType::Table) + } } /// Table-like object information with description/metadata. diff --git a/crates/db/tests/real_databases/postgres/foreign_matview.rs b/crates/db/tests/real_databases/postgres/foreign_matview.rs new file mode 100644 index 000000000..3adf3e472 --- /dev/null +++ b/crates/db/tests/real_databases/postgres/foreign_matview.rs @@ -0,0 +1,386 @@ +use db::connection::DbConnection; +use db::plugin::DatabasePlugin; +use db::postgresql::PostgresPlugin; +use db::types::{DbNode, DbNodeType, TableObjectType}; +use one_core::storage::DatabaseType; + +use crate::real_databases::common::env::{optional_database, postgres_config, skip_database}; +use crate::real_databases::postgres::core_flow::{ + drop_schema, execute, reset_schema, unique_schema, +}; + +/// 外部表依赖 FDW:没有外部服务器就建不出外部表。本地测试用 postgres_fdw 指回 +/// 同一个库,行为与真实外部表一致(可 SELECT / TRUNCATE,DDL 用 FOREIGN TABLE 语法)。 +const FIXTURE_SQL: &str = r#" +CREATE TABLE orders ( + id INTEGER PRIMARY KEY, + amount NUMERIC(10, 2) NOT NULL +); +INSERT INTO orders VALUES (1, 10.50), (2, 20.25); + +CREATE TABLE orders_part ( + id INTEGER NOT NULL, + amount NUMERIC(10, 2) NOT NULL +) PARTITION BY RANGE (id); +CREATE TABLE orders_part_low PARTITION OF orders_part FOR VALUES FROM (0) TO (100); +INSERT INTO orders_part VALUES (1, 1.00); + +CREATE MATERIALIZED VIEW mv_orders AS + SELECT id, amount * 2 AS doubled FROM orders; +COMMENT ON MATERIALIZED VIEW mv_orders IS 'doubled orders'; +"#; + +#[tokio::test] +async fn postgres_real_foreign_table_and_materialized_view_flow() { + let Some(config) = postgres_config() else { + skip_database( + "PostgreSQL", + "ONETCLI_TEST_POSTGRES_PASSWORD (empty string is valid)", + ); + return; + }; + let config = optional_database( + &config, + &std::env::var("ONETCLI_TEST_POSTGRES_DATABASE").unwrap_or_else(|_| "postgres".to_string()), + ); + let password = config.password.clone(); + // 用非 public schema:DDL 必须自带 schema 限定,不能依赖会话 search_path。 + let schema = unique_schema("foreign"); + let server = format!("{schema}_srv"); + let plugin = PostgresPlugin::new(); + let mut connection = plugin + .create_connection(config.clone()) + .await + .expect("PostgreSQL should connect"); + let conn: &(dyn DbConnection + Send + Sync) = connection.as_ref(); + // 这个库本来没装 postgres_fdw 的话,测完把扩展也清掉,不给本地库留残留。 + let extension_existed = postgres_fdw_installed(conn).await; + + reset_schema(&plugin, conn, &schema).await; + conn.switch_schema(&schema) + .await + .expect("switch to the test schema"); + create_foreign_table(&plugin, conn, &schema, &server, &password).await; + execute(&plugin, conn, FIXTURE_SQL).await; + + assert_listed_objects(&plugin, conn, &schema).await; + assert_object_view_types(&plugin, conn, &schema).await; + assert_tree_children(&plugin, conn, &schema).await; + assert_foreign_table_lifecycle(&plugin, conn, &schema, &server).await; + assert_materialized_view_lifecycle(&plugin, conn, &schema).await; + + drop_schema(&plugin, conn, &schema).await; + execute( + &plugin, + conn, + &format!("DROP SERVER IF EXISTS \"{server}\" CASCADE;"), + ) + .await; + if !extension_existed { + execute(&plugin, conn, "DROP EXTENSION IF EXISTS postgres_fdw;").await; + } + connection + .disconnect() + .await + .expect("PostgreSQL should disconnect"); +} + +async fn create_foreign_table( + plugin: &PostgresPlugin, + connection: &(dyn DbConnection + Send + Sync), + schema: &str, + server: &str, + password: &str, +) { + let sql = foreign_table_setup_sql(schema, server, password); + execute(plugin, connection, &sql).await; +} + +async fn postgres_fdw_installed(connection: &(dyn DbConnection + Send + Sync)) -> bool { + let result = connection + .query("SELECT extname FROM pg_extension WHERE extname = 'postgres_fdw'") + .await + .expect("extension lookup should run"); + matches!(result, db::executor::SqlResult::Query(query) if !query.rows.is_empty()) +} + +/// 建立外部表所需的 SQL:FDW 扩展、外部服务器、用户映射、外部表本体。 +fn foreign_table_setup_sql(schema: &str, server: &str, password: &str) -> String { + // 密码来自环境变量(ONETCLI_TEST_POSTGRES_PASSWORD),不硬编码。 + let escaped_password = password.replace('\'', "''"); + // 显式限定外部表与远端表的 schema,不靠会话 search_path。 + format!( + "CREATE EXTENSION IF NOT EXISTS postgres_fdw;\n\ + CREATE SERVER \"{server}\" FOREIGN DATA WRAPPER postgres_fdw \ + OPTIONS (host '127.0.0.1', dbname 'postgres');\n\ + CREATE USER MAPPING FOR CURRENT_USER SERVER \"{server}\" \ + OPTIONS (user 'postgres', password '{escaped_password}');\n\ + CREATE FOREIGN TABLE \"{schema}\".remote_orders (id INTEGER, amount NUMERIC(10, 2)) \ + SERVER \"{server}\" OPTIONS (schema_name '{schema}', table_name 'orders');" + ) +} + +async fn assert_listed_objects( + plugin: &PostgresPlugin, + connection: &(dyn DbConnection + Send + Sync), + schema: &str, +) { + let tables = plugin + .list_tables(connection, "postgres", Some(schema.to_string())) + .await + .expect("tables should list"); + let table = |name: &str| { + tables + .iter() + .find(|table| table.name == name) + .unwrap_or_else(|| panic!("{name} should be listed, got {:?}", names(&tables))) + }; + + // 普通表 / 分区表 / 外部表都在「表」目录,只有外部表需要区分类型。 + assert_eq!(TableObjectType::Table, table("orders").object_type); + assert_eq!(TableObjectType::Table, table("orders_part").object_type); + assert_eq!( + TableObjectType::ForeignTable, + table("remote_orders").object_type + ); + assert!(!table("remote_orders").object_type.is_ddl_comparable()); + assert!(table("orders").object_type.is_ddl_comparable()); + + // 物化视图不在 information_schema.views 里,必须走 pg_class。 + let views = plugin + .list_views(connection, "postgres", Some(schema.to_string())) + .await + .expect("views should list"); + assert!( + !views.iter().any(|view| view.name == "mv_orders"), + "materialized views must not leak into the plain views list" + ); + let matviews = plugin + .list_materialized_views(connection, "postgres", Some(schema.to_string())) + .await + .expect("materialized views should list"); + let matview = matviews + .iter() + .find(|view| view.name == "mv_orders") + .expect("mv_orders should be listed as a materialized view"); + assert_eq!(Some(schema), matview.schema.as_deref()); + assert_eq!(Some("doubled orders"), matview.comment.as_deref()); + assert!( + matview + .definition + .as_deref() + .unwrap_or_default() + .contains("orders"), + "materialized view definition should come from pg_get_viewdef: {:?}", + matview.definition + ); +} + +async fn assert_object_view_types( + plugin: &PostgresPlugin, + connection: &(dyn DbConnection + Send + Sync), + schema: &str, +) { + let view = plugin + .list_tables_view(connection, "postgres", Some(schema.to_string())) + .await + .expect("tables view should load"); + let type_of = |name: &str| { + let row = view + .rows + .iter() + .find(|row| row.first().map(String::as_str) == Some(name)) + .unwrap_or_else(|| panic!("{name} should appear in the object list")); + // 列顺序:name / owner / type / rows / size / indexes / tablespace / comment + row.get(2).cloned().unwrap_or_default() + }; + assert_eq!("Table", type_of("orders")); + assert_eq!("Partitioned Table", type_of("orders_part")); + assert_eq!("Foreign Table", type_of("remote_orders")); + + let matviews = plugin + .list_materialized_views_view(connection, "postgres", Some(schema.to_string())) + .await + .expect("materialized views view should load"); + assert_eq!(DbNodeType::MaterializedView, matviews.db_node_type); + assert!( + matviews + .rows + .iter() + .any(|row| row.first().map(String::as_str) == Some("mv_orders")), + "mv_orders should appear in the materialized views object list" + ); + + let tables_view = plugin + .list_tables_view(connection, "postgres", Some(schema.to_string())) + .await + .expect("tables view should reload"); + assert!( + !tables_view + .rows + .iter() + .any(|row| row.first().map(String::as_str) == Some("mv_orders")), + "materialized views must not appear in the tables object list" + ); +} + +async fn assert_tree_children( + plugin: &PostgresPlugin, + connection: &(dyn DbConnection + Send + Sync), + schema: &str, +) { + let schema_node = DbNode::new( + format!("conn:{schema}"), + schema, + DbNodeType::Schema, + "conn".to_string(), + DatabaseType::PostgreSQL, + ) + .with_metadata(std::collections::HashMap::from([( + "database".to_string(), + "postgres".to_string(), + )])); + + let children = plugin + .build_database_or_schema_children(connection, &schema_node, Some(schema.to_string())) + .await + .expect("schema children should build"); + + let tables = children + .iter() + .find(|node| node.node_type == DbNodeType::TablesFolder) + .expect("tables folder should exist"); + let node_type_of = |name: &str| { + tables + .children + .iter() + .find(|node| node.name == name) + .unwrap_or_else(|| panic!("{name} should be a table node")) + .node_type + }; + assert_eq!(DbNodeType::Table, node_type_of("orders")); + assert_eq!(DbNodeType::Table, node_type_of("orders_part")); + assert_eq!(DbNodeType::ForeignTable, node_type_of("remote_orders")); + + let matviews = children + .iter() + .find(|node| node.node_type == DbNodeType::MaterializedViewsFolder) + .expect("materialized views folder should exist"); + assert_eq!( + vec![DbNodeType::MaterializedView], + matviews + .children + .iter() + .map(|node| node.node_type) + .collect::>() + ); + assert_eq!( + Some("mv_orders"), + matviews.children.first().map(|node| node.name.as_str()), + ); +} + +async fn assert_foreign_table_lifecycle( + plugin: &PostgresPlugin, + connection: &(dyn DbConnection + Send + Sync), + schema: &str, + server: &str, +) { + // 外部表可直接查询(数据面可用)。 + assert_eq!( + 2, + query_row_count(connection, "remote_orders", schema).await + ); + + // 生成的 ALTER 必须能真的改掉非 public schema 下的外部表。 + let rename = + plugin.rename_foreign_table("postgres", Some(schema), "remote_orders", "orders_fdw"); + execute(plugin, connection, &rename).await; + let renamed = plugin + .list_tables(connection, "postgres", Some(schema.to_string())) + .await + .expect("tables should list after rename"); + assert!( + renamed.iter().any(|table| table.name == "orders_fdw" + && table.object_type == TableObjectType::ForeignTable), + "renamed foreign table should still be a foreign table, got {:?}", + names(&renamed) + ); + assert!(!renamed.iter().any(|table| table.name == "remote_orders")); + + // 生成的 DROP FOREIGN TABLE 必须命中外部表本身。 + let drop = plugin.drop_foreign_table("postgres", Some(schema), "orders_fdw"); + execute(plugin, connection, &drop).await; + let after_drop = plugin + .list_tables(connection, "postgres", Some(schema.to_string())) + .await + .expect("tables should list after drop"); + assert!( + !after_drop.iter().any(|table| table.name == "orders_fdw"), + "dropped foreign table should disappear, got {:?}", + names(&after_drop) + ); + + // 重建一张外部表,确认删除普通表不会连带外部表(两条 DDL 互不干扰)。 + let rebuild = format!( + "CREATE FOREIGN TABLE \"{schema}\".remote_orders_2 (id INTEGER, amount NUMERIC(10, 2)) \ + SERVER \"{server}\" OPTIONS (schema_name '{schema}', table_name 'orders');" + ); + execute(plugin, connection, &rebuild).await; + execute( + plugin, + connection, + &plugin.drop_table("postgres", Some(schema), "orders_part_low"), + ) + .await; + let final_tables = plugin + .list_tables(connection, "postgres", Some(schema.to_string())) + .await + .expect("tables should list at the end"); + assert!( + final_tables + .iter() + .any(|table| table.name == "remote_orders_2"), + "dropping a plain table must not touch foreign tables, got {:?}", + names(&final_tables) + ); +} + +async fn assert_materialized_view_lifecycle( + plugin: &PostgresPlugin, + connection: &(dyn DbConnection + Send + Sync), + schema: &str, +) { + // 物化视图存了数据,可以直接查。 + assert_eq!(2, query_row_count(connection, "mv_orders", schema).await); + + let drop = plugin.drop_materialized_view("postgres", Some(schema), "mv_orders"); + execute(plugin, connection, &drop).await; + let matviews = plugin + .list_materialized_views(connection, "postgres", Some(schema.to_string())) + .await + .expect("materialized views should list after drop"); + assert!( + !matviews.iter().any(|view| view.name == "mv_orders"), + "dropped materialized view should disappear" + ); +} + +async fn query_row_count( + connection: &(dyn DbConnection + Send + Sync), + table: &str, + schema: &str, +) -> usize { + let result = connection + .query(&format!("SELECT * FROM \"{schema}\".\"{table}\"")) + .await + .expect("select should run"); + match result { + db::executor::SqlResult::Query(query) => query.rows.len(), + other => panic!("expected rows from {table}, got {other:?}"), + } +} + +fn names(tables: &[db::types::TableInfo]) -> Vec { + tables.iter().map(|table| table.name.clone()).collect() +} diff --git a/crates/db/tests/real_databases/postgres/mod.rs b/crates/db/tests/real_databases/postgres/mod.rs index 57894241d..0e96a7064 100644 --- a/crates/db/tests/real_databases/postgres/mod.rs +++ b/crates/db/tests/real_databases/postgres/mod.rs @@ -1,4 +1,5 @@ mod core_flow; mod data; mod designer; +mod foreign_matview; mod import_export; diff --git a/crates/db_view/locales/db_view.yml b/crates/db_view/locales/db_view.yml index 5bc2f0b8a..97ce273ea 100644 --- a/crates/db_view/locales/db_view.yml +++ b/crates/db_view/locales/db_view.yml @@ -1941,6 +1941,10 @@ DbTree: en: Views zh-CN: 视图 zh-HK: 視圖 + MaterializedViews: + en: Materialized Views + zh-CN: 物化视图 + zh-HK: 實體化檢視 Functions: en: Functions zh-CN: 函数 @@ -2792,6 +2796,10 @@ SqlDump: en: "Found %{count} tables" zh-CN: "找到 %{count} 个表" zh-HK: "找到 %{count} 個表" + skipped_non_ddl_tables: + en: "Skipped %{count} object(s) without a plain table structure (foreign tables, etc.)" + zh-CN: "已跳过 %{count} 个没有普通表结构的对象(如外部表)" + zh-HK: "已略過 %{count} 個沒有普通資料表結構的物件(如外部表)" fetch_tables_failed: en: "Failed to fetch table list: %{error}" zh-CN: "获取表列表失败: %{error}" diff --git a/crates/db_view/src/database_objects_tab.rs b/crates/db_view/src/database_objects_tab.rs index 72a58b37d..e78095441 100644 --- a/crates/db_view/src/database_objects_tab.rs +++ b/crates/db_view/src/database_objects_tab.rs @@ -315,7 +315,13 @@ impl DatabaseObjects { } let node = nodes[0].clone(); - if matches!(node.node_type, DbNodeType::Table | DbNodeType::View) { + if matches!( + node.node_type, + DbNodeType::Table + | DbNodeType::ForeignTable + | DbNodeType::View + | DbNodeType::MaterializedView + ) { cx.emit(DatabaseObjectsEvent::CreateNewQuery { node }); } } @@ -428,8 +434,12 @@ impl DatabaseObjects { supports_action: impl Fn(DatabaseActionId) -> bool, ) -> Option { Some(match node.node_type { - DbNodeType::Table => DatabaseObjectsEvent::OpenTableData { node }, - DbNodeType::View => DatabaseObjectsEvent::OpenViewData { node }, + DbNodeType::Table | DbNodeType::ForeignTable => { + DatabaseObjectsEvent::OpenTableData { node } + } + DbNodeType::View | DbNodeType::MaterializedView => { + DatabaseObjectsEvent::OpenViewData { node } + } DbNodeType::Function if supports_action(DatabaseActionId::OpenFunction) => { DatabaseObjectsEvent::OpenFunction { node } } @@ -458,8 +468,11 @@ impl DatabaseObjects { | DbNodeType::Schema | DbNodeType::TablesFolder | DbNodeType::Table + | DbNodeType::ForeignTable | DbNodeType::ViewsFolder | DbNodeType::View + | DbNodeType::MaterializedViewsFolder + | DbNodeType::MaterializedView | DbNodeType::FunctionsFolder | DbNodeType::Function | DbNodeType::ProceduresFolder @@ -732,7 +745,7 @@ impl DatabaseObjects { ) } } - DbNodeType::TablesFolder | DbNodeType::Table => { + DbNodeType::TablesFolder | DbNodeType::Table | DbNodeType::ForeignTable => { let schema = current_node.get_schema_name(); metadata.insert("database".to_string(), database.clone()); if let Some(schema) = schema.as_ref().filter(|schema| !schema.trim().is_empty()) { @@ -754,7 +767,7 @@ impl DatabaseObjects { } else { format!("{}:table_folder:{}", current_node.id, name) }; - (node_id, DbNodeType::Table) + (node_id, Self::table_row_node_type(columns, row_data)) } DbNodeType::Schema => { if current_node.node_type == DbNodeType::Connection { @@ -783,23 +796,43 @@ impl DatabaseObjects { metadata.insert("table".to_string(), name.clone()); ( format!("{}:{}:{}:table_folder:{}", connection_id, db, schema, name), - DbNodeType::Table, + Self::table_row_node_type(columns, row_data), ) } } - DbNodeType::ViewsFolder | DbNodeType::View => { + DbNodeType::ViewsFolder + | DbNodeType::View + | DbNodeType::MaterializedViewsFolder + | DbNodeType::MaterializedView => { + let is_materialized = matches!( + db_node_type, + DbNodeType::MaterializedViewsFolder | DbNodeType::MaterializedView + ); let schema = current_node.get_schema_name(); metadata.insert("database".to_string(), database.clone()); if let Some(schema) = schema.as_ref().filter(|schema| !schema.trim().is_empty()) { metadata.insert("schema".to_string(), schema.clone()); } + let folder_key = if is_materialized { + "matviews_folder" + } else { + "views_folder" + }; metadata.insert("view".to_string(), name.clone()); - let node_id = if current_node.node_type == DbNodeType::ViewsFolder { + let node_id = if matches!( + current_node.node_type, + DbNodeType::ViewsFolder | DbNodeType::MaterializedViewsFolder + ) { format!("{}:{}", current_node.id, name) } else { - format!("{}:views_folder:{}", current_node.id, name) + format!("{}:{}:{}", current_node.id, folder_key, name) + }; + let node_type = if is_materialized { + DbNodeType::MaterializedView + } else { + DbNodeType::View }; - (node_id, DbNodeType::View) + (node_id, node_type) } DbNodeType::FunctionsFolder | DbNodeType::Function => { let schema = current_node.get_schema_name(); @@ -908,6 +941,17 @@ impl DatabaseObjects { row_data.get(index).cloned() } + /// 「表」目录对象行的真实节点类型。 + /// + /// 外部表与普通表共用同一个列表,必须靠类型列区分,否则右键菜单与 + /// 删除/重命名会按普通表生成 DDL。 + fn table_row_node_type(columns: &[Column], row_data: &[String]) -> DbNodeType { + match Self::row_value_for_column(columns, row_data, "type").as_deref() { + Some("Foreign Table") => DbNodeType::ForeignTable, + _ => DbNodeType::Table, + } + } + fn build_nodes_for_selected_rows(&self) -> Vec { let mut selected_rows: Vec = self.selected_indices.iter().copied().collect(); selected_rows.sort_unstable(); @@ -2242,6 +2286,93 @@ mod tests { .with_metadata(HashMap::from([("database".to_string(), String::new())])) } + fn postgres_tables_folder_node() -> DbNode { + DbNode::new( + "1:app:public:table_folder", + "DbTree.Tables", + DbNodeType::TablesFolder, + "1".to_string(), + DatabaseType::PostgreSQL, + ) + .with_metadata(HashMap::from([ + ("database".to_string(), "app".to_string()), + ("schema".to_string(), "public".to_string()), + ])) + } + + fn object_columns_with_type() -> Vec { + vec![Column::new("name", "Name"), Column::new("type", "Type")] + } + + #[test] + fn postgres_foreign_table_row_builds_foreign_table_node() { + let columns = object_columns_with_type(); + let row = vec!["remote_orders".to_string(), "Foreign Table".to_string()]; + + let node = DatabaseObjects::build_node_from_object_row( + DbNodeType::Table, + Some(&postgres_tables_folder_node()), + &columns, + &row, + ) + .expect("foreign table row should produce a node"); + + // 外部表与普通表同在「表」目录,但节点类型必须区分, + // 否则右键菜单与删除/重命名会按普通表生成 DDL。 + assert_eq!(DbNodeType::ForeignTable, node.node_type); + assert_eq!("1:app:public:table_folder:remote_orders", node.id); + assert_eq!( + Some("remote_orders"), + node.metadata.get("table").map(String::as_str) + ); + } + + #[test] + fn postgres_regular_table_row_stays_table_node() { + let columns = object_columns_with_type(); + let row = vec!["users".to_string(), "Table".to_string()]; + + let node = DatabaseObjects::build_node_from_object_row( + DbNodeType::Table, + Some(&postgres_tables_folder_node()), + &columns, + &row, + ) + .expect("table row should produce a node"); + + assert_eq!(DbNodeType::Table, node.node_type); + assert_eq!("1:app:public:table_folder:users", node.id); + } + + #[test] + fn postgres_matviews_folder_row_builds_materialized_view_node() { + let current_node = DbNode::new( + "1:app:public:matviews_folder", + "DbTree.MaterializedViews", + DbNodeType::MaterializedViewsFolder, + "1".to_string(), + DatabaseType::PostgreSQL, + ) + .with_metadata(HashMap::from([ + ("database".to_string(), "app".to_string()), + ("schema".to_string(), "public".to_string()), + ])); + let columns = vec![Column::new("name", "Name")]; + let row = vec!["mv_orders".to_string()]; + + let node = DatabaseObjects::build_node_from_object_row( + DbNodeType::MaterializedViewsFolder, + Some(¤t_node), + &columns, + &row, + ) + .expect("materialized view row should produce a node"); + + assert_eq!(DbNodeType::MaterializedView, node.node_type); + // 与树中 build_materialized_views_folder 生成的 id 保持一致。 + assert_eq!("1:app:public:matviews_folder:mv_orders", node.id); + } + #[test] fn oracle_tables_folder_row_builds_table_node_matching_tree_id() { let row = vec!["BIZ_MESSAGE".to_string()]; diff --git a/crates/db_view/src/database_view_plugin.rs b/crates/db_view/src/database_view_plugin.rs index 6f3e7312b..628f434de 100644 --- a/crates/db_view/src/database_view_plugin.rs +++ b/crates/db_view/src/database_view_plugin.rs @@ -556,6 +556,22 @@ fn context_menu_rank(node_type: DbNodeType, action_id: DatabaseActionId) -> usiz DatabaseActionId::DeleteView => 20, _ => 900, }, + DbNodeType::ForeignTable => match action_id { + DatabaseActionId::OpenTableData => 10, + DatabaseActionId::RenameTable => 30, + DatabaseActionId::TruncateTable => 50, + DatabaseActionId::DeleteTable => 60, + DatabaseActionId::DumpSqlData => 71, + DatabaseActionId::ImportData => 80, + DatabaseActionId::ExportData => 90, + _ => 900, + }, + DbNodeType::MaterializedView => match action_id { + DatabaseActionId::OpenViewData => 10, + DatabaseActionId::DeleteView => 20, + DatabaseActionId::DumpSqlData => 71, + _ => 900, + }, DbNodeType::Function => match action_id { DatabaseActionId::OpenFunction => 10, _ => 900, @@ -588,7 +604,13 @@ fn insert_query_table_context_menu_item( node_type: DbNodeType, node_id: &str, ) { - if !matches!(node_type, DbNodeType::Table | DbNodeType::View) { + if !matches!( + node_type, + DbNodeType::Table + | DbNodeType::ForeignTable + | DbNodeType::View + | DbNodeType::MaterializedView + ) { return; } @@ -651,6 +673,21 @@ fn context_menu_group(node_type: DbNodeType, action: &DatabaseActionDescriptor) DatabaseActionId::DeleteView => Some("view"), _ => None, }, + DbNodeType::ForeignTable => match action.id { + DatabaseActionId::OpenTableData => Some("open"), + DatabaseActionId::RenameTable + | DatabaseActionId::TruncateTable + | DatabaseActionId::DeleteTable => Some("table"), + DatabaseActionId::DumpSqlData => Some("dump"), + DatabaseActionId::ImportData | DatabaseActionId::ExportData => Some("io"), + _ => None, + }, + DbNodeType::MaterializedView => match action.id { + DatabaseActionId::OpenViewData => Some("open"), + DatabaseActionId::DeleteView => Some("view"), + DatabaseActionId::DumpSqlData => Some("dump"), + _ => None, + }, DbNodeType::Function => match action.id { DatabaseActionId::OpenFunction => Some("open"), _ => None, diff --git a/crates/db_view/src/db_tree_event.rs b/crates/db_view/src/db_tree_event.rs index 6021ec1d2..bb0a2eb40 100644 --- a/crates/db_view/src/db_tree_event.rs +++ b/crates/db_view/src/db_tree_event.rs @@ -12,7 +12,10 @@ use crate::{ sql_editor_view::SqlEditorTab, table_designer_tab::{TableDesigner, TableDesignerConfig}, }; -use db::{DbNode, DbNodeType, GlobalDbState, RoutineIdentity, SqlResult, schema_for_new_query}; +use db::{ + DbNode, DbNodeType, GlobalDbState, RoutineIdentity, SqlResult, TableObjectType, + schema_for_new_query, +}; use gpui::{ App, AppContext, AsyncApp, Context, Entity, ParentElement, PathPromptOptions, Styled, Subscription, Window, div, px, @@ -116,8 +119,8 @@ impl DatabaseEventHandler { fn format_query_table_reference(node: &DbNode, global_state: &GlobalDbState) -> Option { let table = match node.node_type { - DbNodeType::Table => node.get_table_name()?, - DbNodeType::View => node.name.clone(), + DbNodeType::Table | DbNodeType::ForeignTable => node.get_table_name()?, + DbNodeType::View | DbNodeType::MaterializedView => node.name.clone(), _ => return None, }; let plugin = global_state @@ -140,9 +143,19 @@ impl DatabaseEventHandler { }) } + fn table_object_type_for_node(node: &DbNode) -> TableObjectType { + match node.node_type { + DbNodeType::ForeignTable => TableObjectType::ForeignTable, + _ => TableObjectType::Table, + } + } + fn query_title_for_node(node: &DbNode, database: Option<&str>) -> String { match node.node_type { - DbNodeType::Table | DbNodeType::View => format!("{} - Query", node.name), + DbNodeType::Table + | DbNodeType::ForeignTable + | DbNodeType::View + | DbNodeType::MaterializedView => format!("{} - Query", node.name), _ => format!("{} - Query", database.unwrap_or("New Query")), } } @@ -3092,12 +3105,13 @@ impl DatabaseEventHandler { }; let task = state - .drop_table( + .drop_table_like( &mut cx.clone(), node.connection_id.clone(), database, schema, table_name.clone(), + Self::table_object_type_for_node(&node), ) .await; @@ -3204,14 +3218,27 @@ impl DatabaseEventHandler { .get("database") .map(|s| s.to_string()) .unwrap_or_default(); - let result = state - .drop_view( - cx, - node.connection_id.clone(), - database, - node.name.clone(), - ) - .await; + let schema = node.get_schema_name(); + let result = if node.node_type == DbNodeType::MaterializedView { + state + .drop_materialized_view( + cx, + node.connection_id.clone(), + database, + schema, + node.name.clone(), + ) + .await + } else { + state + .drop_view( + cx, + node.connection_id.clone(), + database, + node.name.clone(), + ) + .await + }; match result { Ok(_) => removed.push(node.id.clone()), Err(error) => { @@ -3388,6 +3415,7 @@ impl DatabaseEventHandler { let database_name = node.get_database_name(); let schema_name = node.get_schema_name(); let table_name = node.get_table_name(); + let object_type = Self::table_object_type_for_node(&node); let window_id = cx.active_window(); window.open_dialog(cx, move |dialog, _window, _cx| { @@ -3443,12 +3471,13 @@ impl DatabaseEventHandler { }; let task = state - .drop_table( + .drop_table_like( &mut cx.clone(), conn_id.clone(), database, schema, tbl_name_value.clone(), + object_type, ) .await; @@ -3509,6 +3538,8 @@ impl DatabaseEventHandler { let connection_id = node.connection_id.clone(); let old_table_name = node.name.clone(); let metadata = node.metadata.clone(); + let object_type = Self::table_object_type_for_node(&node); + let schema = node.get_schema_name(); // 创建输入框状态 let input_state = cx.new(|cx| { @@ -3522,6 +3553,7 @@ impl DatabaseEventHandler { let conn_id = connection_id.clone(); let old_name = old_table_name.clone(); let meta = metadata.clone(); + let schema = schema.clone(); let state = global_state.clone(); let input = input_state.clone(); let tree = tree_view.clone(); @@ -3566,6 +3598,7 @@ impl DatabaseEventHandler { let conn_id = conn_id.clone(); let old_name = old_name.clone(); let meta = meta.clone(); + let schema = schema.clone(); let state = state.clone(); let tree = tree.clone(); @@ -3579,12 +3612,14 @@ impl DatabaseEventHandler { let db_node_id = format!("{}:{}", conn_id, database); let task = state - .rename_table( + .rename_table_like( cx, conn_id.clone(), database, + schema, old_name.clone(), new_name.clone(), + object_type, ) .await; match task { @@ -3919,12 +3954,15 @@ impl DatabaseEventHandler { let view_name = node.name.clone(); let view_node_id = node.id.clone(); let metadata = node.metadata.clone(); + let schema = node.get_schema_name(); + let is_materialized = node.node_type == DbNodeType::MaterializedView; window.open_dialog(cx, move |dialog, _window, _cx| { let conn_id = connection_id.clone(); let v_name = view_name.clone(); let v_node_id = view_node_id.clone(); let meta = metadata.clone(); + let schema = schema.clone(); let state = global_state.clone(); let v_name_display = view_name.clone(); let tree = tree_view.clone(); @@ -3951,6 +3989,7 @@ impl DatabaseEventHandler { let v_name = v_name.clone(); let v_node_id = v_node_id.clone(); let meta = meta.clone(); + let schema = schema.clone(); let state = state.clone(); let v_name_log = v_name.clone(); let tree = tree.clone(); @@ -3961,9 +4000,21 @@ impl DatabaseEventHandler { .get("database") .map(|s| s.to_string()) .unwrap_or_default(); - let result = state - .drop_view(cx, conn_id.clone(), database, v_name.clone()) - .await; + let result = if is_materialized { + state + .drop_materialized_view( + cx, + conn_id.clone(), + database, + schema, + v_name.clone(), + ) + .await + } else { + state + .drop_view(cx, conn_id.clone(), database, v_name.clone()) + .await + }; match result { Ok(_) => { diff --git a/crates/db_view/src/db_tree_view.rs b/crates/db_view/src/db_tree_view.rs index 4bb8c4129..4562a5ab5 100644 --- a/crates/db_view/src/db_tree_view.rs +++ b/crates/db_view/src/db_tree_view.rs @@ -507,7 +507,10 @@ pub fn get_icon_for_node_type(node_type: &DbNodeType, _theme: &gpui_component::T DbNodeType::Schema => object_icon(IconName::Schema), DbNodeType::Database => object_icon(IconName::Database), DbNodeType::Table => object_icon(IconName::Table), + // 外部表沿用普通表图标(Navicat 中同样归在「表」目录) + DbNodeType::ForeignTable => object_icon(IconName::Table), DbNodeType::View => object_icon(IconName::View), + DbNodeType::MaterializedView => object_icon(IconName::View), DbNodeType::Function => object_icon(IconName::Function), DbNodeType::Procedure => object_icon(IconName::Procedure), DbNodeType::Column => object_icon(IconName::Column), @@ -594,7 +597,13 @@ impl DbTreeView { let Some(node) = self.db_nodes.get(&node_id) else { return; }; - if matches!(node.node_type, DbNodeType::Table | DbNodeType::View) { + if matches!( + node.node_type, + DbNodeType::Table + | DbNodeType::ForeignTable + | DbNodeType::View + | DbNodeType::MaterializedView + ) { cx.emit(DbTreeViewEvent::CreateNewQuery { node_id }); } } @@ -1492,7 +1501,9 @@ impl DbTreeView { .db_nodes .get(&node_id) .and_then(|node| match node.node_type { - DbNodeType::View | DbNodeType::Function => node.parent_context.clone(), + DbNodeType::View | DbNodeType::MaterializedView | DbNodeType::Function => { + node.parent_context.clone() + } _ => None, }) .unwrap_or_else(|| node_id.clone()); @@ -1939,8 +1950,10 @@ impl DbTreeView { return matches!( node.node_type, DbNodeType::Table + | DbNodeType::ForeignTable | DbNodeType::TablesFolder | DbNodeType::ViewsFolder + | DbNodeType::MaterializedViewsFolder | DbNodeType::ColumnsFolder | DbNodeType::IndexesFolder | DbNodeType::FunctionsFolder @@ -2075,7 +2088,10 @@ impl DbTreeView { Some(DbNodeType::SequencesFolder) => object_icon(IconName::FolderSequences), Some(DbNodeType::Table) => object_icon(IconName::Table), + Some(DbNodeType::ForeignTable) => object_icon(IconName::Table), Some(DbNodeType::View) => object_icon(IconName::View), + Some(DbNodeType::MaterializedView) => object_icon(IconName::View), + Some(DbNodeType::MaterializedViewsFolder) => object_icon(IconName::FolderViews), Some(DbNodeType::Function) => object_icon(IconName::Function), Some(DbNodeType::Procedure) => object_icon(IconName::Procedure), Some(DbNodeType::Column) => { @@ -2113,7 +2129,7 @@ impl DbTreeView { if let Some(node) = self.db_nodes.get(node_id).cloned() { let database = node.get_database_name().unwrap_or_default(); match node.node_type { - DbNodeType::Table => { + DbNodeType::Table | DbNodeType::ForeignTable => { // 查找所属数据库 info!( @@ -2124,7 +2140,7 @@ impl DbTreeView { node_id: node.id.clone(), }); } - DbNodeType::View => { + DbNodeType::View | DbNodeType::MaterializedView => { info!( "DbTreeView: opening view data tab: {}.{}", database, node.name @@ -2183,7 +2199,8 @@ impl DbTreeView { | DbNodeType::QueriesFolder | DbNodeType::QueryFolder | DbNodeType::TablesFolder - | DbNodeType::ViewsFolder => { + | DbNodeType::ViewsFolder + | DbNodeType::MaterializedViewsFolder => { let is_expanded = self.expanded_nodes.contains(node_id); // 切换展开状态 @@ -2669,6 +2686,7 @@ impl DbTreeView { n.node_type, DbNodeType::TablesFolder | DbNodeType::ViewsFolder + | DbNodeType::MaterializedViewsFolder | DbNodeType::FunctionsFolder | DbNodeType::ProceduresFolder | DbNodeType::SequencesFolder @@ -2683,7 +2701,7 @@ impl DbTreeView { } else { n.name.clone() }; - let comment = if n.node_type == DbNodeType::Table { + let comment = if n.is_table_like() { n.metadata.get("comment").cloned() } else { None @@ -2705,6 +2723,7 @@ impl DbTreeView { node_type, Some(DbNodeType::TablesFolder) | Some(DbNodeType::ViewsFolder) + | Some(DbNodeType::MaterializedViewsFolder) | Some(DbNodeType::FunctionsFolder) | Some(DbNodeType::ProceduresFolder) | Some(DbNodeType::TriggersFolder) diff --git a/crates/db_view/src/import_export/sql_dump_target.rs b/crates/db_view/src/import_export/sql_dump_target.rs index f2c005451..dbf9a1e0c 100644 --- a/crates/db_view/src/import_export/sql_dump_target.rs +++ b/crates/db_view/src/import_export/sql_dump_target.rs @@ -22,7 +22,13 @@ pub(crate) fn resolve_sql_dump_target(node: &DbNode) -> Option { schema: non_empty(node.get_schema_name()), table: None, }), - DbNodeType::Table => Some(SqlDumpTarget { + DbNodeType::Table | DbNodeType::ForeignTable => Some(SqlDumpTarget { + database: non_empty(node.get_database_name())?, + schema: non_empty(node.get_schema_name()), + table: Some(node.name.clone()), + }), + // 物化视图在数据层面上就是一张可查询的表,只支持数据转储(结构转储未注册)。 + DbNodeType::MaterializedView => Some(SqlDumpTarget { database: non_empty(node.get_database_name())?, schema: non_empty(node.get_schema_name()), table: Some(node.name.clone()), @@ -128,6 +134,28 @@ mod tests { assert_eq!(None, target.table.as_deref()); } + #[test] + fn resolves_foreign_table_and_matview_data_dump_targets() { + let foreign = node_with_metadata( + DbNodeType::ForeignTable, + "remote_orders", + &[("database", "sales"), ("schema", "public")], + ); + let target = + resolve_sql_dump_target(&foreign).expect("foreign table target should resolve"); + assert_eq!("sales", target.database); + assert_eq!(Some("public"), target.schema.as_deref()); + assert_eq!(Some("remote_orders"), target.table.as_deref()); + + let matview = node_with_metadata( + DbNodeType::MaterializedView, + "mv_orders", + &[("database", "sales"), ("schema", "public")], + ); + let target = resolve_sql_dump_target(&matview).expect("matview target should resolve"); + assert_eq!(Some("mv_orders"), target.table.as_deref()); + } + #[test] fn sanitizes_dump_filename_components_for_path_safe_output() { assert_eq!( diff --git a/crates/db_view/src/import_export/sql_dump_view.rs b/crates/db_view/src/import_export/sql_dump_view.rs index a5a546750..156374251 100644 --- a/crates/db_view/src/import_export/sql_dump_view.rs +++ b/crates/db_view/src/import_export/sql_dump_view.rs @@ -200,7 +200,22 @@ impl SqlDumpView { .await; match tables_result { Ok(table_infos) => { - let tables: Vec<_> = table_infos.into_iter().map(|t| t.name).collect(); + // 外部表没有普通表结构:按普通表生成 CREATE TABLE 会产出无法 + // 执行的脚本,因此整库/整模式转储时跳过。 + let (tables_infos, skipped): (Vec<_>, Vec<_>) = table_infos + .into_iter() + .partition(|info| info.object_type.is_ddl_comparable()); + if !skipped.is_empty() { + Self::add_log( + &cx, + &logs, + &scroll_handle, + "".to_string(), + t!("SqlDump.skipped_non_ddl_tables", count = skipped.len()) + .to_string(), + ); + } + let tables: Vec<_> = tables_infos.into_iter().map(|t| t.name).collect(); Self::add_log( &cx, &logs, diff --git a/crates/db_view/src/sql_editor_view.rs b/crates/db_view/src/sql_editor_view.rs index 19436b955..32a0fc9cd 100644 --- a/crates/db_view/src/sql_editor_view.rs +++ b/crates/db_view/src/sql_editor_view.rs @@ -643,6 +643,8 @@ async fn fetch_foreign_schema_metadata( object_type: match table.object_type { TableObjectType::Table => SqlObjectType::Table, TableObjectType::View => SqlObjectType::View, + // 外部表参与 SQL 补全时按表处理,避免从候选中消失。 + TableObjectType::ForeignTable => SqlObjectType::Table, }, schema: table.schema.clone(), comment: table.comment.clone(), @@ -3010,6 +3012,8 @@ impl SqlEditorTab { object_type: match table.object_type { TableObjectType::Table => SqlObjectType::Table, TableObjectType::View => SqlObjectType::View, + // 外部表参与 SQL 补全时按表处理,避免从候选中消失。 + TableObjectType::ForeignTable => SqlObjectType::Table, }, schema: table.schema.clone(), comment: table.comment.clone(), From ed2f79e8520938fa74379904fa2ad09a3a387773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Mon, 21 Sep 2026 17:10:19 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(editor):=20=E5=85=B3=E9=97=AD=E5=86=85?= =?UTF-8?q?=E7=BD=AE=E8=BF=9C=E7=A8=8B=E7=BC=96=E8=BE=91=E5=99=A8=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E9=9A=90=E8=97=8F=E5=A4=8D=E7=94=A8=EF=BC=8C=E8=A7=84?= =?UTF-8?q?=E9=81=BF=20macOS=20Touch=20Bar=20=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue #262:Intel Touch Bar 机型(MacBookPro16,1)上关闭内置文件编辑器弹窗 必现 SIGILL。用户崩溃报告显示致命点在 AppKit:NSWindow 销毁后 -_NSTouchBarFinderObservation invalidate 在 NSDisplayCycleFlush 里注销 KVO 观察时抛出未捕获 NSException,-[NSApplication _crashOnException:] 以 ud2 终止进程。与 Rust panic 无关(release 为 panic=abort),Apple Silicon 无 Touch Bar 故不可复现。 修复:macOS 上关闭不再销毁原生窗口,改为 orderOut: 隐藏 + 复用: - prepare_window_close 统一收口全部关闭路径(红按钮/⌘W/保存后关闭/ 末标签关闭),隐藏成功即保留 GPUI 窗口与注册表,下次打开复用 - editor_window_visibility::hide_for_reuse 仅 macOS 实现,其余平台/失败 回退原 remove_window 行为 - client 下沉到每个 tab(修跨会话复用会走错连接的问题);同一窗口按 (remote_path, Arc::ptr_eq) 匹配标签 - PendingCloseAction::Tab 改用稳定 tab id;prompt 引入代际计数,旧确认 不得作用于复用后的新会话;隐藏时 next_tab_id 不回退,杜绝旧 I/O 落到 新标签 - apply_load/save_error 返回是否命中标签,未命中的迟到错误不再弹通知 验证:cargo test -p remote_file_editor 70 passed(新增 5 个状态级复用/ 代际/跨连接用例 + 3 个源码契约测试);原生隐藏/复用仍需 Touch Bar 真机 回归(报障人协助)。 --- Cargo.lock | 4 + crates/remote_file_editor/Cargo.toml | 6 + crates/remote_file_editor/src/diagnostics.rs | 24 +- .../remote_file_editor/src/editor_window.rs | 183 +++++++++---- .../src/editor_window_reuse_tests.rs | 250 ++++++++++++++++++ .../src/editor_window_visibility.rs | 35 +++ crates/remote_file_editor/src/lib.rs | 1 + .../src/window_close_contract_tests.rs | 65 +++++ 8 files changed, 507 insertions(+), 61 deletions(-) create mode 100644 crates/remote_file_editor/src/editor_window_reuse_tests.rs create mode 100644 crates/remote_file_editor/src/editor_window_visibility.rs diff --git a/Cargo.lock b/Cargo.lock index 350879514..5632d715e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11740,13 +11740,17 @@ name = "remote_file_editor" version = "0.15.2" dependencies = [ "anyhow", + "async-trait", "extension-runtime", "futures", "gpui-component", "gpui-pre", "notify", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "one-core", "process-util", + "raw-window-handle", "rust-i18n", "sftp", "smol", diff --git a/crates/remote_file_editor/Cargo.toml b/crates/remote_file_editor/Cargo.toml index cd26e4ef1..92ee144bd 100644 --- a/crates/remote_file_editor/Cargo.toml +++ b/crates/remote_file_editor/Cargo.toml @@ -19,7 +19,13 @@ smol = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSResponder", "NSView", "NSWindow"] } +raw-window-handle = { workspace = true, features = ["std"] } + [dev-dependencies] +async-trait = { workspace = true } gpui = { workspace = true, features = ["test-support"] } [lints] diff --git a/crates/remote_file_editor/src/diagnostics.rs b/crates/remote_file_editor/src/diagnostics.rs index ab63c4b73..5d1912610 100644 --- a/crates/remote_file_editor/src/diagnostics.rs +++ b/crates/remote_file_editor/src/diagnostics.rs @@ -16,6 +16,10 @@ //! * `pending_save_tasks` — in-flight remote writes //! * `active_parse_tasks` — in-flight language/parser loads //! +//! On macOS, closing the editor retains one empty window for reuse (#262). +//! `editor_window_hidden` should report zero tabs, but one live view is expected; +//! this is not a full teardown and native renderer resources remain allocated. +//! //! Privacy: these logs intentionally record only `tab_id`, `size_bytes` and the //! resolved language name. Remote paths can carry query-string credentials //! (e.g. `…?token=…`), and file contents must never be logged. @@ -35,9 +39,7 @@ pub(crate) struct EditorLifecycleSnapshot { impl EditorLifecycleSnapshot { /// Whether any in-flight task outlives its own window. pub(crate) const fn has_pending_work(&self) -> bool { - self.pending_load_tasks != 0 - || self.pending_save_tasks != 0 - || self.active_parse_tasks != 0 + self.pending_load_tasks != 0 || self.pending_save_tasks != 0 || self.active_parse_tasks != 0 } /// Whether the editor is fully torn down. @@ -52,6 +54,10 @@ static PENDING_LOAD_TASKS: AtomicI64 = AtomicI64::new(0); static PENDING_SAVE_TASKS: AtomicI64 = AtomicI64::new(0); static ACTIVE_PARSE_TASKS: AtomicI64 = AtomicI64::new(0); +/// All tests that instantiate editor views share the process-wide gauges. +#[cfg(test)] +pub(crate) static GAUGE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// Kind of background editor task, used as the log `task` field. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum EditorTaskKind { @@ -227,14 +233,11 @@ pub(crate) struct EditorTaskDetail<'a> { #[cfg(test)] mod tests { use super::{ - EditorLifecycleSnapshot, EditorTaskDetail, EditorTaskGuard, EditorTaskKind, - record_editor_tab_created, record_editor_view_created, record_editor_view_released_with_tabs, - snapshot, + EditorLifecycleSnapshot, EditorTaskDetail, EditorTaskGuard, EditorTaskKind, GAUGE_LOCK, + record_editor_tab_created, record_editor_view_created, + record_editor_view_released_with_tabs, snapshot, }; - /// Serializes the gauge assertions; the gauges are process-wide. - static GAUGE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - #[test] fn task_guard_returns_every_gauge_to_its_baseline() { let _lock = GAUGE_LOCK.lock().unwrap(); @@ -249,7 +252,8 @@ mod tests { language: Some("rust"), }, ); - let _save = EditorTaskGuard::begin(EditorTaskKind::Save, 7, EditorTaskDetail::default()); + let _save = + EditorTaskGuard::begin(EditorTaskKind::Save, 7, EditorTaskDetail::default()); let _parse = EditorTaskGuard::begin( EditorTaskKind::Parse, 7, diff --git a/crates/remote_file_editor/src/editor_window.rs b/crates/remote_file_editor/src/editor_window.rs index 3fdf00782..034fad40e 100644 --- a/crates/remote_file_editor/src/editor_window.rs +++ b/crates/remote_file_editor/src/editor_window.rs @@ -4,8 +4,7 @@ use crate::file_policy::{ }; use crate::language::load_language_for_path; use crate::{ - CloseIntercept, RemoteMutationCallback, active_index_after_close, active_index_after_open, - decide_close_intercept, + CloseIntercept, RemoteMutationCallback, active_index_after_close, decide_close_intercept, }; use gpui::{ AnyWindowHandle, App, AppContext, Context, Entity, InteractiveElement as _, IntoElement, @@ -28,7 +27,7 @@ use one_core::{ }; use rust_i18n::t; use sftp::{RemoteFileClient, SharedRemoteFileClient}; -use std::sync::{Mutex as StdMutex, Once, OnceLock}; +use std::sync::{Arc, Mutex as StdMutex, Once, OnceLock}; actions!(remote_file_editor, [OpenSearch, OpenReplace]); @@ -63,6 +62,7 @@ pub fn open_remote_file_editor( let result = cx.update(|cx| { if open_in_existing_window( remote_path.clone(), + client.clone(), on_remote_changed.clone(), cx, )? { @@ -105,6 +105,7 @@ pub fn open_remote_file_editor( fn open_in_existing_window( remote_path: String, + client: SharedRemoteFileClient, on_remote_changed: RemoteMutationCallback, cx: &mut App, ) -> anyhow::Result { @@ -117,7 +118,7 @@ fn open_in_existing_window( editor_window .view .update(cx, |this, cx| { - this.open_or_focus_tab(remote_path, on_remote_changed, window, cx); + this.open_or_focus_tab(remote_path, client, on_remote_changed, window, cx); }) .is_ok() }); @@ -254,11 +255,12 @@ struct LoadedFile { #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PendingCloseAction { Window, - Tab(usize), + Tab(u64), } struct RemoteEditorTab { id: u64, + client: SharedRemoteFileClient, remote_path: String, display_name: String, editor: Option>, @@ -275,9 +277,15 @@ struct RemoteEditorTab { } impl RemoteEditorTab { - fn new(id: u64, remote_path: String, on_remote_changed: RemoteMutationCallback) -> Self { + fn new( + id: u64, + remote_path: String, + client: SharedRemoteFileClient, + on_remote_changed: RemoteMutationCallback, + ) -> Self { Self { id, + client, display_name: display_name_from_path(&remote_path), remote_path, editor: None, @@ -313,13 +321,13 @@ impl RemoteEditorTab { } struct RemoteFileEditorWindow { - client: SharedRemoteFileClient, tabs: Vec, active_tab: usize, close_prompt_open: bool, pending_close_action: Option, close_window_after_saves: bool, next_tab_id: u64, + prompt_generation: u64, } /// Releases the per-view and per-tab gauges at the true end of the view's @@ -340,17 +348,17 @@ impl RemoteFileEditorWindow { cx: &mut Context, ) -> Self { let mut this = Self { - client, tabs: Vec::new(), active_tab: 0, close_prompt_open: false, pending_close_action: None, close_window_after_saves: false, next_tab_id: 1, + prompt_generation: 0, }; diagnostics::record_editor_view_created(); this.register_close_guard(window, cx); - this.open_or_focus_tab(remote_path, on_remote_changed, window, cx); + this.open_or_focus_tab(remote_path, client, on_remote_changed, window, cx); this } @@ -365,17 +373,25 @@ impl RemoteFileEditorWindow { fn open_or_focus_tab( &mut self, remote_path: String, + client: SharedRemoteFileClient, on_remote_changed: RemoteMutationCallback, window: &mut Window, cx: &mut Context, ) { - let paths = self.tab_paths(); - let active_index = active_index_after_open(&paths, &remote_path); + let active_index = self + .tabs + .iter() + .position(|tab| tab.remote_path == remote_path && Arc::ptr_eq(&tab.client, &client)) + .unwrap_or(self.tabs.len()); if active_index == self.tabs.len() { let tab_id = self.next_tab_id; self.next_tab_id += 1; - self.tabs - .push(RemoteEditorTab::new(tab_id, remote_path, on_remote_changed)); + self.tabs.push(RemoteEditorTab::new( + tab_id, + remote_path, + client, + on_remote_changed, + )); diagnostics::record_editor_tab_created(); self.active_tab = active_index; self.reload_tab(active_index, window, cx); @@ -388,13 +404,6 @@ impl RemoteFileEditorWindow { self.update_window_title(window); } - fn tab_paths(&self) -> Vec { - self.tabs - .iter() - .map(|tab| tab.remote_path.clone()) - .collect() - } - fn tab_index_by_identity(&self, tab_id: u64, remote_path: &str) -> Option { self.tabs .iter() @@ -432,7 +441,7 @@ impl RemoteFileEditorWindow { let tab_id = tab.id; let remote_path = tab.remote_path.clone(); let task_remote_path = remote_path.clone(); - let client = self.client.clone(); + let client = tab.client.clone(); let max_bytes = one_core::settings::AppSettings::current(cx) .remote_file_editor .max_file_size_bytes(); @@ -489,8 +498,10 @@ impl RemoteFileEditorWindow { let message = error.to_string(); if view .update_in(cx, |this, window, cx| { - this.apply_load_error(tab_id, &remote_path, message.clone(), cx); - window.push_notification(Notification::error(message), cx); + if this.apply_load_error(tab_id, &remote_path, message.clone(), cx) + { + window.push_notification(Notification::error(message), cx); + } }) .is_err() { @@ -501,8 +512,10 @@ impl RemoteFileEditorWindow { let message = error.to_string(); if view .update_in(cx, |this, window, cx| { - this.apply_load_error(tab_id, &remote_path, message.clone(), cx); - window.push_notification(Notification::error(message), cx); + if this.apply_load_error(tab_id, &remote_path, message.clone(), cx) + { + window.push_notification(Notification::error(message), cx); + } }) .is_err() { @@ -583,17 +596,18 @@ impl RemoteFileEditorWindow { remote_path: &str, message: String, cx: &mut Context, - ) { + ) -> bool { let Some(index) = self.tab_index_by_identity(tab_id, remote_path) else { - return; + return false; }; let Some(tab) = self.tabs.get_mut(index) else { - return; + return false; }; tab.loading = false; tab.load_error = Some(message); tab.status_message = t!("RemoteFileEditor.status.load_failed").to_string(); cx.notify(); + true } fn save(&mut self, close_after_save: bool, window: &mut Window, cx: &mut Context) { @@ -629,7 +643,7 @@ impl RemoteFileEditorWindow { let tab_id = tab.id; let remote_path = tab.remote_path.clone(); let task_remote_path = remote_path.clone(); - let client = self.client.clone(); + let client = tab.client.clone(); let save_bytes = text.len(); let task = Tokio::spawn(cx, async move { let mut client = client.lock().await; @@ -676,8 +690,10 @@ impl RemoteFileEditorWindow { let message = error.to_string(); if view .update_in(cx, |this, window, cx| { - this.apply_save_error(tab_id, &remote_path, message.clone(), cx); - window.push_notification(Notification::error(message), cx); + if this.apply_save_error(tab_id, &remote_path, message.clone(), cx) + { + window.push_notification(Notification::error(message), cx); + } }) .is_err() { @@ -688,8 +704,10 @@ impl RemoteFileEditorWindow { let message = error.to_string(); if view .update_in(cx, |this, window, cx| { - this.apply_save_error(tab_id, &remote_path, message.clone(), cx); - window.push_notification(Notification::error(message), cx); + if this.apply_save_error(tab_id, &remote_path, message.clone(), cx) + { + window.push_notification(Notification::error(message), cx); + } }) .is_err() { @@ -724,8 +742,7 @@ impl RemoteFileEditorWindow { if self.close_window_after_saves && !self.has_dirty_tabs(cx) { self.close_window_after_saves = false; - clear_editor_window(); - window.remove_window(); + self.finish_window_close(window, cx); } else if close_after_save { self.close_clean_tab(index, window, cx); } else { @@ -744,25 +761,73 @@ impl RemoteFileEditorWindow { remote_path: &str, _message: String, cx: &mut Context, - ) { + ) -> bool { let Some(index) = self.tab_index_by_identity(tab_id, remote_path) else { - return; + return false; }; let Some(tab) = self.tabs.get_mut(index) else { - return; + return false; }; tab.saving = false; tab.status_message = t!("RemoteFileEditor.status.save_failed").to_string(); self.close_window_after_saves = false; cx.notify(); + true } - fn handle_window_should_close(&mut self, window: &mut Window, cx: &mut Context) -> bool { - match decide_close_intercept(self.has_dirty_tabs(cx), self.close_prompt_open) { - CloseIntercept::Allow => { + /// Return whether the native/GPUI close may proceed. Keeping the registry + /// and the GPUI window alive also keeps AppKit's NSWindow out of dealloc. + fn prepare_window_close(&mut self, window: &mut Window, cx: &mut Context) -> bool { + match crate::editor_window_visibility::hide_for_reuse(window) { + Ok(true) => { + window.blur(cx); + window.clear_notifications(cx); + self.reset_for_reuse(); + diagnostics::log_snapshot("editor_window_hidden"); + cx.notify(); + false + } + Ok(false) => { clear_editor_window(); true } + Err(error) => { + tracing::warn!( + ?error, + "failed to hide remote editor; falling back to window removal" + ); + clear_editor_window(); + true + } + } + } + + fn finish_window_close(&mut self, window: &mut Window, cx: &mut Context) { + if self.prepare_window_close(window, cx) { + window.remove_window(); + } + } + + fn reset_for_reuse(&mut self) { + for _ in &self.tabs { + diagnostics::record_editor_tab_dropped(); + } + self.tabs.clear(); + self.active_tab = 0; + self.close_prompt_open = false; + self.pending_close_action = None; + self.close_window_after_saves = false; + self.prompt_generation += 1; + // Do NOT reset next_tab_id: late I/O from a discarded tab must never + // match a newly opened tab, even when its remote path is identical. + } + + fn handle_window_should_close(&mut self, window: &mut Window, cx: &mut Context) -> bool { + if self.close_prompt_open { + return false; + } + match decide_close_intercept(self.has_dirty_tabs(cx), self.close_prompt_open) { + CloseIntercept::Allow => self.prepare_window_close(window, cx), CloseIntercept::Ignore => false, CloseIntercept::Prompt => { if let Some(index) = self.first_dirty_tab(cx) { @@ -795,7 +860,11 @@ impl RemoteFileEditorWindow { CloseIntercept::Allow => self.close_clean_tab(index, window, cx), CloseIntercept::Ignore => {} CloseIntercept::Prompt => { - self.show_unsaved_changes_prompt(PendingCloseAction::Tab(index), window, cx); + self.show_unsaved_changes_prompt( + PendingCloseAction::Tab(self.tabs[index].id), + window, + cx, + ); } } } @@ -814,8 +883,7 @@ impl RemoteFileEditorWindow { self.focus_editor(window, cx); cx.notify(); } else { - clear_editor_window(); - window.remove_window(); + self.finish_window_close(window, cx); } } @@ -826,11 +894,12 @@ impl RemoteFileEditorWindow { cx: &mut Context, ) { match action { - PendingCloseAction::Window => { - clear_editor_window(); - window.remove_window(); + PendingCloseAction::Window => self.finish_window_close(window, cx), + PendingCloseAction::Tab(id) => { + if let Some(index) = self.tabs.iter().position(|tab| tab.id == id) { + self.close_clean_tab(index, window, cx); + } } - PendingCloseAction::Tab(index) => self.close_clean_tab(index, window, cx), } } @@ -842,7 +911,11 @@ impl RemoteFileEditorWindow { ) { match action { PendingCloseAction::Window => self.save_dirty_tabs_and_close_window(window, cx), - PendingCloseAction::Tab(index) => self.save_tab(index, true, window, cx), + PendingCloseAction::Tab(id) => { + if let Some(index) = self.tabs.iter().position(|tab| tab.id == id) { + self.save_tab(index, true, window, cx); + } + } } } @@ -855,8 +928,7 @@ impl RemoteFileEditorWindow { .collect::>(); if dirty_indexes.is_empty() { - clear_editor_window(); - window.remove_window(); + self.finish_window_close(window, cx); return; } @@ -874,6 +946,8 @@ impl RemoteFileEditorWindow { ) { self.close_prompt_open = true; self.pending_close_action = Some(action); + self.prompt_generation += 1; + let prompt_generation = self.prompt_generation; let prompt_title = t!("RemoteFileEditor.prompt.unsaved_title").to_string(); let prompt_message = t!("RemoteFileEditor.prompt.unsaved_message").to_string(); let save_label = t!("RemoteFileEditor.action.save").to_string(); @@ -897,6 +971,9 @@ impl RemoteFileEditorWindow { let selection = answer.await.ok(); let _ = cx.update_window(window_handle, |_, window, cx| { let _ = this.update(cx, |this, cx| { + if this.prompt_generation != prompt_generation { + return; + } let action = this.pending_close_action.take(); this.close_prompt_open = false; match (selection, action) { @@ -1256,6 +1333,10 @@ fn format_size(size: usize) -> String { } } +#[cfg(test)] +#[path = "editor_window_reuse_tests.rs"] +mod reuse_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/remote_file_editor/src/editor_window_reuse_tests.rs b/crates/remote_file_editor/src/editor_window_reuse_tests.rs new file mode 100644 index 000000000..40e0daf32 --- /dev/null +++ b/crates/remote_file_editor/src/editor_window_reuse_tests.rs @@ -0,0 +1,250 @@ +//! State-level tests deliberately avoid Tokio::spawn in GPUI's deterministic +//! scheduler. Native AppKit hide/reopen still needs a real Touch Bar machine. +use super::*; +use anyhow::Result; +use sftp::{DirectoryConflictPolicy, FileEntry, PathMetadata, ProgressCallback}; +use std::sync::atomic::AtomicBool; + +struct NoIoClient; + +// These fixtures exercise delivery/close state, never a network operation. +macro_rules! no_io_client { + ($($name:ident($($arg:ident: $ty:ty),*) -> $out:ty;)*) => { + #[async_trait::async_trait] + impl RemoteFileClient for NoIoClient { + $(async fn $name(&mut self, $($arg: $ty),*) -> Result<$out> { + panic!("unexpected I/O in editor lifecycle test: {}", stringify!($name)) + })* + } + }; +} + +no_io_client! { + list_dir(_path: &str) -> Vec; + stat(_path: &str) -> Option; + download_with_progress(_remote: &str, _local: &str, _cancel: Arc, _progress: ProgressCallback) -> (); + upload_with_progress(_local: &str, _remote: &str, _cancel: Arc, _progress: ProgressCallback) -> (); + delete(_path: &str, _is_dir: bool) -> (); + delete_recursive(_path: &str, _cancel: Arc, _progress: ProgressCallback) -> (); + mkdir(_path: &str) -> (); + rename(_old: &str, _new: &str) -> (); + chmod(_path: &str, _mode: u32) -> (); + read_file(_path: &str, _max: usize) -> Vec; + write_file(_path: &str, _content: &[u8]) -> (); + list_dir_recursive(_path: &str, _cancel: Arc) -> Vec; + download_dir_with_progress(_remote: &str, _local: &str, _cancel: Arc, _progress: ProgressCallback) -> (); + upload_dir_with_progress(_local: &str, _remote: &str, _policy: DirectoryConflictPolicy, _cancel: Arc, _progress: ProgressCallback) -> (); + disconnect() -> (); + realpath(_path: &str) -> String; +} + +fn client() -> SharedRemoteFileClient { + Arc::new(tokio::sync::Mutex::new(Box::new(NoIoClient))) +} + +fn empty_editor() -> RemoteFileEditorWindow { + diagnostics::record_editor_view_created(); + RemoteFileEditorWindow { + tabs: Vec::new(), + active_tab: 0, + close_prompt_open: false, + pending_close_action: None, + close_window_after_saves: false, + next_tab_id: 1, + prompt_generation: 0, + } +} + +fn add_tab(view: &mut RemoteFileEditorWindow, client: SharedRemoteFileClient) -> u64 { + let id = view.next_tab_id; + view.next_tab_id += 1; + view.tabs.push(RemoteEditorTab::new( + id, + "/same/path.txt".into(), + client, + RemoteMutationCallback::new(|_| panic!("stale save notified the remote browser")), + )); + diagnostics::record_editor_tab_created(); + id +} + +#[test] +fn hiding_releases_tab_connections_and_invalidates_close_state_without_reusing_ids() { + let _guard = diagnostics::GAUGE_LOCK.lock().unwrap(); + let baseline = diagnostics::snapshot(); + let mut view = empty_editor(); + let old_client = client(); + let weak_client = Arc::downgrade(&old_client); + let old_id = add_tab(&mut view, old_client); + view.active_tab = 10; + view.close_prompt_open = true; + view.pending_close_action = Some(PendingCloseAction::Tab(old_id)); + view.close_window_after_saves = true; + let old_prompt = view.prompt_generation; + + view.reset_for_reuse(); + assert!(view.tabs.is_empty()); + assert!(weak_client.upgrade().is_none()); + assert_eq!(view.active_tab, 0); + assert!(!view.close_prompt_open); + assert!(view.pending_close_action.is_none()); + assert!(!view.close_window_after_saves); + assert_ne!(view.prompt_generation, old_prompt); + assert!(add_tab(&mut view, client()) > old_id); + + // Repeated closes must not underflow the tab gauges or resurrect state. + view.reset_for_reuse(); + view.reset_for_reuse(); + assert!(view.tabs.is_empty()); + assert!(view.next_tab_id > old_id); + assert_eq!( + diagnostics::snapshot().live_editor_tabs, + baseline.live_editor_tabs + ); + drop(view); + assert_eq!(diagnostics::snapshot(), baseline); +} + +#[gpui::test] +fn old_io_completions_cannot_touch_reopened_same_path(cx: &mut gpui::TestAppContext) { + let _guard = diagnostics::GAUGE_LOCK.lock().unwrap(); + let cx = cx.add_empty_window(); + let view = cx.new(|_| empty_editor()); + view.update_in(cx, |view, window, cx| { + let old_id = add_tab(view, client()); + view.reset_for_reuse(); + let new_id = add_tab(view, client()); + view.tabs[0].saved_text = "new connection".into(); + view.close_window_after_saves = true; + + assert!(!view.apply_load_error(old_id, "/same/path.txt", "old read error".into(), cx)); + assert!(!view.apply_save_error(old_id, "/same/path.txt", "old write error".into(), cx)); + view.apply_loaded_file( + old_id, + "/same/path.txt", + LoadedFile { + text: "old server".into(), + policy: FilePolicy { + mode: EditorMode::Code, + is_large_file: false, + }, + file_size: 10, + language: "plain".into(), + }, + window, + cx, + ); + // close_after_save=true must not close the newly opened window/tab. + view.apply_saved_file( + old_id, + "/same/path.txt", + "old save".into(), + true, + window, + cx, + ); + assert_eq!(view.tabs.len(), 1); + assert_eq!(view.tabs[0].id, new_id); + assert_eq!(view.tabs[0].saved_text, "new connection"); + assert!(view.tabs[0].editor.is_none()); + assert!(view.tabs[0].loading); + assert!(view.close_window_after_saves); + assert!(view.apply_load_error(new_id, "/same/path.txt", "current error".into(), cx)); + assert!(!view.tabs[0].loading); + }); + drop(view); + cx.run_until_parked(); +} + +#[gpui::test] +fn close_prompt_targets_tab_identity_not_shifted_index(cx: &mut gpui::TestAppContext) { + let _guard = diagnostics::GAUGE_LOCK.lock().unwrap(); + let cx = cx.add_empty_window(); + let view = cx.new(|_| empty_editor()); + view.update_in(cx, |view, window, cx| { + let first = add_tab(view, client()); + let target = add_tab(view, client()); + let last = add_tab(view, client()); + view.close_clean_tab(0, window, cx); + assert!(view.tabs.iter().all(|tab| tab.id != first)); + view.discard_close_action(PendingCloseAction::Tab(target), window, cx); + assert_eq!(view.tabs.len(), 1); + assert_eq!(view.tabs[0].id, last); + // A response for an already closed tab must be a no-op. + view.discard_close_action(PendingCloseAction::Tab(target), window, cx); + assert_eq!(view.tabs.len(), 1); + }); + drop(view); + cx.run_until_parked(); +} + +#[gpui::test] +fn same_path_on_different_connections_focuses_the_matching_tab(cx: &mut gpui::TestAppContext) { + let _guard = diagnostics::GAUGE_LOCK.lock().unwrap(); + let cx = cx.add_empty_window(); + let view = cx.new(|_| empty_editor()); + view.update_in(cx, |view, window, cx| { + let first_client = client(); + let second_client = client(); + add_tab(view, first_client.clone()); + add_tab(view, second_client.clone()); + view.open_or_focus_tab( + "/same/path.txt".into(), + second_client, + RemoteMutationCallback::new(|_| {}), + window, + cx, + ); + assert_eq!(view.active_tab, 1); + assert_eq!(view.tabs.len(), 2); + view.open_or_focus_tab( + "/same/path.txt".into(), + first_client, + RemoteMutationCallback::new(|_| {}), + window, + cx, + ); + assert_eq!(view.active_tab, 0); + assert_eq!(view.tabs.len(), 2); + }); + drop(view); + cx.run_until_parked(); +} + +#[gpui::test] +fn prompt_cancel_preserves_tabs_and_old_prompt_cannot_close_reused_session( + cx: &mut gpui::TestAppContext, +) { + let _guard = diagnostics::GAUGE_LOCK.lock().unwrap(); + let cx = cx.add_empty_window(); + let view = cx.new(|_| empty_editor()); + view.update_in(cx, |view, window, cx| { + add_tab(view, client()); + view.show_unsaved_changes_prompt(PendingCloseAction::Window, window, cx); + // Closing again while a sheet is up must not hide/destroy the window. + assert!(!view.handle_window_should_close(window, cx)); + }); + cx.simulate_prompt_answer(&t!("RemoteFileEditor.action.cancel")); + cx.run_until_parked(); + view.update_in(cx, |view, window, cx| { + assert_eq!(view.tabs.len(), 1); + assert!(!view.close_prompt_open); + assert!(view.pending_close_action.is_none()); + view.show_unsaved_changes_prompt(PendingCloseAction::Window, window, cx); + view.reset_for_reuse(); + add_tab(view, client()); + // A later session has its own pending action. The previous answer + // must not take it or clear its prompt flag. + view.pending_close_action = Some(PendingCloseAction::Window); + view.close_prompt_open = true; + }); + cx.simulate_prompt_answer(&t!("RemoteFileEditor.action.discard")); + cx.run_until_parked(); + view.update(cx, |view, _| { + assert_eq!(view.tabs.len(), 1); + assert!(view.close_prompt_open); + assert_eq!(view.pending_close_action, Some(PendingCloseAction::Window)); + }); + drop(view); + cx.run_until_parked(); +} diff --git a/crates/remote_file_editor/src/editor_window_visibility.rs b/crates/remote_file_editor/src/editor_window_visibility.rs new file mode 100644 index 000000000..8e63cee37 --- /dev/null +++ b/crates/remote_file_editor/src/editor_window_visibility.rs @@ -0,0 +1,35 @@ +//! Keep the macOS editor's native window alive across close/reopen (issue #262). +//! This avoids the suspected Touch Bar observer teardown trigger, not all +//! possible AppKit exceptions. The caller still owns the GPUI window. + +/// `true` means hidden and reusable; `false` preserves other platforms' close +/// behavior. Errors let the caller fall back to actual window removal. +#[cfg(target_os = "macos")] +pub(super) fn hide_for_reuse(window: &gpui::Window) -> anyhow::Result { + use anyhow::Context as _; + use objc2_app_kit::NSView; + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + let Some(_main_thread) = objc2::MainThreadMarker::new() else { + anyhow::bail!("remote editor window must be hidden on the AppKit main thread"); + }; + let handle = HasWindowHandle::window_handle(window) + .context("remote editor has no native window handle")?; + let RawWindowHandle::AppKit(raw) = handle.as_raw() else { + anyhow::bail!("remote editor does not have an AppKit window handle"); + }; + // SAFETY: GPUI owns this live NSView for the duration of the window borrow. + // The reference stays within this function and AppKit's main thread. + let view: &NSView = unsafe { raw.ns_view.cast::().as_ref() }; + let native = view + .window() + .context("remote editor NSView has no NSWindow")?; + native.orderOut(None); + anyhow::ensure!(!native.isVisible(), "AppKit did not hide the remote editor"); + Ok(true) +} + +#[cfg(not(target_os = "macos"))] +pub(super) fn hide_for_reuse(_window: &gpui::Window) -> anyhow::Result { + Ok(false) +} diff --git a/crates/remote_file_editor/src/lib.rs b/crates/remote_file_editor/src/lib.rs index 5b46a0b62..f07e0150c 100644 --- a/crates/remote_file_editor/src/lib.rs +++ b/crates/remote_file_editor/src/lib.rs @@ -3,6 +3,7 @@ rust_i18n::i18n!("locales", fallback = "en"); mod close_guard; mod diagnostics; mod editor_window; +mod editor_window_visibility; mod external_edit_controller; mod external_editor; mod external_editor_confirmation; diff --git a/crates/remote_file_editor/src/window_close_contract_tests.rs b/crates/remote_file_editor/src/window_close_contract_tests.rs index 7c749ff20..c9215d770 100644 --- a/crates/remote_file_editor/src/window_close_contract_tests.rs +++ b/crates/remote_file_editor/src/window_close_contract_tests.rs @@ -90,3 +90,68 @@ fn editor_lifecycle_counters_are_released_on_every_close_path() { assert!(open.contains("record_editor_tab_created()")); assert!(source.contains("record_editor_view_created()")); } + +#[test] +fn all_editor_close_paths_use_the_reusable_window_boundary() { + let source = include_str!("editor_window.rs"); + let native_close = method_source(source, "fn handle_window_should_close("); + assert!(native_close.contains("self.prepare_window_close(window, cx)")); + for signature in [ + "fn apply_saved_file(", + "fn close_clean_tab(", + "fn discard_close_action(", + "fn save_dirty_tabs_and_close_window(", + ] { + let body = method_source(source, signature); + assert!( + body.contains("self.finish_window_close(window, cx)"), + "{signature}" + ); + assert!(!body.contains("window.remove_window()"), "{signature}"); + } +} + +#[test] +fn reused_editor_tabs_keep_their_own_remote_connection() { + let source = include_str!("editor_window.rs"); + let open = method_source(source, "fn open_or_focus_tab("); + assert!(open.contains("Arc::ptr_eq(&tab.client, &client)")); + for signature in ["fn reload_tab(", "fn save_tab("] { + let body = method_source(source, signature); + assert!(body.contains("tab.client.clone()"), "{signature}"); + assert!(!body.contains("self.client"), "{signature}"); + // A closed tab's error must not appear on a later use of this window. + assert!(body.contains("if this.apply_"), "{signature}"); + } +} + +#[test] +fn native_hide_is_macos_only_and_keeps_the_window_registered() { + let platform = include_str!("editor_window_visibility.rs"); + assert!(platform.contains("#[cfg(target_os = \"macos\")]")); + assert!(platform.contains("MainThreadMarker::new()")); + assert!(platform.contains("native.orderOut(None)")); + assert!(platform.contains("!native.isVisible()")); + let other_platforms = platform + .split("#[cfg(not(target_os = \"macos\"))]") + .nth(1) + .expect("non-macOS close behavior"); + assert!(other_platforms.contains("Ok(false)")); + + let source = include_str!("editor_window.rs"); + let prepare = method_source(source, "fn prepare_window_close("); + let hidden = prepare + .split("Ok(true) =>") + .nth(1) + .unwrap() + .split("Ok(false)") + .next() + .unwrap(); + assert!(hidden.contains("self.reset_for_reuse()")); + assert!(hidden.contains("false")); + assert!(!hidden.contains("clear_editor_window")); + assert!(!hidden.contains("remove_window")); + let reset = method_source(source, "fn reset_for_reuse("); + assert!(!reset.contains("self.next_tab_id =")); + assert!(reset.contains("self.tabs.clear()")); +} From 21979414e6504b57b980a2d28cd8c5a154cfc6fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Mon, 21 Sep 2026 20:42:43 +0800 Subject: [PATCH 3/3] =?UTF-8?q?chore(release):=20=E5=87=86=E5=A4=87=20v0.1?= =?UTF-8?q?8.6=EF=BC=88CHANGELOG=20=E5=8F=8C=E8=AF=AD=E6=9D=A1=E7=9B=AE=20?= =?UTF-8?q?+=20=E7=89=88=E6=9C=AC=E5=8F=B7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG.md 增加 v0.18.6 双语发布说明,覆盖本版全部改动: - 新功能:数据库对象列表全选与拖选、PostgreSQL 外部表与物化视图 - 修复:SQL 页签并发执行、MySQL 未知字符集兜底解码、 SSH 目录上传受限并发、macOS 关闭内置远程编辑器不再崩溃 - main/Cargo.toml 与 Cargo.lock 的主包版本 0.18.5 -> 0.18.6 - 一并把 origin/main 合入 dev,使发布 PR 收口 v0.18.5 之后的全部改动 --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ Cargo.lock | 2 +- main/Cargo.toml | 2 +- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd5053ce1..0ffb30190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,38 @@ Navop user-facing release notes. Generate and review each bilingual version entr +## [v0.18.6] - 2026-09-21 + +#### 更新内容 + +- 数据库对象列表支持全选与拖选。表、视图、存储过程等对象现在可以 Shift 点击或用鼠标拖选多行,Cmd/Ctrl 拖选在已有选择上继续追加,来回拖动区间可正常收缩。修掉了两处交互问题:在列表外松开鼠标时不再沿用旧锚点截出错误区间;Shift 点击只把目标行滚动到最近位置,不会再把可见行整块顶走。 +- PostgreSQL 支持外部表和物化视图。此前 PG 连接的表目录写死只列普通表,外部表与分区表根本列不出来,物化视图也没有入口(视图列表不含它),用户已经建好的对象在 Navop 里等于不存在。现在外部表与普通表、分区表同处「表」目录,类型列分别显示 Table / Partitioned Table / Foreign Table,物化视图单独一个目录;重命名、清空、删除、转储等右键动作按对象类型分别生成 SQL,非 public schema 下也带 schema 限定名,不会把 `DROP TABLE` 打到外部表上。外部表不参与结构比较、数据比较、ER 图与整库 DDL 转储(数据转储仍可用)。 + +#### 修复与优化 + +- 修复 SQL 页签无法并发执行的问题。A 页签执行时 B 页签就连不上,即使 B 连的是另一台库——根因是整张会话表共用一把锁,取到连接后要一直持有到语句执行结束,等于所有页签排队。会话池的复用与释放还有三处并发缺陷:释放与下一条语句交错时会把正在执行的会话标成空闲,随后被清理回收(报 session not found);复用扫描一个正在跑长语句的会话会连带堵住其它页签;会话被并发摘除时会重复断开连接。现在锁粒度降到每个会话各自的连接,会话状态查询不再等待执行中的语句,复用与生命周期按引用计数同步,一条语句恰好占用与释放一次。同一会话内仍保持互斥。 +- 修复 MySQL 文本列被显示成二进制的问题。连接某些 MySQL 兼容实现或代理时,结果列元数据里的字符集为 0,取不到解码器就一律降级成二进制,文本列在网格里显示为「二进制 · N B」,表结构元数据、自定义 SQL 与 UNION ALL 结果同样受影响,而这些字节其实是合法 UTF-8。现在字符集未知时按严格 UTF-8 兜底解码,非法 UTF-8 或含控制字符的字节仍保留二进制。 +- 修复 SSH 目录上传小文件过慢的问题。上传每个文件固定要付 6 次控制往返(stat / open / fsync / fstat / close / rename),串行执行时吞吐由网络往返延迟决定而不是带宽,且任何时刻只有一个文件在传。现在小文件走受限并发(并发度 6),超过 512 KiB 的文件独占预算、行为与串行时一致,不会在内存里堆积未确认字节;失败语义不变,出错后不再接纳新任务、等在飞任务跑完再返回,避免残留暂存文件。进度累计改为全局原子量,并发上报时不再回跳。 +- 修复 macOS 上关闭内置远程编辑器导致应用崩溃的问题。Intel Touch Bar 机型上关闭编辑器弹窗必现崩溃:原生窗口被销毁后,系统在显示周期里注销 Touch Bar 观察者时抛出未捕获异常,进程被直接终止。现在 macOS 上关闭不再销毁原生窗口,改为隐藏并复用,下次打开直接复用该窗口;同时把连接改为按标签持有,修掉跨会话复用会走错连接的问题,旧弹窗的确认也不会作用到复用后的新会话上。 + +国内下载:如果 GitHub 下载较慢,可从 [CNB 镜像](https://cnb.cool/navop-dev/navop/-/releases/tag/v0.18.6) 下载桌面端安装包 + +--- + +#### What's New + +- Database object lists now support select all and drag-select. Tables, views and stored procedures can be Shift-clicked or drag-selected as multiple rows, Cmd/Ctrl drag-select adds to the existing selection, and dragging back and forth shrinks the range as expected. Two interaction bugs are fixed as well: releasing the mouse outside the list no longer reuses a stale anchor and cuts out a wrong range, and Shift-click scrolls the target row just into view instead of pushing the visible rows away. +- PostgreSQL foreign tables and materialized views are now supported. The table catalogue for PG connections was hard-coded to ordinary tables only, so foreign and partitioned tables simply never showed up, and materialized views had no entry point at all because the view list does not include them — objects the user had already created did not exist in Navop. Foreign tables now live in the same "Tables" folder as ordinary and partitioned tables, with the type column showing Table / Partitioned Table / Foreign Table, and materialized views get their own folder. Rename, truncate, drop and dump actions generate SQL according to the object type and are schema-qualified outside `public`, so `DROP TABLE` is never issued against a foreign table. Foreign tables are excluded from schema comparison, data comparison, ER diagrams and whole-database DDL dumps (data dumps still work). + +#### Fixes and Improvements + +- Fixed SQL tabs not being able to execute concurrently. While one tab was running, no other tab could execute even when connected to a different database: the whole session table shared a single lock, held from acquiring the connection until the statement finished, so every tab queued behind it. The session pool also had three concurrency defects: releasing a session interleaved with the next statement could mark a running session as idle, which was then reclaimed by the idle sweep (surfacing as "session not found"); scanning for a reusable session blocked other tabs when it hit one running a long statement; and a concurrently detached session could be disconnected twice. Locking is now per-session on the connection itself, session state queries no longer wait for a running statement, and reuse and lifecycle are synchronized by reference counting so a statement acquires and releases exactly once. Mutual exclusion within the same session is preserved. +- Fixed MySQL text columns being displayed as binary. When connecting to some MySQL-compatible implementations or proxies, the character set in the result column metadata is 0, so with no decoder available every column was downgraded to binary and text columns showed up as "二进制 · N B" in the grid. Table structure metadata, custom SQL and UNION ALL results were affected in the same way, even though the bytes were valid UTF-8. Unknown character sets now fall back to strict UTF-8 decoding; bytes that are not valid UTF-8 or contain control characters still stay binary. +- Fixed SSH directory uploads being slow with many small files. Every uploaded file costs six control round trips (stat / open / fsync / fstat / close / rename), so serial execution made throughput depend on round-trip latency rather than bandwidth, and only one file was ever in flight. Small files now use bounded concurrency (6 in flight), while files above 512 KiB take the whole budget and behave exactly as before, so unacknowledged bytes are not piled up in memory. Failure semantics are unchanged: after the first error no new task is accepted and in-flight ones are awaited before returning, which keeps temporary files from being left behind. Progress accumulation is now a global atomic counter, so concurrent reports no longer jump backwards. +- Fixed the app crashing on macOS when closing the built-in remote editor. On Intel Touch Bar models closing the editor dialog crashed every time: after the native window was destroyed, the system unregistered the Touch Bar observer during a display cycle and threw an uncaught exception, terminating the process. Closing no longer destroys the native window on macOS — it is hidden and reused, and reopening reuses that window. Connections are now held per tab, fixing cross-session reuse hitting the wrong connection, and a confirmation from a previous dialog can no longer act on the reused session. + +**Full Changelog**: https://github.com/feigeCode/navop/compare/v0.18.5...v0.18.6 + ## [v0.18.5] - 2026-09-20 #### 修复与优化 diff --git a/Cargo.lock b/Cargo.lock index dfd0c4d81..254e369af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7931,7 +7931,7 @@ checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" [[package]] name = "main" -version = "0.18.5" +version = "0.18.6" dependencies = [ "agent_runtime", "ai_chat_view", diff --git a/main/Cargo.toml b/main/Cargo.toml index 5aa6567d7..3f736e2fc 100644 --- a/main/Cargo.toml +++ b/main/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "main" -version = "0.18.5" +version = "0.18.6" publish.workspace = true edition.workspace = true