diff --git a/Cargo.lock b/Cargo.lock index cc1542e..ed7906e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4017,6 +4017,15 @@ dependencies = [ "lock_api", ] +[[package]] +name = "sqlparser" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7bbffee862a796d67959a89859d6b1046bb5016d63e23835ad0da182777bbe0" +dependencies = [ + "log", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5489,6 +5498,7 @@ dependencies = [ "serde_json", "serial_test", "sha2 0.10.9", + "sqlparser", "syntect", "sysinfo", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 806cc49..ac14783 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ zenwebp = { version = "0.3", optional = true } moka = { version = "0.12", features = ["future", "sync"], optional = true } governor = { version = "0.8", optional = true } sysinfo = { version = "0.34", optional = true } +sqlparser = { version = "0.45", optional = true } md-5 = { version = "0.10", optional = true } futures = { version = "0.3", optional = true } bytes = { version = "1", optional = true } @@ -89,6 +90,7 @@ server = [ "dep:moka", "dep:governor", "dep:sysinfo", + "dep:sqlparser", "dep:md-5", "dep:futures", "dep:bytes", diff --git a/src/api/database/mod.rs b/src/api/database/mod.rs index 0076df7..dc117c8 100644 --- a/src/api/database/mod.rs +++ b/src/api/database/mod.rs @@ -10,3 +10,7 @@ pub mod status; /// 服务器状态聚合查询(应用内 + 主机层)。 pub mod system_status; +/// SQL 控制台执行(全读写 + 4 护栏)。 +pub mod sql_console; +/// SQL 补全用 schema 拉取。 +pub mod schema; diff --git a/src/api/database/schema.rs b/src/api/database/schema.rs new file mode 100644 index 0000000..f107f01 --- /dev/null +++ b/src/api/database/schema.rs @@ -0,0 +1,48 @@ +#![allow(clippy::unused_unit, deprecated)] + +//! SQL 补全用 schema 拉取(供 CodeMirror lang-sql 表/列补全)。 + +use dioxus::prelude::*; + +use crate::api::auth::get_current_admin_user; +use crate::api::error::AppError; +use crate::codemirror_bridge::SqlSchema; +use crate::db::pool::get_conn; + +/// 拉取数据库 schema(表名 + 列名),供 CodeMirror SQL 补全。 +#[server(GetDbSchema, "/api")] +pub async fn get_db_schema() -> Result { + let _user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + let client = get_conn().await.map_err(AppError::db_conn)?; + let rows = client + .query( + "SELECT t.table_name, \ + string_agg(c.column_name, ',' ORDER BY c.ordinal_position) \ + FROM information_schema.tables t \ + JOIN information_schema.columns c USING (table_schema, table_name) \ + WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE' \ + GROUP BY t.table_name ORDER BY t.table_name", + &[], + ) + .await + .map_err(AppError::query)?; + let tables = rows + .into_iter() + .map(|r| { + let cols: String = r.get(1); + crate::codemirror_bridge::SqlTable { + name: r.get(0), + columns: cols.split(',').map(|s| s.to_string()).collect(), + } + }) + .collect(); + Ok(SqlSchema { tables }) + } + #[cfg(not(feature = "server"))] + { + Ok(SqlSchema::default()) + } +} diff --git a/src/api/database/sql_console.rs b/src/api/database/sql_console.rs new file mode 100644 index 0000000..b74744d --- /dev/null +++ b/src/api/database/sql_console.rs @@ -0,0 +1,320 @@ +#![allow(clippy::unused_unit, deprecated)] + +//! SQL 控制台执行(全读写 + 4 道护栏)。 +//! +//! 护栏: +//! 1. 高危语句闸门:`DROP DATABASE`/`DROP SCHEMA`(字符串预检)绝禁; +//! `DROP`/`TRUNCATE`/`ALTER` 需 `confirm_dangerous`。 +//! 2. 无 WHERE 拦截:`UPDATE`/`DELETE` 无 `selection` 拒绝。 +//! 3. 查询超时上限:复用 `STATEMENT_TIMEOUT_SECS`(pool 层已注入 GUC)。 +//! 4. 前端二次确认(前端实现)。 +//! +//! 默认禁止多语句(`allow_multi` 放开)。 + +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::api::auth::get_current_admin_user; +use crate::api::error::AppError; +use crate::db::pool::get_conn; + +#[derive(Deserialize, Serialize, Debug, Clone, Copy)] +pub struct ExecuteSqlOpts { + /// 是否允许多语句(`;` 分隔),默认 false。 + pub allow_multi: bool, + /// 是否勾选「我了解后果」(放开 DROP/TRUNCATE/ALTER 等高危)。 + pub confirm_dangerous: bool, + /// 是否带 EXPLAIN 执行计划。 + pub with_explain: bool, +} + +#[derive(Serialize, Deserialize, Debug, Default, Clone)] +pub struct SqlResult { + pub columns: Vec, + /// 每格用 JSON 表示(text/int/timestamp/bool/null)。 + pub rows: Vec>, + pub affected_rows: u64, + pub elapsed_ms: u64, + /// 语句类型(来自 AST,如 "Select"/"Update"/"CreateTable")。 + pub statement_type: String, + pub explain: Option, + /// 是否因 500 行上限截断。 + pub truncated: bool, + /// 截断时的估算总行数(重查用)。 + pub total_estimate: Option, +} + +/// 结果行数上限(超出截断 + 提示)。 +const MAX_ROWS: usize = 500; + +/// 绝对禁止的语句关键词(字符串预检,sqlparser 无 ObjectType::Database/Schema)。 +/// 命中即拒,不可放行。 +const ABSOLUTELY_FORBIDDEN: &[&str] = &["drop database", "drop schema", "create database"]; + +/// 护栏检查返回值。 +#[derive(Debug)] +enum GuardResult { + Allowed, + /// 需 confirm_dangerous 才放行。 + NeedsConfirm, + /// 不可放行(附带原因)。 + Forbidden(String), +} + +/// 护栏 1+2:sqlparser 解析后遍历 AST,检查高危语句与无 WHERE 的 UPDATE/DELETE。 +#[cfg(feature = "server")] +fn check_guards( + asts: &[sqlparser::ast::Statement], + confirm_dangerous: bool, +) -> GuardResult { + use sqlparser::ast::Statement; + + for stmt in asts { + match stmt { + // 护栏 1:需确认的高危语句 + Statement::Drop { .. } | Statement::Truncate { .. } | Statement::AlterTable { .. } => { + if !confirm_dangerous { + return GuardResult::NeedsConfirm; + } + } + // 护栏 2:UPDATE 无 WHERE + Statement::Update { selection: None, .. } => { + return GuardResult::Forbidden( + "UPDATE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(), + ); + } + // 护栏 2:DELETE 无 WHERE + Statement::Delete { selection: None, .. } => { + return GuardResult::Forbidden( + "DELETE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(), + ); + } + _ => {} + } + } + GuardResult::Allowed +} + +/// 提取语句类型名(AST 变体名,如 "Select"/"Insert"/"Update")。 +#[cfg(feature = "server")] +fn statement_type_name(stmt: &sqlparser::ast::Statement) -> String { + use sqlparser::ast::Statement; + let name = match stmt { + Statement::Query(_) => "Select", + Statement::Insert { .. } => "Insert", + Statement::Update { .. } => "Update", + Statement::Delete { .. } => "Delete", + Statement::CreateTable { .. } => "CreateTable", + Statement::AlterTable { .. } => "AlterTable", + Statement::Drop { .. } => "Drop", + Statement::Truncate { .. } => "Truncate", + Statement::Explain { .. } => "Explain", + _ => "Other", + }; + name.to_string() +} + +/// 判断语句是否只读(SELECT/EXPLAIN/SHOW/WITH...SELECT)。 +#[cfg(feature = "server")] +fn is_read_only(stmt: &sqlparser::ast::Statement) -> bool { + use sqlparser::ast::Statement; + matches!( + stmt, + Statement::Query(_) | Statement::Explain { .. } + ) +} + +/// 把一列的值转成 JSON(按 PG 类型名分发)。 +#[cfg(feature = "server")] +fn col_to_json(row: &tokio_postgres::Row, idx: usize) -> serde_json::Value { + use serde_json::json; + let ty = row.columns().get(idx).map(|c| c.type_().name()).unwrap_or(""); + match ty { + "int2" => row + .try_get::<_, Option>(idx) + .ok() + .flatten() + .map(|v| json!(v)) + .unwrap_or(serde_json::Value::Null), + "int4" => row + .try_get::<_, Option>(idx) + .ok() + .flatten() + .map(|v| json!(v)) + .unwrap_or(serde_json::Value::Null), + "int8" => row + .try_get::<_, Option>(idx) + .ok() + .flatten() + .map(|v| json!(v)) + .unwrap_or(serde_json::Value::Null), + "float4" => row + .try_get::<_, Option>(idx) + .ok() + .flatten() + .map(|v| json!(v)) + .unwrap_or(serde_json::Value::Null), + "float8" => row + .try_get::<_, Option>(idx) + .ok() + .flatten() + .map(|v| json!(v)) + .unwrap_or(serde_json::Value::Null), + "bool" => row + .try_get::<_, Option>(idx) + .ok() + .flatten() + .map(|v| json!(v)) + .unwrap_or(serde_json::Value::Null), + // 其余(text/varchar/timestamp/jsonb/...)一律按字符串取,失败则 null + _ => row + .try_get::<_, Option>(idx) + .ok() + .flatten() + .map(|v| json!(v)) + .unwrap_or(serde_json::Value::Null), + } +} + +/// 执行 SQL(全读写,管理员)。护栏见模块文档。 +#[server(ExecuteSql, "/api")] +pub async fn execute_sql(sql: String, opts: ExecuteSqlOpts) -> Result { + let _user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + use sqlparser::dialect::PostgreSqlDialect; + use sqlparser::parser::Parser; + + // 护栏 1(绝禁):字符串预检 DROP/CREATE DATABASE、DROP SCHEMA + let normalized = sql.to_lowercase(); + for forbidden in ABSOLUTELY_FORBIDDEN { + if normalized.contains(forbidden) { + return Err(AppError::BadRequest(format!( + "禁止的操作:{}", + forbidden.to_uppercase() + )) + .into()); + } + } + + // 解析 SQL + let dialect = PostgreSqlDialect {}; + let asts = Parser::parse_sql(&dialect, &sql) + .map_err(|e| AppError::BadRequest(format!("SQL 解析失败:{e}")))?; + if asts.is_empty() { + return Err(AppError::BadRequest("空的 SQL 语句".into()).into()); + } + + // 多语句检查(默认禁止) + if asts.len() > 1 && !opts.allow_multi { + return Err(AppError::BadRequest( + "检测到多条语句,请勾选「允许多语句」后再执行".into(), + ) + .into()); + } + + // 护栏 1+2:AST 检查 + match check_guards(&asts, opts.confirm_dangerous) { + GuardResult::Forbidden(msg) => { + return Err(AppError::BadRequest(msg).into()); + } + GuardResult::NeedsConfirm => { + return Err(AppError::BadRequest( + "高危操作(DROP/TRUNCATE/ALTER),需勾选「我了解后果」".into(), + ) + .into()); + } + GuardResult::Allowed => {} + } + + let client = get_conn().await.map_err(AppError::db_conn)?; + let start = std::time::Instant::now; + + // 逐条执行(allow_multi 时多条,否则单条) + let mut last_result = SqlResult::default(); + for stmt in &asts { + last_result = execute_one(&client, stmt, &sql, opts.with_explain, start).await?; + } + Ok(last_result) + } + #[cfg(not(feature = "server"))] + { + let _ = (sql, opts); + Ok(SqlResult::default()) + } +} + +/// 执行单条语句,返回结果。 +#[cfg(feature = "server")] +async fn execute_one( + client: &deadpool_postgres::Object, + stmt: &sqlparser::ast::Statement, + sql: &str, + with_explain: bool, + start: impl Fn() -> std::time::Instant + Copy, +) -> Result { + let statement_type = statement_type_name(stmt); + let read_only = is_read_only(stmt); + + if with_explain && read_only { + // EXPLAIN 模式:包裹原 SQL 执行计划,取首列文本拼接 + let explain_sql = format!("EXPLAIN {}", sql.trim_end_matches(';')); + let rows = client + .query(&explain_sql, &[]) + .await + .map_err(AppError::query)?; + let explain = rows + .iter() + .filter_map(|r| r.try_get::<_, String>(0).ok()) + .collect::>() + .join("\n"); + return Ok(SqlResult { + statement_type, + explain: Some(explain), + elapsed_ms: start().elapsed().as_millis() as u64, + ..Default::default() + }); + } + + if read_only { + // 只读:取结果集。列名从第一行取(空结果集时无列名,前端容错)。 + let rows = client.query(sql, &[]).await.map_err(AppError::query)?; + let columns: Vec = rows + .first() + .map(|r| { + r.columns() + .iter() + .map(|c| c.name().to_string()) + .collect() + }) + .unwrap_or_default(); + let mut data: Vec> = Vec::new(); + let mut truncated = false; + for r in &rows { + if data.len() >= MAX_ROWS { + truncated = true; + break; + } + let row: Vec = (0..r.len()).map(|i| col_to_json(r, i)).collect(); + data.push(row); + } + Ok(SqlResult { + columns, + rows: data, + truncated, + statement_type, + elapsed_ms: start().elapsed().as_millis() as u64, + ..Default::default() + }) + } else { + // 写操作:返回影响行数 + let affected = client.execute(sql, &[]).await.map_err(AppError::query)?; + Ok(SqlResult { + affected_rows: affected, + statement_type, + elapsed_ms: start().elapsed().as_millis() as u64, + ..Default::default() + }) + } +} diff --git a/src/api/error.rs b/src/api/error.rs index 5221b4b..1e5f259 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -14,6 +14,8 @@ pub enum AppError { Forbidden(&'static str), /// 资源不存在(404)。 NotFound(&'static str), + /// 客户端请求错误(400)——业务规则拒绝,消息原样透传给用户。 + BadRequest(String), /// 数据库连接失败。 DbConn(String), /// SQL 查询执行失败。 @@ -54,6 +56,8 @@ impl From for ServerFnError { AppError::Unauthorized(m) => m.to_string(), AppError::Forbidden(m) => m.to_string(), AppError::NotFound(m) => m.to_string(), + // BadRequest 是业务规则拒绝(如 SQL 护栏拦截),消息原样透传给用户。 + AppError::BadRequest(m) => m.to_string(), AppError::DbConn(_) => "服务暂时不可用".to_string(), AppError::Query(_) => "操作失败".to_string(), AppError::Transaction(_) => "操作失败".to_string(), diff --git a/src/pages/admin/system.rs b/src/pages/admin/system.rs index 2a76362..d111df0 100644 --- a/src/pages/admin/system.rs +++ b/src/pages/admin/system.rs @@ -61,7 +61,7 @@ pub fn System() -> Element { match active_tab() { SystemTab::DbStatus => rsx! { DbStatusTab {} }, SystemTab::ServerStatus => rsx! { ServerStatusTab {} }, - SystemTab::SqlConsole => rsx! { div { class: "text-paper-secondary py-8", "SQL 控制台(待实现)" } }, + SystemTab::SqlConsole => rsx! { SqlConsoleTab {} }, SystemTab::Export => rsx! { div { class: "text-paper-secondary py-8", "数据导出(待实现)" } }, SystemTab::Backup => rsx! { div { class: "text-paper-secondary py-8", "备份恢复(待实现)" } }, } @@ -634,3 +634,269 @@ fn ServerStatusTab() -> Element { } } } + +/// SQL 控制台 tab:CodeMirror 编辑器(SQL 高亮/补全/Vim)+ 4 道护栏 + 结果表 + EXPLAIN。 +/// +/// 护栏 4(前端二次确认):提交写操作前弹窗确认。 +#[allow(non_snake_case)] +fn SqlConsoleTab() -> Element { + use crate::api::database::sql_console::SqlResult; + #[cfg(target_arch = "wasm32")] + use crate::api::database::sql_console::{execute_sql, ExecuteSqlOpts}; + #[cfg(target_arch = "wasm32")] + use crate::api::database::schema::get_db_schema; + use crate::components::ui::ADMIN_TABLE_CLASS; + #[cfg(target_arch = "wasm32")] + use crate::codemirror_bridge; + use crate::theme::use_theme; + #[cfg(target_arch = "wasm32")] + use crate::theme::Theme; + + let theme = use_theme(); + let sql_text = use_signal(String::new); + let result = use_signal(|| Option::::None); + let mut error = use_signal(|| Option::::None); + let mut running = use_signal(|| false); + // 选项 toggles + let mut with_explain = use_signal(|| false); + let mut allow_multi = use_signal(|| false); + let mut confirm_dangerous = use_signal(|| false); + // theme/sql_text 仅在 wasm32 块内使用;server 构建时显式引用避免 unused 警告。 + #[cfg(not(target_arch = "wasm32"))] + let _ = (&theme, &sql_text); + + // CodeMirror 实例句柄(仅 WASM)。 + #[cfg(target_arch = "wasm32")] + let mut editor_handle: Signal> = use_signal(|| None); + + // 初始化 CodeMirror + 拉取 schema 注入补全。仅 WASM。 + #[cfg(target_arch = "wasm32")] + { + use dioxus::prelude::wasm_bindgen::closure::Closure; + use_effect(move || { + if editor_handle().is_some() { + return; + } + let mut text = sql_text; + let on_change = Closure::new(move |v: String| { + text.set(v); + }); + let on_ready = Closure::new(|| {}); + + let theme_name = if theme() == Theme::Dark { "dark" } else { "light" }; + let opts = codemirror_bridge::EditorOptions::new(); + opts.set_language("sql"); + opts.set_theme(theme_name); + opts.set_vim(true); + opts.set_on_change(&on_change); + opts.set_on_ready(&on_ready); + + match codemirror_bridge::get_module().create("sql-editor", &opts) { + Ok(Some(inst)) => { + let handle = codemirror_bridge::EditorHandle::new(inst, on_change, on_ready); + editor_handle.set(Some(handle)); + } + _ => {} + } + + // 异步拉取 schema 注入补全 + spawn(async move { + if let Ok(schema) = get_db_schema().await { + if let Some(h) = editor_handle.read().as_ref() { + h.instance().set_schema(&schema); + } + } + }); + }); + + // 主题切换时同步编辑器主题 + use_effect(move || { + let t = theme(); + if let Some(h) = editor_handle.read().as_ref() { + h.instance().set_theme(if t == Theme::Dark { "dark" } else { "light" }); + } + }); + } + + // 执行 SQL + let mut run_sql = move || { + running.set(true); + error.set(None); + #[cfg(target_arch = "wasm32")] + { + let sql = sql_text.read().clone(); + let opts = ExecuteSqlOpts { + allow_multi: allow_multi(), + confirm_dangerous: confirm_dangerous(), + with_explain: with_explain(), + }; + // 护栏 4:写操作前端二次确认(简单判断:含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词) + let lower = sql.to_lowercase(); + let looks_write = ["update ", "delete ", "insert ", "alter ", "drop ", "truncate ", "create "] + .iter() + .any(|k| lower.contains(k)); + if looks_write && !confirm_dangerous() { + // 简单提示;真正的高危放行靠 confirm_dangerous 开关 + let confirmed = web_sys::window().and_then(|w| { + w.confirm_with_message( + "这是写操作(修改数据/结构),确认执行?\n\n高危操作(DROP/TRUNCATE/ALTER)还需勾选「我了解后果」。", + ) + .ok() + }); + if confirmed != Some(true) { + running.set(false); + return; + } + } + spawn(async move { + match execute_sql(sql, opts).await { + Ok(r) => result.set(Some(r)), + Err(e) => error.set(Some(e.to_string())), + } + running.set(false); + }); + } + #[cfg(not(target_arch = "wasm32"))] + { + running.set(false); + } + }; + + let current_result = result.read().clone(); + let current_error = error.read().clone(); + let elapsed = current_result.as_ref().map(|r| r.elapsed_ms).unwrap_or(0); + let affected = current_result.as_ref().map(|r| r.affected_rows).unwrap_or(0); + let stmt_type = current_result + .as_ref() + .map(|r| r.statement_type.clone()) + .unwrap_or_default(); + let truncated = current_result.as_ref().map(|r| r.truncated).unwrap_or(false); + // 结果行预格式化(避免在 rsx for 循环体内格式化) + let result_rows: Vec> = current_result + .as_ref() + .map(|r| { + r.rows + .iter() + .map(|row| { + row.iter() + .map(|cell| match cell { + serde_json::Value::Null => "NULL".to_string(), + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }) + .collect() + }) + .collect() + }) + .unwrap_or_default(); + let result_columns: Vec = current_result + .as_ref() + .map(|r| r.columns.clone()) + .unwrap_or_default(); + let explain = current_result.as_ref().and_then(|r| r.explain.clone()); + + rsx! { + div { class: "space-y-4", + // 编辑器容器 + div { + class: "border border-paper-border rounded-lg overflow-hidden bg-paper-entry", + id: "sql-editor", + style: "min-height: 200px" + } + + // 选项 + 执行按钮 + div { class: "flex flex-wrap items-center gap-3", + button { + class: "px-4 py-1.5 text-sm bg-paper-accent text-paper-theme rounded hover:brightness-110 transition disabled:opacity-50", + disabled: running(), + onclick: move |_| run_sql(), + if running() { "执行中..." } else { "执行 (Ctrl+Enter)" } + } + label { class: "flex items-center gap-1 text-sm text-paper-secondary", + input { + r#type: "checkbox", + class: "mr-1", + checked: with_explain(), + onchange: move |e| with_explain.set(e.checked()), + } + "EXPLAIN" + } + label { class: "flex items-center gap-1 text-sm text-paper-secondary", + input { + r#type: "checkbox", + class: "mr-1", + checked: allow_multi(), + onchange: move |e| allow_multi.set(e.checked()), + } + "允许多语句" + } + label { class: "flex items-center gap-1 text-sm text-red-600 dark:text-red-400", + input { + r#type: "checkbox", + class: "mr-1", + checked: confirm_dangerous(), + onchange: move |e| confirm_dangerous.set(e.checked()), + } + "我了解后果(放开 DROP/TRUNCATE/ALTER)" + } + } + + // 错误 + if let Some(err) = current_error { + div { class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 text-sm text-red-700 dark:text-red-300", + "{err}" + } + } + + // 结果摘要 + if current_result.is_some() { + div { class: "flex flex-wrap gap-4 text-sm text-paper-secondary", + span { "类型:{stmt_type}" } + if affected > 0 { + span { "影响行数:{affected}" } + } + span { "耗时:{elapsed}ms" } + if truncated { + span { class: "text-amber-600 dark:text-amber-400", "结果超过 500 行,已截断" } + } + } + } + + // 结果表格 + if !result_rows.is_empty() { + div { class: "{ADMIN_TABLE_CLASS}", + div { class: "overflow-x-auto", + table { class: "w-full text-sm", + thead { + tr { class: "border-b border-paper-border text-left text-paper-secondary", + for col in result_columns.iter() { + th { class: "px-4 py-2 font-medium whitespace-nowrap", "{col}" } + } + } + } + tbody { + for row in result_rows.iter() { + tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors", + for cell in row.iter() { + td { class: "px-4 py-2 font-mono text-xs text-paper-secondary", "{cell}" } + } + } + } + } + } + } + } + } + + // EXPLAIN 输出 + if let Some(explain) = explain { + div { class: "{ADMIN_TABLE_CLASS}", + div { class: "px-4 py-2 border-b border-paper-border text-sm font-medium text-paper-primary", + "执行计划" + } + pre { class: "p-4 text-xs font-mono text-paper-secondary overflow-x-auto whitespace-pre", "{explain}" } + } + } + } + } +}