diff --git a/src-tauri/src/dap.rs b/src-tauri/src/dap.rs index e8543071..46ce5a10 100644 --- a/src-tauri/src/dap.rs +++ b/src-tauri/src/dap.rs @@ -202,6 +202,93 @@ pub fn dap_stop(state: State<'_, DapState>, session: String) -> Result<(), Strin Ok(()) } +/// 为调试构建编译型语言,返回可执行文件路径。 +/// Rust: cargo build → target/debug/;C/C++: 用 cc/c++ -g 编译到临时文件。 +#[tauri::command] +pub async fn dap_build( + language: String, + file_path: String, + root: String, +) -> Result { + tokio::task::spawn_blocking(move || match language.as_str() { + "rust" => build_rust(&root), + "c" => build_cc("cc", &file_path), + "cpp" => build_cc("c++", &file_path), + _ => Err(format!("{} 暂不支持编译调试", language)), + }) + .await + .map_err(|e| format!("构建任务失败: {}", e))? +} + +fn build_rust(root: &str) -> Result { + let exe = + find_in_path("cargo").ok_or_else(|| "未找到 cargo(请安装 Rust 工具链)".to_string())?; + let out = Command::new(&exe) + .args(["build"]) + .current_dir(root) + .env("PATH", augmented_path()) + .output() + .map_err(|e| format!("执行 cargo 失败: {}", e))?; + if !out.status.success() { + return Err(format!( + "cargo build 失败:\n{}", + String::from_utf8_lossy(&out.stderr) + )); + } + // 解析 Cargo.toml 的 package name + let toml = std::fs::read_to_string(std::path::Path::new(root).join("Cargo.toml")) + .map_err(|e| format!("读取 Cargo.toml 失败: {}", e))?; + let mut name = String::new(); + for line in toml.lines() { + let l = line.trim(); + if let Some(rest) = l.strip_prefix("name") { + if let Some(v) = rest.trim_start_matches(['=', ' ']).strip_prefix('"') { + if let Some(end) = v.find('"') { + name = v[..end].to_string(); + break; + } + } + } + } + if name.is_empty() { + return Err("无法从 Cargo.toml 解析 package name".to_string()); + } + let mut bin = std::path::Path::new(root).join("target/debug").join(&name); + if cfg!(windows) { + bin.set_extension("exe"); + } + if !bin.is_file() { + return Err(format!("未找到可执行文件: {}", bin.display())); + } + Ok(bin.to_string_lossy().to_string()) +} + +fn build_cc(compiler: &str, file_path: &str) -> Result { + let exe = + find_in_path(compiler).ok_or_else(|| format!("未找到编译器 {}(请安装)", compiler))?; + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let mut out_path = std::env::temp_dir().join(format!("codeforge-dbg-{}", stamp)); + if cfg!(windows) { + out_path.set_extension("exe"); + } + let out_str = out_path.to_string_lossy().to_string(); + let result = Command::new(&exe) + .args(["-g", "-O0", "-o", &out_str, file_path]) + .env("PATH", augmented_path()) + .output() + .map_err(|e| format!("执行 {} 失败: {}", compiler, e))?; + if !result.status.success() { + return Err(format!( + "编译失败:\n{}", + String::from_utf8_lossy(&result.stderr) + )); + } + Ok(out_str) +} + // 可安装的调试适配器:(id, 展示名, 安装命令) fn adapter_defs() -> Vec<(&'static str, &'static str, &'static str)> { vec![ @@ -215,6 +302,7 @@ fn adapter_defs() -> Vec<(&'static str, &'static str, &'static str)> { "Go (delve)", "go install github.com/go-delve/delve/cmd/dlv@latest", ), + ("lldb-dap", "Rust / C / C++ (lldb-dap)", "brew install llvm"), ] } @@ -223,6 +311,12 @@ fn adapter_installed(id: &str) -> bool { // debugpy 校验模块可导入 "debugpy" => dap_available("python".to_string()), "delve" => find_in_path("dlv").is_some(), + // lldb-dap 随 LLVM 提供,brew 的 llvm 是 keg-only 不在 PATH,额外探测常见路径 + "lldb-dap" => { + find_in_path("lldb-dap").is_some() + || std::path::Path::new("/opt/homebrew/opt/llvm/bin/lldb-dap").is_file() + || std::path::Path::new("/usr/local/opt/llvm/bin/lldb-dap").is_file() + } _ => false, } } diff --git a/src-tauri/src/db/duckdb.rs b/src-tauri/src/db/duckdb.rs index 27798a77..29f0a650 100644 --- a/src-tauri/src/db/duckdb.rs +++ b/src-tauri/src/db/duckdb.rs @@ -1,9 +1,84 @@ -use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult, split_sql}; +use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult, TxnConn, split_sql}; use duckdb::Connection; use serde_json::Value as JsonValue; pub(crate) struct DuckdbExecutor; +fn open_conn(source: &DataSource) -> Result { + let conn = match source.file.as_deref() { + Some(p) if !p.trim().is_empty() => Connection::open(p), + _ => Connection::open_in_memory(), + }; + conn.map_err(|e| format!("打开 DuckDB 失败: {}", e)) +} + +/// 在已有连接上执行脚本(run 与事务会话共用) +fn run_on_conn(conn: &Connection, sql: &str) -> SqlRunResult { + let mut result = SqlRunResult::new(); + 'stmts: for stmt_sql in split_sql(sql) { + let mut stmt = match conn.prepare(&stmt_sql) { + Ok(s) => s, + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + }; + let ncol = stmt.column_count(); + if ncol > 0 { + let columns: Vec = stmt.column_names().iter().map(|s| s.to_string()).collect(); + let rows_iter = stmt.query_map([], |row| { + let mut v = Vec::with_capacity(ncol); + for idx in 0..ncol { + v.push(value_to_json(row.get(idx)?)); + } + Ok(v) + }); + match rows_iter { + Ok(it) => { + let mut rows = Vec::new(); + for r in it { + match r { + Ok(rw) => rows.push(rw), + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } + result.result_sets.push(SqlResultSet { columns, rows }); + } + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } else { + match stmt.execute([]) { + Ok(affected) => result.messages.push(format!("OK,影响 {} 行", affected)), + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } + } + result +} + +struct DuckdbTxn { + conn: Connection, +} +impl TxnConn for DuckdbTxn { + fn exec(&mut self, sql: &str) -> SqlRunResult { + run_on_conn(&self.conn, sql) + } + fn finish(&mut self, commit: bool) -> Result<(), String> { + self.conn + .execute_batch(if commit { "COMMIT" } else { "ROLLBACK" }) + .map_err(|e| e.to_string()) + } +} + fn value_to_json(v: duckdb::types::Value) -> JsonValue { use duckdb::types::Value::*; match v { @@ -33,67 +108,20 @@ impl DbExecutor for DuckdbExecutor { } fn run(&self, sql: &str, source: &DataSource) -> SqlRunResult { - let mut result = SqlRunResult::new(); - let conn = match source.file.as_deref() { - Some(p) if !p.trim().is_empty() => Connection::open(p), - _ => Connection::open_in_memory(), - }; - let conn = match conn { - Ok(c) => c, + match open_conn(source) { + Ok(conn) => run_on_conn(&conn, sql), Err(e) => { - result.error = Some(format!("打开 DuckDB 失败: {}", e)); - return result; - } - }; - - 'stmts: for stmt_sql in split_sql(sql) { - let mut stmt = match conn.prepare(&stmt_sql) { - Ok(s) => s, - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - }; - let ncol = stmt.column_count(); - if ncol > 0 { - let columns: Vec = - stmt.column_names().iter().map(|s| s.to_string()).collect(); - let rows_iter = stmt.query_map([], |row| { - let mut v = Vec::with_capacity(ncol); - for idx in 0..ncol { - v.push(value_to_json(row.get(idx)?)); - } - Ok(v) - }); - match rows_iter { - Ok(it) => { - let mut rows = Vec::new(); - for r in it { - match r { - Ok(rw) => rows.push(rw), - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - } - } - result.result_sets.push(SqlResultSet { columns, rows }); - } - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - } - } else { - match stmt.execute([]) { - Ok(affected) => result.messages.push(format!("OK,影响 {} 行", affected)), - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - } + let mut result = SqlRunResult::new(); + result.error = Some(e); + result } } - result + } + + fn begin(&self, source: &DataSource) -> Result, String> { + let conn = open_conn(source)?; + conn.execute_batch("BEGIN TRANSACTION") + .map_err(|e| format!("开启事务失败: {}", e))?; + Ok(Box::new(DuckdbTxn { conn })) } } diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index adbb2735..d1108fe2 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -118,12 +118,103 @@ pub(crate) fn resolve_endpoint(source: &DataSource, default_port: u16) -> Result } } +/// 交互式事务会话:持有一条连接,跨多次调用在同一连接上执行。 +pub(crate) trait TxnConn: Send { + /// 在持有连接上执行 SQL(错误写入 result.error) + fn exec(&mut self, sql: &str) -> SqlRunResult; + /// 结束事务:commit=true 提交,否则回滚 + fn finish(&mut self, commit: bool) -> Result<(), String>; +} + /// 数据库执行器接口:新增数据库类型只需实现本 trait 并在 executors() 注册。 pub(crate) trait DbExecutor: Send + Sync { /// 是否处理该数据源类型(如 sqlite 同时处理 "sqlite" 与 "memory") fn handles(&self, kind: &str) -> bool; /// 执行脚本,返回结构化结果(错误写入 result.error,不以 Err 形式返回) fn run(&self, sql: &str, source: &DataSource) -> SqlRunResult; + /// 开启交互式事务,返回持有连接的会话;不支持事务的执行器返回 Err。 + fn begin(&self, _source: &DataSource) -> Result, String> { + Err("该数据源暂不支持交互式事务".to_string()) + } +} + +/// 全局事务状态:同一时刻仅允许一个进行中的交互式事务(一个编辑器一个事务)。 +pub struct TxnState(pub std::sync::Arc>>>); + +impl TxnState { + pub fn new() -> Self { + Self(std::sync::Arc::new(std::sync::Mutex::new(None))) + } +} + +impl Default for TxnState { + fn default() -> Self { + Self::new() + } +} + +/// 开启事务(已有进行中的事务会先回滚丢弃)。 +#[tauri::command] +pub async fn tx_begin(source: DataSource, state: tauri::State<'_, TxnState>) -> Result<(), String> { + let slot = state.0.clone(); + tokio::task::spawn_blocking(move || { + let execs = executors(); + let exec = execs + .iter() + .find(|e| e.handles(&source.kind)) + .ok_or_else(|| format!("不支持的数据源类型: {}", source.kind))?; + let conn = exec.begin(&source)?; + let mut guard = slot.lock().map_err(|e| e.to_string())?; + if let Some(mut old) = guard.take() { + let _ = old.finish(false); + } + *guard = Some(conn); + Ok(()) + }) + .await + .map_err(|e| format!("事务任务失败: {}", e))? +} + +/// 在进行中的事务上执行 SQL。 +#[tauri::command] +pub async fn tx_exec( + sql: String, + state: tauri::State<'_, TxnState>, +) -> Result { + let slot = state.0.clone(); + tokio::task::spawn_blocking(move || { + let mut guard = slot.lock().map_err(|e| e.to_string())?; + let conn = guard + .as_mut() + .ok_or_else(|| "没有进行中的事务".to_string())?; + Ok(conn.exec(&sql)) + }) + .await + .map_err(|e| format!("事务任务失败: {}", e))? +} + +/// 结束事务:commit=true 提交,否则回滚。 +#[tauri::command] +pub async fn tx_finish(commit: bool, state: tauri::State<'_, TxnState>) -> Result<(), String> { + let slot = state.0.clone(); + tokio::task::spawn_blocking(move || { + let mut guard = slot.lock().map_err(|e| e.to_string())?; + match guard.take() { + Some(mut conn) => conn.finish(commit), + None => Err("没有进行中的事务".to_string()), + } + }) + .await + .map_err(|e| format!("事务任务失败: {}", e))? +} + +/// 是否有进行中的事务。 +#[tauri::command] +pub async fn tx_active(state: tauri::State<'_, TxnState>) -> Result { + let slot = state.0.clone(); + tokio::task::spawn_blocking(move || Ok(slot.lock().map(|g| g.is_some()).unwrap_or(false))) + .await + .map_err(|e| format!("事务任务失败: {}", e))? } /// 已注册的执行器。新增数据库类型:在此加一行。 diff --git a/src-tauri/src/db/mysql.rs b/src-tauri/src/db/mysql.rs index 29d61e9d..af463544 100644 --- a/src-tauri/src/db/mysql.rs +++ b/src-tauri/src/db/mysql.rs @@ -1,8 +1,88 @@ -use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult, resolve_endpoint, split_sql}; +use super::{ + DataSource, DbExecutor, Endpoint, SqlResultSet, SqlRunResult, TxnConn, resolve_endpoint, + split_sql, +}; use serde_json::Value as JsonValue; pub(crate) struct MysqlExecutor; +// 建连并返回连接 + 端点(端点持有 SSH 隧道,须随连接一起存活) +fn connect(source: &DataSource) -> Result<(mysql::Conn, Endpoint), String> { + let endpoint = resolve_endpoint(source, 3306)?; + let mut builder = mysql::OptsBuilder::new() + .ip_or_hostname(Some(endpoint.host.clone())) + .tcp_port(endpoint.port) + .user(source.user.clone()) + .pass(source.password.clone()) + .db_name(source.database.clone()); + if source.ssl.unwrap_or(false) { + builder = builder.ssl_opts(Some( + mysql::SslOpts::default() + .with_danger_accept_invalid_certs(true) + .with_danger_skip_domain_validation(true), + )); + } + let conn = mysql::Conn::new(builder).map_err(|e| format!("连接 MySQL 失败: {}", e))?; + Ok((conn, endpoint)) +} + +/// 在已有连接上执行脚本(run 与事务会话共用) +fn run_on_conn(conn: &mut mysql::Conn, sql: &str) -> SqlRunResult { + use mysql::prelude::Queryable; + let mut result = SqlRunResult::new(); + 'stmts: for stmt_sql in split_sql(sql) { + let mut qr = match conn.query_iter(&stmt_sql) { + Ok(q) => q, + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + }; + let columns: Vec = qr + .columns() + .as_ref() + .iter() + .map(|c| c.name_str().to_string()) + .collect(); + if columns.is_empty() { + let affected = qr.affected_rows(); + result.messages.push(format!("OK,影响 {} 行", affected)); + } else { + let mut rows = Vec::new(); + for r in qr.by_ref() { + match r { + Ok(row) => { + let vals = row.unwrap(); + rows.push(vals.iter().map(value_to_json).collect()); + } + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } + result.result_sets.push(SqlResultSet { columns, rows }); + } + } + result +} + +struct MysqlTxn { + conn: mysql::Conn, + _endpoint: Endpoint, +} +impl TxnConn for MysqlTxn { + fn exec(&mut self, sql: &str) -> SqlRunResult { + run_on_conn(&mut self.conn, sql) + } + fn finish(&mut self, commit: bool) -> Result<(), String> { + use mysql::prelude::Queryable; + self.conn + .query_drop(if commit { "COMMIT" } else { "ROLLBACK" }) + .map_err(|e| e.to_string()) + } +} + fn value_to_json(v: &mysql::Value) -> JsonValue { use mysql::Value::*; match v { @@ -36,76 +116,25 @@ impl DbExecutor for MysqlExecutor { } fn run(&self, sql: &str, source: &DataSource) -> SqlRunResult { - use mysql::prelude::Queryable; - let mut result = SqlRunResult::new(); - - // 解析端点:启用 SSH 时隧道转发到本地端口(隧道随 endpoint 在本函数结束时关闭) - let endpoint = match resolve_endpoint(source, 3306) { - Ok(e) => e, + match connect(source) { + // endpoint 持有隧道,run 期间保持存活 + Ok((mut conn, _endpoint)) => run_on_conn(&mut conn, sql), Err(e) => { + let mut result = SqlRunResult::new(); result.error = Some(e); - return result; + result } - }; - - let mut builder = mysql::OptsBuilder::new() - .ip_or_hostname(Some(endpoint.host.clone())) - .tcp_port(endpoint.port) - .user(source.user.clone()) - .pass(source.password.clone()) - .db_name(source.database.clone()); - - // 启用 SSL:rustls 加密连接(开发场景放宽证书校验) - if source.ssl.unwrap_or(false) { - builder = builder.ssl_opts(Some( - mysql::SslOpts::default() - .with_danger_accept_invalid_certs(true) - .with_danger_skip_domain_validation(true), - )); } + } - let mut conn = match mysql::Conn::new(builder) { - Ok(c) => c, - Err(e) => { - result.error = Some(format!("连接 MySQL 失败: {}", e)); - return result; - } - }; - - 'stmts: for stmt_sql in split_sql(sql) { - let mut qr = match conn.query_iter(&stmt_sql) { - Ok(q) => q, - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - }; - let columns: Vec = qr - .columns() - .as_ref() - .iter() - .map(|c| c.name_str().to_string()) - .collect(); - if columns.is_empty() { - let affected = qr.affected_rows(); - result.messages.push(format!("OK,影响 {} 行", affected)); - } else { - let mut rows = Vec::new(); - for r in qr.by_ref() { - match r { - Ok(row) => { - let vals = row.unwrap(); - rows.push(vals.iter().map(value_to_json).collect()); - } - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - } - } - result.result_sets.push(SqlResultSet { columns, rows }); - } - } - result + fn begin(&self, source: &DataSource) -> Result, String> { + use mysql::prelude::Queryable; + let (mut conn, endpoint) = connect(source)?; + conn.query_drop("START TRANSACTION") + .map_err(|e| format!("开启事务失败: {}", e))?; + Ok(Box::new(MysqlTxn { + conn, + _endpoint: endpoint, + })) } } diff --git a/src-tauri/src/db/postgres.rs b/src-tauri/src/db/postgres.rs index 91d54974..56717b79 100644 --- a/src-tauri/src/db/postgres.rs +++ b/src-tauri/src/db/postgres.rs @@ -1,89 +1,61 @@ -use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult, resolve_endpoint}; +use super::{ + DataSource, DbExecutor, Endpoint, SqlResultSet, SqlRunResult, TxnConn, resolve_endpoint, +}; use postgres::config::SslMode; -use postgres::{Config, NoTls, SimpleQueryMessage}; +use postgres::{Client, Config, NoTls, SimpleQueryMessage}; use serde_json::Value as JsonValue; pub(crate) struct PostgresExecutor; -impl DbExecutor for PostgresExecutor { - fn handles(&self, kind: &str) -> bool { - kind == "postgres" || kind == "postgresql" +// 建连并返回客户端 + 端点(端点持有 SSH 隧道,须随客户端一起存活) +fn connect(source: &DataSource) -> Result<(Client, Endpoint), String> { + let endpoint = resolve_endpoint(source, 5432)?; + let use_ssl = source.ssl.unwrap_or(false); + let mut cfg = Config::new(); + cfg.host(&endpoint.host) + .port(endpoint.port) + .user(source.user.as_deref().unwrap_or("postgres")) + .ssl_mode(if use_ssl { + SslMode::Require + } else { + SslMode::Disable + }); + if let Some(pwd) = source.password.as_deref() { + cfg.password(pwd); } + if let Some(db) = source.database.as_deref() { + cfg.dbname(db); + } + let client = if use_ssl { + let connector = native_tls::TlsConnector::builder() + .danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true) + .build() + .map_err(|e| format!("初始化 TLS 失败: {}", e))?; + let tls = postgres_native_tls::MakeTlsConnector::new(connector); + cfg.connect(tls) + .map_err(|e| format!("连接 PostgreSQL 失败: {}", e))? + } else { + cfg.connect(NoTls) + .map_err(|e| format!("连接 PostgreSQL 失败: {}", e))? + }; + Ok((client, endpoint)) +} - fn run(&self, sql: &str, source: &DataSource) -> SqlRunResult { - let mut result = SqlRunResult::new(); - - // 解析端点:启用 SSH 时隧道转发到本地端口(隧道随 endpoint 在本函数结束时关闭) - let endpoint = match resolve_endpoint(source, 5432) { - Ok(e) => e, - Err(e) => { - result.error = Some(e); - return result; - } - }; - - let use_ssl = source.ssl.unwrap_or(false); - let mut cfg = Config::new(); - cfg.host(&endpoint.host) - .port(endpoint.port) - .user(source.user.as_deref().unwrap_or("postgres")) - .ssl_mode(if use_ssl { - SslMode::Require - } else { - SslMode::Disable - }); - if let Some(pwd) = source.password.as_deref() { - cfg.password(pwd); +/// 在已有客户端上执行脚本(run 与事务会话共用)。simple_query 一次执行整段,按消息流切分结果集。 +fn run_on_client(client: &mut Client, sql: &str) -> SqlRunResult { + let mut result = SqlRunResult::new(); + let messages = match client.simple_query(sql) { + Ok(m) => m, + Err(e) => { + result.error = Some(e.to_string()); + return result; } - if let Some(db) = source.database.as_deref() { - cfg.dbname(db); - } - - // 启用 SSL:native-tls 加密连接(开发场景放宽证书校验);否则 NoTls 明文 - let mut client = if use_ssl { - let connector = match native_tls::TlsConnector::builder() - .danger_accept_invalid_certs(true) - .danger_accept_invalid_hostnames(true) - .build() - { - Ok(c) => c, - Err(e) => { - result.error = Some(format!("初始化 TLS 失败: {}", e)); - return result; - } - }; - let tls = postgres_native_tls::MakeTlsConnector::new(connector); - match cfg.connect(tls) { - Ok(c) => c, - Err(e) => { - result.error = Some(format!("连接 PostgreSQL 失败: {}", e)); - return result; - } - } - } else { - match cfg.connect(NoTls) { - Ok(c) => c, - Err(e) => { - result.error = Some(format!("连接 PostgreSQL 失败: {}", e)); - return result; - } - } - }; - - // simple_query 一次执行整段脚本,按消息流切分结果集与影响行数 - let messages = match client.simple_query(sql) { - Ok(m) => m, - Err(e) => { - result.error = Some(e.to_string()); - return result; - } - }; - - let mut columns: Vec = Vec::new(); - let mut rows: Vec> = Vec::new(); - let flush = |columns: &mut Vec, - rows: &mut Vec>, - result: &mut SqlRunResult| { + }; + let mut columns: Vec = Vec::new(); + let mut rows: Vec> = Vec::new(); + let flush = + |columns: &mut Vec, rows: &mut Vec>, result: &mut SqlRunResult| { if !columns.is_empty() { result.result_sets.push(SqlResultSet { columns: std::mem::take(columns), @@ -91,32 +63,74 @@ impl DbExecutor for PostgresExecutor { }); } }; - - for msg in messages { - match msg { - SimpleQueryMessage::Row(row) => { - if columns.is_empty() { - columns = row.columns().iter().map(|c| c.name().to_string()).collect(); - } - let vals = (0..row.len()) - .map(|i| match row.get(i) { - Some(s) => JsonValue::from(s.to_string()), - None => JsonValue::Null, - }) - .collect(); - rows.push(vals); + for msg in messages { + match msg { + SimpleQueryMessage::Row(row) => { + if columns.is_empty() { + columns = row.columns().iter().map(|c| c.name().to_string()).collect(); } - SimpleQueryMessage::CommandComplete(n) => { - if columns.is_empty() { - result.messages.push(format!("OK,影响 {} 行", n)); - } else { - flush(&mut columns, &mut rows, &mut result); - } + let vals = (0..row.len()) + .map(|i| match row.get(i) { + Some(s) => JsonValue::from(s.to_string()), + None => JsonValue::Null, + }) + .collect(); + rows.push(vals); + } + SimpleQueryMessage::CommandComplete(n) => { + if columns.is_empty() { + result.messages.push(format!("OK,影响 {} 行", n)); + } else { + flush(&mut columns, &mut rows, &mut result); } - _ => {} + } + _ => {} + } + } + flush(&mut columns, &mut rows, &mut result); + result +} + +struct PostgresTxn { + client: Client, + _endpoint: Endpoint, +} +impl TxnConn for PostgresTxn { + fn exec(&mut self, sql: &str) -> SqlRunResult { + run_on_client(&mut self.client, sql) + } + fn finish(&mut self, commit: bool) -> Result<(), String> { + self.client + .simple_query(if commit { "COMMIT" } else { "ROLLBACK" }) + .map(|_| ()) + .map_err(|e| e.to_string()) + } +} + +impl DbExecutor for PostgresExecutor { + fn handles(&self, kind: &str) -> bool { + kind == "postgres" || kind == "postgresql" + } + + fn run(&self, sql: &str, source: &DataSource) -> SqlRunResult { + match connect(source) { + Ok((mut client, _endpoint)) => run_on_client(&mut client, sql), + Err(e) => { + let mut result = SqlRunResult::new(); + result.error = Some(e); + result } } - flush(&mut columns, &mut rows, &mut result); - result + } + + fn begin(&self, source: &DataSource) -> Result, String> { + let (mut client, endpoint) = connect(source)?; + client + .simple_query("BEGIN") + .map_err(|e| format!("开启事务失败: {}", e))?; + Ok(Box::new(PostgresTxn { + client, + _endpoint: endpoint, + })) } } diff --git a/src-tauri/src/db/sqlite.rs b/src-tauri/src/db/sqlite.rs index 9c563a5e..05ed25fc 100644 --- a/src-tauri/src/db/sqlite.rs +++ b/src-tauri/src/db/sqlite.rs @@ -1,9 +1,84 @@ -use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult, split_sql}; +use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult, TxnConn, split_sql}; use rusqlite::Connection; use serde_json::Value as JsonValue; pub(crate) struct SqliteExecutor; +fn open_conn(source: &DataSource) -> Result { + let conn = match source.file.as_deref() { + Some(p) if !p.trim().is_empty() => Connection::open(p), + _ => Connection::open_in_memory(), + }; + conn.map_err(|e| format!("打开数据库失败: {}", e)) +} + +/// 在已有连接上执行脚本(run 与事务会话共用) +fn run_on_conn(conn: &Connection, sql: &str) -> SqlRunResult { + let mut result = SqlRunResult::new(); + 'stmts: for stmt_sql in split_sql(sql) { + let mut stmt = match conn.prepare(&stmt_sql) { + Ok(s) => s, + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + }; + let ncol = stmt.column_count(); + if ncol > 0 { + let columns: Vec = stmt.column_names().iter().map(|s| s.to_string()).collect(); + let rows_iter = stmt.query_map([], |row| { + let mut v = Vec::with_capacity(ncol); + for idx in 0..ncol { + v.push(value_to_json(row.get(idx)?)); + } + Ok(v) + }); + match rows_iter { + Ok(it) => { + let mut rows = Vec::new(); + for r in it { + match r { + Ok(rw) => rows.push(rw), + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } + result.result_sets.push(SqlResultSet { columns, rows }); + } + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } else { + match stmt.execute([]) { + Ok(affected) => result.messages.push(format!("OK,影响 {} 行", affected)), + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } + } + result +} + +struct SqliteTxn { + conn: Connection, +} +impl TxnConn for SqliteTxn { + fn exec(&mut self, sql: &str) -> SqlRunResult { + run_on_conn(&self.conn, sql) + } + fn finish(&mut self, commit: bool) -> Result<(), String> { + self.conn + .execute_batch(if commit { "COMMIT" } else { "ROLLBACK" }) + .map_err(|e| e.to_string()) + } +} + fn value_to_json(v: rusqlite::types::Value) -> JsonValue { use rusqlite::types::Value::*; match v { @@ -21,67 +96,20 @@ impl DbExecutor for SqliteExecutor { } fn run(&self, sql: &str, source: &DataSource) -> SqlRunResult { - let mut result = SqlRunResult::new(); - let conn = match source.file.as_deref() { - Some(p) if !p.trim().is_empty() => Connection::open(p), - _ => Connection::open_in_memory(), - }; - let conn = match conn { - Ok(c) => c, + match open_conn(source) { + Ok(conn) => run_on_conn(&conn, sql), Err(e) => { - result.error = Some(format!("打开数据库失败: {}", e)); - return result; - } - }; - - 'stmts: for stmt_sql in split_sql(sql) { - let mut stmt = match conn.prepare(&stmt_sql) { - Ok(s) => s, - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - }; - let ncol = stmt.column_count(); - if ncol > 0 { - let columns: Vec = - stmt.column_names().iter().map(|s| s.to_string()).collect(); - let rows_iter = stmt.query_map([], |row| { - let mut v = Vec::with_capacity(ncol); - for idx in 0..ncol { - v.push(value_to_json(row.get(idx)?)); - } - Ok(v) - }); - match rows_iter { - Ok(it) => { - let mut rows = Vec::new(); - for r in it { - match r { - Ok(rw) => rows.push(rw), - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - } - } - result.result_sets.push(SqlResultSet { columns, rows }); - } - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - } - } else { - match stmt.execute([]) { - Ok(affected) => result.messages.push(format!("OK,影响 {} 行", affected)), - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - } + let mut result = SqlRunResult::new(); + result.error = Some(e); + result } } - result + } + + fn begin(&self, source: &DataSource) -> Result, String> { + let conn = open_conn(source)?; + conn.execute_batch("BEGIN") + .map_err(|e| format!("开启事务失败: {}", e))?; + Ok(Box::new(SqliteTxn { conn })) } } diff --git a/src-tauri/src/filesystem.rs b/src-tauri/src/filesystem.rs index 4016702d..452e9920 100644 --- a/src-tauri/src/filesystem.rs +++ b/src-tauri/src/filesystem.rs @@ -498,6 +498,232 @@ pub async fn git_init(root: String) -> Result { .map_err(|e| format!("git 任务失败: {}", e))? } +#[derive(Serialize, Default)] +pub struct EditorConfigResolved { + /// "tab" | "space" + indent_style: Option, + indent_size: Option, + tab_width: Option, + trim_trailing_whitespace: Option, + insert_final_newline: Option, +} + +/// 把 editorconfig glob 转为正则(覆盖 * ** ? {a,b} [..]) +fn editorconfig_glob_to_regex(glob: &str) -> String { + let chars: Vec = glob.chars().collect(); + let mut re = String::from("^"); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + match c { + '*' => { + if i + 1 < chars.len() && chars[i + 1] == '*' { + re.push_str(".*"); + i += 1; + } else { + re.push_str("[^/]*"); + } + } + '?' => re.push_str("[^/]"), + '{' => { + // {a,b,c} -> (a|b|c) + let mut j = i + 1; + let mut buf = String::new(); + let mut alts = String::from("("); + let mut closed = false; + while j < chars.len() { + match chars[j] { + '}' => { + alts.push_str(®ex::escape(&buf)); + alts.push(')'); + closed = true; + j += 1; + break; + } + ',' => { + alts.push_str(®ex::escape(&buf)); + alts.push('|'); + buf.clear(); + } + ch => buf.push(ch), + } + j += 1; + } + if closed { + re.push_str(&alts); + i = j; + continue; + } else { + re.push_str("\\{"); + } + } + '[' => { + re.push('['); + let mut j = i + 1; + if j < chars.len() && chars[j] == '!' { + re.push('^'); + j += 1; + } + while j < chars.len() && chars[j] != ']' { + re.push(chars[j]); + j += 1; + } + re.push(']'); + i = j; + continue; + } + '.' | '(' | ')' | '+' | '|' | '^' | '$' | '\\' => { + re.push('\\'); + re.push(c); + } + _ => re.push(c), + } + i += 1; + } + re.push('$'); + re +} + +fn editorconfig_is_root(content: &str) -> bool { + for line in content.lines() { + let l = line.trim(); + if l.starts_with('[') { + break; + } + if let Some((k, v)) = l.split_once('=') { + if k.trim().eq_ignore_ascii_case("root") && v.trim().eq_ignore_ascii_case("true") { + return true; + } + } + } + false +} + +fn editorconfig_apply( + content: &str, + rel_path: &str, + file_name: &str, + out: &mut EditorConfigResolved, +) { + let mut matched = false; + for line in content.lines() { + let l = line.trim(); + if l.is_empty() || l.starts_with('#') || l.starts_with(';') { + continue; + } + if let Some(glob) = l.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + // 含 '/' 按相对路径匹配,否则按文件名匹配 + let (pat, target) = if glob.contains('/') { + (glob.trim_start_matches('/'), rel_path) + } else { + (glob, file_name) + }; + matched = regex::Regex::new(&editorconfig_glob_to_regex(pat)) + .map(|re| re.is_match(target)) + .unwrap_or(false); + continue; + } + if !matched { + continue; + } + if let Some((k, v)) = l.split_once('=') { + let key = k.trim().to_lowercase(); + let val = v.trim().to_string(); + match key.as_str() { + "indent_style" => out.indent_style = Some(val.to_lowercase()), + "indent_size" => { + if let Ok(n) = val.parse::() { + out.indent_size = Some(n); + } + } + "tab_width" => { + if let Ok(n) = val.parse::() { + out.tab_width = Some(n); + } + } + "trim_trailing_whitespace" => { + out.trim_trailing_whitespace = Some(val.eq_ignore_ascii_case("true")) + } + "insert_final_newline" => { + out.insert_final_newline = Some(val.eq_ignore_ascii_case("true")) + } + _ => {} + } + } + } +} + +/// 解析某文件适用的 .editorconfig(向上查找,近的覆盖远的,遇 root=true 停)。 +#[tauri::command] +pub async fn resolve_editorconfig(file_path: String) -> Result { + tokio::task::spawn_blocking(move || { + let path = std::path::PathBuf::from(&file_path); + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + // 自近向远收集 .editorconfig,遇 root 停 + let mut configs: Vec = Vec::new(); + let mut dir = path.parent(); + while let Some(d) = dir { + let cfg = d.join(".editorconfig"); + if cfg.is_file() { + let is_root = std::fs::read_to_string(&cfg) + .map(|c| editorconfig_is_root(&c)) + .unwrap_or(false); + configs.push(cfg); + if is_root { + break; + } + } + dir = d.parent(); + } + // 自远向近应用(近的覆盖) + let mut out = EditorConfigResolved::default(); + for cfg in configs.iter().rev() { + if let (Ok(content), Some(cfg_dir)) = (std::fs::read_to_string(cfg), cfg.parent()) { + let rel = path + .strip_prefix(cfg_dir) + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_default(); + editorconfig_apply(&content, &rel, &file_name, &mut out); + } + } + Ok(out) + }) + .await + .map_err(|e| format!(".editorconfig 解析失败: {}", e))? +} + +/// 追加一段 .gitignore 模板块(按首行标题去重,已存在则跳过)。 +#[tauri::command] +pub async fn git_ignore_append_block(root: String, content: String) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + let block = content.trim_end_matches('\n'); + if block.trim().is_empty() { + return Ok(()); + } + let path = std::path::Path::new(&root).join(".gitignore"); + let mut existing = std::fs::read_to_string(&path).unwrap_or_default(); + // 用块首行(通常是 # 标题)判重,避免重复插入 + let header = block.lines().next().unwrap_or("").trim(); + if !header.is_empty() && existing.lines().any(|l| l.trim() == header) { + return Ok(()); + } + if !existing.is_empty() { + if !existing.ends_with('\n') { + existing.push('\n'); + } + existing.push('\n'); // 与上一块隔一空行 + } + existing.push_str(block); + existing.push('\n'); + std::fs::write(&path, existing).map_err(|e| format!("写入 .gitignore 失败: {}", e)) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + /// 把一个匹配模式追加到 .gitignore(已存在则跳过)。 #[tauri::command] pub async fn git_ignore_add(root: String, pattern: String) -> Result<(), String> { diff --git a/src-tauri/src/gitignore_templates.rs b/src-tauri/src/gitignore_templates.rs new file mode 100644 index 00000000..779b734f --- /dev/null +++ b/src-tauri/src/gitignore_templates.rs @@ -0,0 +1,99 @@ +use crate::execution::get_codeforge_db_path; +use rusqlite::{Connection, params}; +use serde::{Deserialize, Serialize}; +use std::sync::Mutex as StdMutex; +use tauri::State; + +/// 用户自定义 .gitignore 模板,存于同一个 codeforge.sqlite 库的独立表。 +pub struct GitignoreStore { + conn: StdMutex, +} + +#[derive(Serialize, Deserialize)] +pub struct GitignoreTemplate { + pub id: String, + pub label: String, + pub content: String, +} + +impl GitignoreStore { + pub fn new() -> Result { + let db_path = get_codeforge_db_path()?; + let conn = Connection::open(&db_path).map_err(|e| format!("打开数据库失败: {}", e))?; + let _ = conn.pragma_update(None, "journal_mode", "WAL"); + let _ = conn.pragma_update(None, "synchronous", "NORMAL"); + let _ = conn.busy_timeout(std::time::Duration::from_secs(5)); + conn.execute( + "CREATE TABLE IF NOT EXISTS gitignore_templates ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + content TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT 0 + )", + [], + ) + .map_err(|e| format!("初始化 .gitignore 模板表失败: {}", e))?; + + Ok(Self { + conn: StdMutex::new(conn), + }) + } +} + +/// 列出自定义 .gitignore 模板(按名称排序) +#[tauri::command] +pub async fn gitignore_templates_list( + state: State<'_, GitignoreStore>, +) -> Result, String> { + let conn = state.conn.lock().map_err(|_| "数据库锁错误".to_string())?; + let mut stmt = conn + .prepare("SELECT id, label, content FROM gitignore_templates ORDER BY label") + .map_err(|e| format!("查询模板失败: {}", e))?; + let rows = stmt + .query_map([], |row| { + Ok(GitignoreTemplate { + id: row.get(0)?, + label: row.get(1)?, + content: row.get(2)?, + }) + }) + .map_err(|e| format!("读取模板失败: {}", e))?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(|e| format!("读取模板失败: {}", e))?); + } + Ok(out) +} + +/// 新增/更新一条自定义模板(按 id upsert) +#[tauri::command] +pub async fn gitignore_template_save( + template: GitignoreTemplate, + state: State<'_, GitignoreStore>, +) -> Result<(), String> { + let conn = state.conn.lock().map_err(|_| "数据库锁错误".to_string())?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + conn.execute( + "INSERT INTO gitignore_templates (id, label, content, updated_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(id) DO UPDATE SET label=?2, content=?3, updated_at=?4", + params![template.id, template.label, template.content, now], + ) + .map_err(|e| format!("保存模板失败: {}", e))?; + Ok(()) +} + +/// 删除一条自定义模板 +#[tauri::command] +pub async fn gitignore_template_delete( + id: String, + state: State<'_, GitignoreStore>, +) -> Result<(), String> { + let conn = state.conn.lock().map_err(|_| "数据库锁错误".to_string())?; + conn.execute("DELETE FROM gitignore_templates WHERE id = ?1", params![id]) + .map_err(|e| format!("删除模板失败: {}", e))?; + Ok(()) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index affbb8e6..b5e3a37c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -19,6 +19,7 @@ mod execution; mod filesystem; mod font; mod geo; +mod gitignore_templates; mod kv; mod logger; mod lsp; @@ -41,9 +42,10 @@ use crate::custom_plugin_commands::{ update_custom_plugin, }; use crate::dap::{ - DapState, dap_adapter_list, dap_available, dap_install, dap_send, dap_start, dap_stop, + DapState, dap_adapter_list, dap_available, dap_build, dap_install, dap_send, dap_start, + dap_stop, }; -use crate::db::{run_sql, run_sql_paged}; +use crate::db::{TxnState, run_sql, run_sql_paged, tx_active, tx_begin, tx_exec, tx_finish}; use crate::db_connections::{ DbConnStore, db_connection_delete, db_connection_save, db_connections_list, }; @@ -67,16 +69,20 @@ use crate::filesystem::{ git_branches, git_checkout, git_checkout_track, git_cherry_pick, git_clean, git_clean_preview, git_clone, git_commit, git_compare, git_delete_remote_branch, git_diff, git_discard, git_fetch, git_file_diff, git_file_head, git_get_identity, git_get_signing, git_graph, git_hook_delete, - git_hook_read, git_hook_save, git_hooks, git_ignore_add, git_init, git_log, git_log_file, - git_merge, git_op_abort, git_op_continue, git_op_skip, git_op_state, git_pull, git_pull_rebase, - git_push, git_push_force, git_push_tags, git_rebase_interactive, git_reflog, git_remote_add, - git_remote_branches, git_remote_remove, git_remotes, git_reset, git_restore_file, git_revert, - git_set_identity, git_set_signing, git_set_upstream, git_show, git_stage, git_stash_apply, - git_stash_drop, git_stash_list, git_stash_pop, git_stash_push, git_stash_show, git_status, - git_submodule_sync, git_submodule_update, git_submodules, git_tag_create, git_tag_delete, - git_tags, git_unstage, git_worktree_add, git_worktree_prune, git_worktree_remove, - git_worktrees, list_files, read_directory_tree, read_file_lines, read_file_text, rename_path, - replace_in_files, reveal_path, search_in_files, watch_directory, write_file_text, + git_hook_read, git_hook_save, git_hooks, git_ignore_add, git_ignore_append_block, git_init, + git_log, git_log_file, git_merge, git_op_abort, git_op_continue, git_op_skip, git_op_state, + git_pull, git_pull_rebase, git_push, git_push_force, git_push_tags, git_rebase_interactive, + git_reflog, git_remote_add, git_remote_branches, git_remote_remove, git_remotes, git_reset, + git_restore_file, git_revert, git_set_identity, git_set_signing, git_set_upstream, git_show, + git_stage, git_stash_apply, git_stash_drop, git_stash_list, git_stash_pop, git_stash_push, + git_stash_show, git_status, git_submodule_sync, git_submodule_update, git_submodules, + git_tag_create, git_tag_delete, git_tags, git_unstage, git_worktree_add, git_worktree_prune, + git_worktree_remove, git_worktrees, list_files, read_directory_tree, read_file_lines, + read_file_text, rename_path, replace_in_files, resolve_editorconfig, reveal_path, + search_in_files, watch_directory, write_file_text, +}; +use crate::gitignore_templates::{ + GitignoreStore, gitignore_template_delete, gitignore_template_save, gitignore_templates_list, }; use crate::kv::{KvStore, kv_delete, kv_get_all, kv_set}; use crate::lsp::{ @@ -122,9 +128,11 @@ fn main() { .manage(Snippets::new().expect("failed to initialize snippets database")) .manage(KvStore::new().expect("failed to initialize kv store database")) .manage(DbConnStore::new().expect("failed to initialize db connections database")) + .manage(GitignoreStore::new().expect("failed to initialize gitignore templates database")) .manage(TerminalState::new()) .manage(LspState::new()) .manage(DapState::new()) + .manage(TxnState::new()) .manage(ExecutionPluginManagerState::new(PluginManager::new())) .manage(EnvironmentManagerState::new(env_manager)) .setup(|app| { @@ -279,6 +287,8 @@ fn main() { git_set_upstream, git_init, git_ignore_add, + git_ignore_append_block, + resolve_editorconfig, git_clone, git_clean_preview, git_clean, @@ -319,6 +329,10 @@ fn main() { db_connections_list, db_connection_save, db_connection_delete, + // 自定义 .gitignore 模板 + gitignore_templates_list, + gitignore_template_save, + gitignore_template_delete, // 集成终端 terminal_create, terminal_write, @@ -327,6 +341,11 @@ fn main() { // SQL 执行 run_sql, run_sql_paged, + // 交互式事务 + tx_begin, + tx_exec, + tx_finish, + tx_active, // LSP 桥接 lsp_available, lsp_start, @@ -340,7 +359,8 @@ fn main() { dap_send, dap_stop, dap_adapter_list, - dap_install + dap_install, + dap_build ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.vue b/src/App.vue index 93ef9f21..69ad0a2e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -79,7 +79,7 @@
-
+

{{ getLanguageDisplayName(currentLanguage) }} {{ t('app.codeEditor') }}

+ + +