diff --git a/README.md b/README.md index 1bff5f9..9c8c948 100644 --- a/README.md +++ b/README.md @@ -85,25 +85,39 @@ + + + + + + + + + + + + + + @@ -122,7 +136,7 @@
-`Python` · `Node.js` · `TypeScript` · `JavaScript` · `React` · `Go` · `Rust` · `Java` · `Kotlin` · `Scala` · `Groovy` · `Clojure` · `C` · `C++` · `Objective-C/C++` · `Swift` · `Ruby` · `PHP` · `R` · `Lua` · `Haskell` · `Cangjie` · `Shell` · `AppleScript` · `SQL` · `HTML` · `CSS` · `Less` · `SVG` · `JSON` · `XML` · `YAML` · `Markdown` · `CSV` · `TSV` · `Excel` · `Text` +`Python` · `Node.js` · `TypeScript` · `JavaScript` · `React` · `Vue` · `Go` · `Rust` · `D` · `Java` · `Kotlin` · `Scala` · `F#` · `Groovy` · `Clojure` · `Common Lisp` · `Scheme` · `C` · `C++` · `Objective-C/C++` · `Pascal` · `Swift` · `Dart` · `Ruby` · `Crystal` · `Perl` · `PHP` · `R` · `Julia` · `Lua` · `Haskell` · `OCaml` · `Erlang` · `Tcl` · `Cangjie` · `Shell` · `PowerShell` · `AppleScript` · `SQL` · `HTML` · `CSS` · `Less` · `SVG` · `JSON` · `XML` · `YAML` · `Markdown` · `CSV` · `TSV` · `Excel` · `Text`
diff --git a/public/icons/commonlisp.svg b/public/icons/commonlisp.svg new file mode 100644 index 0000000..74eca5d --- /dev/null +++ b/public/icons/commonlisp.svg @@ -0,0 +1,4 @@ + + + λ + diff --git a/public/icons/crystal.svg b/public/icons/crystal.svg new file mode 100644 index 0000000..8b48f18 --- /dev/null +++ b/public/icons/crystal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons/d.svg b/public/icons/d.svg new file mode 100644 index 0000000..4c8bec7 --- /dev/null +++ b/public/icons/d.svg @@ -0,0 +1,4 @@ + + + D + diff --git a/public/icons/dart.svg b/public/icons/dart.svg new file mode 100644 index 0000000..40ac534 --- /dev/null +++ b/public/icons/dart.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons/erlang.svg b/public/icons/erlang.svg new file mode 100644 index 0000000..159adea --- /dev/null +++ b/public/icons/erlang.svg @@ -0,0 +1,4 @@ + + + erlang + diff --git a/public/icons/fsharp.svg b/public/icons/fsharp.svg new file mode 100644 index 0000000..806c4db --- /dev/null +++ b/public/icons/fsharp.svg @@ -0,0 +1,4 @@ + + + F# + diff --git a/public/icons/julia.svg b/public/icons/julia.svg new file mode 100644 index 0000000..953bdcc --- /dev/null +++ b/public/icons/julia.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons/ocaml.svg b/public/icons/ocaml.svg new file mode 100644 index 0000000..86fe3c8 --- /dev/null +++ b/public/icons/ocaml.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/pascal.svg b/public/icons/pascal.svg new file mode 100644 index 0000000..a9c2489 --- /dev/null +++ b/public/icons/pascal.svg @@ -0,0 +1,4 @@ + + + P + diff --git a/public/icons/perl.svg b/public/icons/perl.svg new file mode 100644 index 0000000..aedf57e --- /dev/null +++ b/public/icons/perl.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/powershell.svg b/public/icons/powershell.svg new file mode 100644 index 0000000..cc8ad3a --- /dev/null +++ b/public/icons/powershell.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons/scheme.svg b/public/icons/scheme.svg new file mode 100644 index 0000000..c88fdf8 --- /dev/null +++ b/public/icons/scheme.svg @@ -0,0 +1,4 @@ + + + λ + diff --git a/public/icons/tcl.svg b/public/icons/tcl.svg new file mode 100644 index 0000000..28bdfd9 --- /dev/null +++ b/public/icons/tcl.svg @@ -0,0 +1,4 @@ + + + Tcl + diff --git a/public/icons/vue.svg b/public/icons/vue.svg new file mode 100644 index 0000000..b1893c2 --- /dev/null +++ b/public/icons/vue.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cf72807..5fda3df 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -16,9 +16,11 @@ dependencies = [ "futures-util", "log", "mysql", + "native-tls", "notify", "portable-pty", "postgres", + "postgres-native-tls", "regex", "reqwest 0.11.27", "rfd 0.15.4", @@ -3366,9 +3368,9 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", @@ -3834,15 +3836,14 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.73" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ "bitflags 2.12.1", "cfg-if", "foreign-types 0.3.2", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -3860,15 +3861,15 @@ dependencies = [ [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.109" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -4164,6 +4165,18 @@ dependencies = [ "tokio-postgres", ] +[[package]] +name = "postgres-native-tls" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef4de47bb81477e0c3deaf153a1b10ae176484713ff1640969f4cb96b653ebc" +dependencies = [ + "native-tls", + "tokio", + "tokio-native-tls", + "tokio-postgres", +] + [[package]] name = "postgres-protocol" version = "0.6.10" @@ -4898,11 +4911,11 @@ checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4976,12 +4989,12 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" [[package]] name = "security-framework" -version = "2.11.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.12.1", - "core-foundation 0.9.4", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4989,9 +5002,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ef05be5..6bd7928 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -47,6 +47,8 @@ tar = "0.4" xz2 = "0.1" zstd = "0.13" notify = "6" +native-tls = "0.2.18" +postgres-native-tls = "0.5" # DuckDB 走 bundled 编译,其 vendored C++(fmt) 在部分新版 MSVC 上编译失败, # 故仅在非 Windows 平台启用;Windows 不提供 DuckDB 数据源。 diff --git a/src-tauri/src/db/clickhouse.rs b/src-tauri/src/db/clickhouse.rs index 26543ad..b48cb3c 100644 --- a/src-tauri/src/db/clickhouse.rs +++ b/src-tauri/src/db/clickhouse.rs @@ -1,4 +1,4 @@ -use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult, split_sql}; +use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult, resolve_endpoint, split_sql}; use serde_json::Value as JsonValue; pub(crate) struct ClickhouseExecutor; @@ -11,15 +11,29 @@ impl DbExecutor for ClickhouseExecutor { fn run(&self, sql: &str, source: &DataSource) -> SqlRunResult { let mut result = SqlRunResult::new(); - let host = source.host.as_deref().unwrap_or("127.0.0.1"); - let port = source.port.unwrap_or(8123); + // 解析端点:启用 SSH 时隧道转发到本地端口(隧道随 endpoint 在本函数结束时关闭) + let endpoint = match resolve_endpoint(source, 8123) { + Ok(e) => e, + Err(e) => { + result.error = Some(e); + return result; + } + }; + let database = source.database.as_deref().unwrap_or("default"); let user = source.user.as_deref().unwrap_or("default"); - // 走 HTTP 接口,SELECT 以 JSONCompact 返回,DDL/写入返回空体 + // SSL 直连用 https;走 SSH 隧道时已由 ssh 加密,本地仍用 http + let scheme = if source.ssl.unwrap_or(false) && !source.ssh_enabled.unwrap_or(false) { + "https" + } else { + "http" + }; + // 走 HTTP(S) 接口,SELECT 以 JSONCompact 返回,DDL/写入返回空体 let url = format!( - "http://{}:{}/?default_format=JSONCompact&database={}", - host, - port, + "{}://{}:{}/?default_format=JSONCompact&database={}", + scheme, + endpoint.host, + endpoint.port, urlencode(database) ); diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index bd6eae0..adbb273 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -8,6 +8,7 @@ mod duckdb; mod mysql; mod postgres; mod sqlite; +mod tunnel; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; @@ -37,8 +38,9 @@ impl SqlRunResult { } } -/// 数据源描述:内存 / SQLite 文件 / MySQL(后续可扩展更多字段) +/// 数据源描述:内存 / SQLite 文件 / 网络型数据库(含可选 SSL 与 SSH 隧道) #[derive(Deserialize)] +#[serde(rename_all = "camelCase")] pub struct DataSource { pub kind: String, #[serde(default)] @@ -53,6 +55,67 @@ pub struct DataSource { pub password: Option, #[serde(default)] pub database: Option, + // 直连 DB 时启用 TLS(pg/mysql 走加密连接,clickhouse 改 https) + #[serde(default)] + pub ssl: Option, + // SSH 隧道:经跳板机本地端口转发到目标 DB + #[serde(default)] + pub ssh_enabled: Option, + #[serde(default)] + pub ssh_host: Option, + #[serde(default)] + pub ssh_port: Option, + #[serde(default)] + pub ssh_user: Option, + #[serde(default)] + pub ssh_password: Option, + #[serde(default)] + pub ssh_key_file: Option, +} + +/// 解析后的连接端点:直连时即原 host/port;启用 SSH 时为本地转发端口, +/// 并持有隧道句柄(随 Endpoint 释放而关闭 ssh 进程)。 +pub(crate) struct Endpoint { + pub host: String, + pub port: u16, + _tunnel: Option, +} + +/// 按数据源解析实际连接端点:启用 SSH 隧道则开隧道并返回本地端口。 +pub(crate) fn resolve_endpoint(source: &DataSource, default_port: u16) -> Result { + let host = source + .host + .clone() + .unwrap_or_else(|| "127.0.0.1".to_string()); + let port = source.port.unwrap_or(default_port); + + if source.ssh_enabled.unwrap_or(false) { + let ssh_host = source.ssh_host.clone().unwrap_or_default(); + let ssh_user = source.ssh_user.clone().unwrap_or_default(); + if ssh_host.is_empty() || ssh_user.is_empty() { + return Err("SSH 隧道需填写跳板机主机与用户名".to_string()); + } + let cfg = tunnel::SshConfig { + host: ssh_host, + port: source.ssh_port.unwrap_or(22), + user: ssh_user, + password: source.ssh_password.clone(), + key_file: source.ssh_key_file.clone(), + }; + let t = tunnel::SshTunnel::open(&cfg, &host, port)?; + let local_port = t.local_port; + Ok(Endpoint { + host: "127.0.0.1".to_string(), + port: local_port, + _tunnel: Some(t), + }) + } else { + Ok(Endpoint { + host, + port, + _tunnel: None, + }) + } } /// 数据库执行器接口:新增数据库类型只需实现本 trait 并在 executors() 注册。 diff --git a/src-tauri/src/db/mysql.rs b/src-tauri/src/db/mysql.rs index 21e7e10..29d61e9 100644 --- a/src-tauri/src/db/mysql.rs +++ b/src-tauri/src/db/mysql.rs @@ -1,4 +1,4 @@ -use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult, split_sql}; +use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult, resolve_endpoint, split_sql}; use serde_json::Value as JsonValue; pub(crate) struct MysqlExecutor; @@ -39,19 +39,32 @@ impl DbExecutor for MysqlExecutor { use mysql::prelude::Queryable; let mut result = SqlRunResult::new(); - let opts = mysql::OptsBuilder::new() - .ip_or_hostname( - source - .host - .clone() - .or_else(|| Some("127.0.0.1".to_string())), - ) - .tcp_port(source.port.unwrap_or(3306)) + // 解析端点:启用 SSH 时隧道转发到本地端口(隧道随 endpoint 在本函数结束时关闭) + let endpoint = match resolve_endpoint(source, 3306) { + Ok(e) => e, + Err(e) => { + result.error = Some(e); + return 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()); - let mut conn = match mysql::Conn::new(opts) { + // 启用 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)); diff --git a/src-tauri/src/db/postgres.rs b/src-tauri/src/db/postgres.rs index 343c546..91d5497 100644 --- a/src-tauri/src/db/postgres.rs +++ b/src-tauri/src/db/postgres.rs @@ -1,4 +1,5 @@ -use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult}; +use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult, resolve_endpoint}; +use postgres::config::SslMode; use postgres::{Config, NoTls, SimpleQueryMessage}; use serde_json::Value as JsonValue; @@ -12,10 +13,25 @@ impl DbExecutor for PostgresExecutor { 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(source.host.as_deref().unwrap_or("127.0.0.1")) - .port(source.port.unwrap_or(5432)) - .user(source.user.as_deref().unwrap_or("postgres")); + 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); } @@ -23,12 +39,34 @@ impl DbExecutor for PostgresExecutor { cfg.dbname(db); } - // SSL/SSH 隧道为独立议题(#93),此处暂用 NoTls - let mut client = match cfg.connect(NoTls) { - Ok(c) => c, - Err(e) => { - result.error = Some(format!("连接 PostgreSQL 失败: {}", e)); - return result; + // 启用 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; + } } }; diff --git a/src-tauri/src/db/tunnel.rs b/src-tauri/src/db/tunnel.rs new file mode 100644 index 0000000..ae63692 --- /dev/null +++ b/src-tauri/src/db/tunnel.rs @@ -0,0 +1,117 @@ +//! SSH 隧道:用系统 OpenSSH 的本地端口转发(ssh -L)把目标 DB 端口映射到本地, +//! 由 OpenSSH 负责转发与并发,避免自行实现 SSH 转发的复杂度与原生依赖。 +//! 私钥认证用 `ssh -i`;密码认证需本机 `sshpass`;都缺则依赖 ssh-agent/默认密钥。 + +use std::net::{TcpListener, TcpStream}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +pub(crate) struct SshConfig { + pub host: String, + pub port: u16, + pub user: String, + pub password: Option, + pub key_file: Option, +} + +pub(crate) struct SshTunnel { + child: Child, + pub local_port: u16, +} + +impl SshTunnel { + pub fn open(cfg: &SshConfig, target_host: &str, target_port: u16) -> Result { + let local_port = pick_free_port()?; + let forward = format!("127.0.0.1:{}:{}:{}", local_port, target_host, target_port); + let dest = format!("{}@{}", cfg.user, cfg.host); + + let has_key = cfg.key_file.as_deref().is_some_and(|k| !k.is_empty()); + let has_pwd = cfg.password.as_deref().is_some_and(|p| !p.is_empty()); + + let mut cmd = if !has_key && has_pwd { + // 密码认证:经 sshpass 注入密码 + let mut c = Command::new("sshpass"); + c.arg("-p").arg(cfg.password.clone().unwrap_or_default()); + c.arg("ssh"); + c + } else { + Command::new("ssh") + }; + + // 非交互:私钥/agent 失败时不要挂起等待输入 + if has_key { + cmd.arg("-i").arg(cfg.key_file.clone().unwrap_or_default()); + cmd.arg("-o").arg("BatchMode=yes"); + } else if !has_pwd { + cmd.arg("-o").arg("BatchMode=yes"); + } + + cmd.arg("-N") + .arg("-T") + .arg("-o") + .arg("ExitOnForwardFailure=yes") + .arg("-o") + .arg("StrictHostKeyChecking=accept-new") + .arg("-o") + .arg("ConnectTimeout=10") + .arg("-p") + .arg(cfg.port.to_string()) + .arg("-L") + .arg(&forward) + .arg(&dest) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let child = cmd.spawn().map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + if !has_key && has_pwd { + "SSH 密码认证需要本机安装 sshpass(如 brew install sshpass),或改用私钥认证" + .to_string() + } else { + "未找到 ssh 命令,请确认本机已安装 OpenSSH 客户端".to_string() + } + } else { + format!("启动 SSH 隧道失败: {}", e) + } + })?; + + let tunnel = SshTunnel { child, local_port }; + tunnel.wait_ready(Duration::from_secs(12))?; + Ok(tunnel) + } + + /// 轮询本地转发端口直至可连接,作为隧道就绪信号。 + fn wait_ready(&self, timeout: Duration) -> Result<(), String> { + let deadline = Instant::now() + timeout; + loop { + if TcpStream::connect(("127.0.0.1", self.local_port)).is_ok() { + return Ok(()); + } + if Instant::now() >= deadline { + return Err( + "SSH 隧道建立超时(请检查跳板机地址、认证方式与目标端口是否可达)".to_string(), + ); + } + std::thread::sleep(Duration::from_millis(150)); + } + } +} + +impl Drop for SshTunnel { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// 取一个空闲本地端口:绑定 :0 拿到端口号后立即释放,交给 ssh 去监听。 +fn pick_free_port() -> Result { + let listener = + TcpListener::bind("127.0.0.1:0").map_err(|e| format!("分配本地端口失败: {}", e))?; + let port = listener + .local_addr() + .map_err(|e| format!("读取本地端口失败: {}", e))? + .port(); + Ok(port) +} diff --git a/src-tauri/src/db_connections.rs b/src-tauri/src/db_connections.rs index c70c63d..acf8cb5 100644 --- a/src-tauri/src/db_connections.rs +++ b/src-tauri/src/db_connections.rs @@ -8,6 +8,7 @@ use std::sync::Mutex as StdMutex; use tauri::State; #[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] pub struct DbConnection { pub id: String, pub name: String, @@ -24,6 +25,22 @@ pub struct DbConnection { pub password: Option, #[serde(default)] pub database: Option, + // 加密连接:直连 TLS + #[serde(default)] + pub ssl: Option, + // SSH 隧道 + #[serde(default)] + pub ssh_enabled: Option, + #[serde(default)] + pub ssh_host: Option, + #[serde(default)] + pub ssh_port: Option, + #[serde(default)] + pub ssh_user: Option, + #[serde(default)] + pub ssh_password: Option, + #[serde(default)] + pub ssh_key_file: Option, } pub struct DbConnStore { @@ -48,17 +65,65 @@ impl DbConnStore { user TEXT, password TEXT, database TEXT, + ssl INTEGER, + ssh_enabled INTEGER, + ssh_host TEXT, + ssh_port INTEGER, + ssh_user TEXT, + ssh_password TEXT, + ssh_key_file TEXT, sort_order INTEGER NOT NULL DEFAULT 0 )", [], ) .map_err(|e| format!("初始化连接表失败: {}", e))?; + + // 旧表懒迁移:逐列尝试新增,已存在则忽略错误 + for col in [ + "ssl INTEGER", + "ssh_enabled INTEGER", + "ssh_host TEXT", + "ssh_port INTEGER", + "ssh_user TEXT", + "ssh_password TEXT", + "ssh_key_file TEXT", + ] { + let _ = conn.execute( + &format!("ALTER TABLE db_connections ADD COLUMN {}", col), + [], + ); + } + Ok(Self { conn: StdMutex::new(conn), }) } } +const COLUMNS: &str = "id, name, kind, file, host, port, user, password, database, \ + ssl, ssh_enabled, ssh_host, ssh_port, ssh_user, ssh_password, ssh_key_file"; + +fn row_to_conn(row: &rusqlite::Row) -> rusqlite::Result { + Ok(DbConnection { + id: row.get(0)?, + name: row.get(1)?, + kind: row.get(2)?, + file: row.get(3)?, + host: row.get(4)?, + port: row.get::<_, Option>(5)?.map(|v| v as u16), + user: row.get(6)?, + password: row.get(7)?, + database: row.get(8)?, + ssl: row.get::<_, Option>(9)?, + ssh_enabled: row.get::<_, Option>(10)?, + ssh_host: row.get(11)?, + ssh_port: row.get::<_, Option>(12)?.map(|v| v as u16), + ssh_user: row.get(13)?, + ssh_password: row.get(14)?, + ssh_key_file: row.get(15)?, + }) +} + /// 列出全部连接(按 sort_order, name) #[tauri::command] pub async fn db_connections_list( @@ -66,25 +131,13 @@ pub async fn db_connections_list( ) -> Result, String> { let conn = state.conn.lock().map_err(|_| "数据库锁错误".to_string())?; let mut stmt = conn - .prepare( - "SELECT id, name, kind, file, host, port, user, password, database - FROM db_connections ORDER BY sort_order, name", - ) + .prepare(&format!( + "SELECT {} FROM db_connections ORDER BY sort_order, name", + COLUMNS + )) .map_err(|e| format!("查询连接失败: {}", e))?; let rows = stmt - .query_map([], |row| { - Ok(DbConnection { - id: row.get(0)?, - name: row.get(1)?, - kind: row.get(2)?, - file: row.get(3)?, - host: row.get(4)?, - port: row.get::<_, Option>(5)?.map(|v| v as u16), - user: row.get(6)?, - password: row.get(7)?, - database: row.get(8)?, - }) - }) + .query_map([], row_to_conn) .map_err(|e| format!("读取连接失败: {}", e))?; let mut out = Vec::new(); for r in rows { @@ -111,8 +164,27 @@ pub async fn db_connection_save( .is_some(); if exists { conn.execute( - "UPDATE db_connections SET name=?2, kind=?3, file=?4, host=?5, port=?6, user=?7, password=?8, database=?9 WHERE id=?1", - params![c.id, c.name, c.kind, c.file, c.host, c.port, c.user, c.password, c.database], + "UPDATE db_connections SET name=?2, kind=?3, file=?4, host=?5, port=?6, user=?7, \ + password=?8, database=?9, ssl=?10, ssh_enabled=?11, ssh_host=?12, ssh_port=?13, \ + ssh_user=?14, ssh_password=?15, ssh_key_file=?16 WHERE id=?1", + params![ + c.id, + c.name, + c.kind, + c.file, + c.host, + c.port, + c.user, + c.password, + c.database, + c.ssl, + c.ssh_enabled, + c.ssh_host, + c.ssh_port, + c.ssh_user, + c.ssh_password, + c.ssh_key_file + ], ) .map_err(|e| format!("更新连接失败: {}", e))?; } else { @@ -124,9 +196,29 @@ pub async fn db_connection_save( ) .unwrap_or(1); conn.execute( - "INSERT INTO db_connections (id, name, kind, file, host, port, user, password, database, sort_order) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - params![c.id, c.name, c.kind, c.file, c.host, c.port, c.user, c.password, c.database, next], + "INSERT INTO db_connections (id, name, kind, file, host, port, user, password, \ + database, ssl, ssh_enabled, ssh_host, ssh_port, ssh_user, ssh_password, \ + ssh_key_file, sort_order) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", + params![ + c.id, + c.name, + c.kind, + c.file, + c.host, + c.port, + c.user, + c.password, + c.database, + c.ssl, + c.ssh_enabled, + c.ssh_host, + c.ssh_port, + c.ssh_user, + c.ssh_password, + c.ssh_key_file, + next + ], ) .map_err(|e| format!("保存连接失败: {}", e))?; } diff --git a/src-tauri/src/filesystem.rs b/src-tauri/src/filesystem.rs index a21adcc..a99fa32 100644 --- a/src-tauri/src/filesystem.rs +++ b/src-tauri/src/filesystem.rs @@ -1,5 +1,5 @@ use notify::{RecommendedWatcher, RecursiveMode, Watcher}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; @@ -471,6 +471,57 @@ pub async fn git_diff(root: String) -> Result { // ===== Git 源代码管理 ===== +/// 克隆远程仓库到 dir 下,返回克隆出的仓库目录路径。 +#[tauri::command] +pub async fn git_clone(url: String, dir: String) -> Result { + tokio::task::spawn_blocking(move || { + run_git(&dir, &["clone", &url])?; + // 由 URL 推断仓库目录名(去掉结尾 / 与 .git) + let name = url + .trim() + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or("repo") + .trim_end_matches(".git"); + Ok(format!("{}/{}", dir.trim_end_matches('/'), name)) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 在目录初始化 Git 仓库。 +#[tauri::command] +pub async fn git_init(root: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["init"])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 把一个匹配模式追加到 .gitignore(已存在则跳过)。 +#[tauri::command] +pub async fn git_ignore_add(root: String, pattern: String) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + let p = pattern.trim(); + if p.is_empty() { + return Ok(()); + } + let path = std::path::Path::new(&root).join(".gitignore"); + let mut content = std::fs::read_to_string(&path).unwrap_or_default(); + if content.lines().any(|l| l.trim() == p) { + return Ok(()); + } + if !content.is_empty() && !content.ends_with('\n') { + content.push('\n'); + } + content.push_str(p); + content.push('\n'); + std::fs::write(&path, content).map_err(|e| format!("写入 .gitignore 失败: {}", e)) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + /// 同步执行 git 子命令,返回标准输出;失败时返回 stderr。 fn run_git(root: &str, args: &[&str]) -> Result { let mut full: Vec<&str> = vec!["-C", root]; @@ -486,6 +537,72 @@ fn run_git(root: &str, args: &[&str]) -> Result { Ok(String::from_utf8_lossy(&output.stdout).to_string()) } +/// 与 run_git 类似,但通过标准输入传入数据(用于 git apply 接收补丁)。 +fn run_git_stdin(root: &str, args: &[&str], input: &str) -> Result { + use std::io::Write; + use std::process::Stdio; + let mut full: Vec<&str> = vec!["-C", root]; + full.extend_from_slice(args); + let mut child = std::process::Command::new("git") + .args(&full) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("执行 git 失败: {}", e))?; + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(input.as_bytes()) + .map_err(|e| format!("写入 git 输入失败: {}", e))?; + } + let output = child + .wait_with_output() + .map_err(|e| format!("等待 git 失败: {}", e))?; + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr); + return Err(err.trim().to_string()); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +/// 获取单个文件的 unified diff。staged=true 返回已暂存(index vs HEAD),否则工作区(worktree vs index)。 +#[tauri::command] +pub async fn git_file_diff(root: String, rel_path: String, staged: bool) -> Result { + tokio::task::spawn_blocking(move || { + let mut args: Vec<&str> = vec!["diff"]; + if staged { + args.push("--cached"); + } + args.push("--"); + args.push(rel_path.as_str()); + run_git(&root, &args) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 应用单个 hunk 补丁。cached=true 作用于暂存区,reverse=true 反向应用(用于取消暂存/丢弃)。 +#[tauri::command] +pub async fn git_apply_patch( + root: String, + patch: String, + cached: bool, + reverse: bool, +) -> Result { + tokio::task::spawn_blocking(move || { + let mut args: Vec<&str> = vec!["apply"]; + if cached { + args.push("--cached"); + } + if reverse { + args.push("--reverse"); + } + run_git_stdin(&root, &args, &patch) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + #[derive(Serialize)] pub struct GitFileStatus { /// 相对仓库根的路径 @@ -588,6 +705,30 @@ pub async fn git_stage(root: String, paths: Vec) -> Result<(), String> { .map_err(|e| format!("git 任务失败: {}", e))? } +/// 把某文件恢复到指定提交时的版本(checkout -- ,写入工作区)。 +#[tauri::command] +pub async fn git_restore_file(root: String, rel_path: String, hash: String) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + run_git(&root, &["checkout", &hash, "--", &rel_path]).map(|_| ()) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 丢弃已跟踪文件的改动:暂存区与工作区一并恢复到 HEAD(不可恢复)。 +/// 未跟踪文件不在此处理(由前端删除)。 +#[tauri::command] +pub async fn git_discard(root: String, paths: Vec) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + let mut args = vec!["restore", "--source=HEAD", "--staged", "--worktree", "--"]; + let refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect(); + args.extend_from_slice(&refs); + run_git(&root, &args).map(|_| ()) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + /// 取消暂存指定文件。 #[tauri::command] pub async fn git_unstage(root: String, paths: Vec) -> Result<(), String> { @@ -602,11 +743,36 @@ pub async fn git_unstage(root: String, paths: Vec) -> Result<(), String> } /// 提交已暂存的改动。 +/// amend=true 修正上次提交(message 为空则保留原信息);all=true 自动暂存已跟踪改动(-a);signoff=true 追加 Signed-off-by(-s)。 #[tauri::command] -pub async fn git_commit(root: String, message: String) -> Result { - tokio::task::spawn_blocking(move || run_git(&root, &["commit", "-m", &message])) - .await - .map_err(|e| format!("git 任务失败: {}", e))? +pub async fn git_commit( + root: String, + message: String, + amend: bool, + all: bool, + signoff: bool, +) -> Result { + tokio::task::spawn_blocking(move || { + let mut args: Vec<&str> = vec!["commit"]; + if amend { + args.push("--amend"); + } + if all { + args.push("-a"); + } + if signoff { + args.push("-s"); + } + if amend && message.trim().is_empty() { + args.push("--no-edit"); + } else { + args.push("-m"); + args.push(&message); + } + run_git(&root, &args) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? } /// 推送当前分支。 @@ -617,6 +783,155 @@ pub async fn git_push(root: String) -> Result { .map_err(|e| format!("git 任务失败: {}", e))? } +/// 预览将被 git clean 删除的未跟踪文件/目录(dry-run)。 +#[tauri::command] +pub async fn git_clean_preview(root: String) -> Result, String> { + tokio::task::spawn_blocking(move || { + let out = run_git(&root, &["clean", "-nd"])?; + Ok(out + .lines() + .filter_map(|l| l.strip_prefix("Would remove ").map(|s| s.to_string())) + .collect()) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 清理未跟踪文件与目录(git clean -fd,不可恢复)。 +#[tauri::command] +pub async fn git_clean(root: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["clean", "-fd"])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 拉取并合并远程当前分支。 +#[tauri::command] +pub async fn git_pull(root: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["pull"])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 带变基拉取(pull --rebase)。 +#[tauri::command] +pub async fn git_pull_rebase(root: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["pull", "--rebase"])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 安全强制推送(--force-with-lease)。 +#[tauri::command] +pub async fn git_push_force(root: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["push", "--force-with-lease"])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 推送所有标签。 +#[tauri::command] +pub async fn git_push_tags(root: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["push", "--tags"])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 删除远程分支。 +#[tauri::command] +pub async fn git_delete_remote_branch( + root: String, + remote: String, + branch: String, +) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["push", &remote, "--delete", &branch])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 抓取远程更新(不合并)。 +#[tauri::command] +pub async fn git_fetch(root: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["fetch", "--all", "--prune"])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +#[derive(Serialize)] +pub struct GitStashEntry { + /// 形如 stash@{0} + reference: String, + message: String, +} + +/// 列出 stash 列表。 +#[tauri::command] +pub async fn git_stash_list(root: String) -> Result, String> { + tokio::task::spawn_blocking(move || { + let out = run_git(&root, &["stash", "list", "--pretty=format:%gd\x1f%s"])?; + let mut list = Vec::new(); + for line in out.lines() { + let p: Vec<&str> = line.split('\u{1f}').collect(); + if p.len() >= 2 { + list.push(GitStashEntry { + reference: p[0].to_string(), + message: p[1].to_string(), + }); + } + } + Ok(list) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 暂存当前改动(含未跟踪文件)。message 为空则用默认信息。 +#[tauri::command] +pub async fn git_stash_push(root: String, message: String) -> Result { + tokio::task::spawn_blocking(move || { + let mut args = vec!["stash", "push", "--include-untracked"]; + if !message.trim().is_empty() { + args.push("-m"); + args.push(message.as_str()); + } + run_git(&root, &args) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 应用某个 stash 但保留(apply)。 +#[tauri::command] +pub async fn git_stash_apply(root: String, reference: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["stash", "apply", &reference])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 查看某个 stash 的补丁内容。 +#[tauri::command] +pub async fn git_stash_show(root: String, reference: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["stash", "show", "-p", &reference])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 应用并移除某个 stash(pop)。 +#[tauri::command] +pub async fn git_stash_pop(root: String, reference: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["stash", "pop", &reference])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 丢弃某个 stash(drop)。 +#[tauri::command] +pub async fn git_stash_drop(root: String, reference: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["stash", "drop", &reference])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + #[derive(Serialize)] pub struct GitBranches { current: String, @@ -650,6 +965,932 @@ pub async fn git_checkout(root: String, branch: String) -> Result Result, String> { + tokio::task::spawn_blocking(move || { + let out = run_git(&root, &["branch", "-r", "--format=%(refname:short)"])?; + Ok(out + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty() && !l.contains("->")) + .collect()) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 检出远程分支并建立本地跟踪分支(checkout -t origin/x)。 +#[tauri::command] +pub async fn git_checkout_track(root: String, remote_branch: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["checkout", "-t", &remote_branch])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 新建并切换到分支。 +#[tauri::command] +pub async fn git_branch_create(root: String, name: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["checkout", "-b", &name])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 删除分支(安全删除,未合并会失败)。 +#[tauri::command] +pub async fn git_branch_delete(root: String, name: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["branch", "-d", &name])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 重命名分支。 +#[tauri::command] +pub async fn git_branch_rename(root: String, old: String, new: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["branch", "-m", &old, &new])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 把指定分支合并到当前分支。 +#[tauri::command] +pub async fn git_merge(root: String, branch: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["merge", "--no-edit", &branch])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +#[derive(Serialize)] +pub struct GitCommit { + hash: String, + short: String, + author: String, + date: String, + subject: String, +} + +/// 提交历史(分页):limit 条,跳过 skip 条。revision 指定时查看该分支/引用的历史。 +#[tauri::command] +pub async fn git_log( + root: String, + limit: u32, + skip: u32, + revision: Option, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + let n = format!("-n{}", limit); + let sk = format!("--skip={}", skip); + // 字段以 \x1f 分隔、每提交一行;%s 为单行主题 + let mut args = vec![ + "log", + n.as_str(), + sk.as_str(), + "--date=format:%Y-%m-%d %H:%M", + "--pretty=format:%H\x1f%h\x1f%an\x1f%ad\x1f%s", + ]; + let rev = revision.unwrap_or_default(); + if !rev.trim().is_empty() { + args.push(rev.as_str()); + } + let out = run_git(&root, &args)?; + let mut commits = Vec::new(); + for line in out.lines() { + let p: Vec<&str> = line.split('\u{1f}').collect(); + if p.len() >= 5 { + commits.push(GitCommit { + hash: p[0].to_string(), + short: p[1].to_string(), + author: p[2].to_string(), + date: p[3].to_string(), + subject: p[4].to_string(), + }); + } + } + Ok(commits) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +#[derive(Serialize)] +pub struct GitWorktree { + path: String, + head: String, + /// 分支短名;分离 HEAD 或裸仓库时为空 + branch: String, + bare: bool, + detached: bool, + locked: bool, +} + +/// 列出 worktree(解析 worktree list --porcelain)。 +#[tauri::command] +pub async fn git_worktrees(root: String) -> Result, String> { + tokio::task::spawn_blocking(move || { + let out = run_git(&root, &["worktree", "list", "--porcelain"])?; + let mut list: Vec = Vec::new(); + let mut cur: Option = None; + for line in out.lines() { + if let Some(p) = line.strip_prefix("worktree ") { + if let Some(w) = cur.take() { + list.push(w); + } + cur = Some(GitWorktree { + path: p.to_string(), + head: String::new(), + branch: String::new(), + bare: false, + detached: false, + locked: false, + }); + } else if let Some(w) = cur.as_mut() { + if let Some(h) = line.strip_prefix("HEAD ") { + w.head = h.chars().take(8).collect(); + } else if let Some(b) = line.strip_prefix("branch ") { + w.branch = b.strip_prefix("refs/heads/").unwrap_or(b).to_string(); + } else if line == "bare" { + w.bare = true; + } else if line == "detached" { + w.detached = true; + } else if line.starts_with("locked") { + w.locked = true; + } + } + } + if let Some(w) = cur.take() { + list.push(w); + } + Ok(list) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 新增 worktree。ref 为空则由 git 按路径名自动建分支。 +#[tauri::command] +pub async fn git_worktree_add( + root: String, + path: String, + reference: String, +) -> Result { + tokio::task::spawn_blocking(move || { + let mut args = vec!["worktree", "add", path.as_str()]; + if !reference.trim().is_empty() { + args.push(reference.as_str()); + } + run_git(&root, &args) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 移除 worktree。 +#[tauri::command] +pub async fn git_worktree_remove(root: String, path: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["worktree", "remove", &path])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 清理失效的 worktree 记录。 +#[tauri::command] +pub async fn git_worktree_prune(root: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["worktree", "prune"])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +#[derive(Serialize)] +pub struct GitSubmodule { + path: String, + hash: String, + /// ok / uninitialized / modified / conflict + state: String, + describe: String, +} + +/// 列出子模块状态(解析 git submodule status)。 +#[tauri::command] +pub async fn git_submodules(root: String) -> Result, String> { + tokio::task::spawn_blocking(move || { + let out = run_git(&root, &["submodule", "status"])?; + let mut list: Vec = Vec::new(); + for line in out.lines() { + if line.trim().is_empty() { + continue; + } + // 形如 " ()",首字符表示状态 + let prefix = line.chars().next().unwrap_or(' '); + let state = match prefix { + '-' => "uninitialized", + '+' => "modified", + 'U' => "conflict", + _ => "ok", + } + .to_string(); + let rest = line[1..].trim(); + let mut it = rest.splitn(2, ' '); + let hash = it.next().unwrap_or("").to_string(); + let tail = it.next().unwrap_or(""); + let (path, describe) = match tail.find(" (") { + Some(idx) => ( + tail[..idx].trim().to_string(), + tail[idx + 2..].trim_end_matches(')').to_string(), + ), + None => (tail.trim().to_string(), String::new()), + }; + if !path.is_empty() { + list.push(GitSubmodule { + path, + hash, + state, + describe, + }); + } + } + Ok(list) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 更新子模块(init + recursive)。path 为空则更新全部。 +#[tauri::command] +pub async fn git_submodule_update(root: String, path: String) -> Result { + tokio::task::spawn_blocking(move || { + let mut args = vec!["submodule", "update", "--init", "--recursive"]; + if !path.trim().is_empty() { + args.push("--"); + args.push(path.as_str()); + } + run_git(&root, &args) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 同步子模块 URL 配置(submodule sync --recursive)。 +#[tauri::command] +pub async fn git_submodule_sync(root: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["submodule", "sync", "--recursive"])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +#[derive(Serialize)] +pub struct GitGraphCommit { + hash: String, + short: String, + parents: Vec, + refs: String, + author: String, + date: String, + subject: String, +} + +/// 提交图数据:--all --topo-order,含父提交与引用名,供前端绘制分支图。 +#[tauri::command] +pub async fn git_graph(root: String, limit: u32) -> Result, String> { + tokio::task::spawn_blocking(move || { + let n = format!("-n{}", limit); + let args = vec![ + "log", + n.as_str(), + "--all", + "--topo-order", + "--date=format:%Y-%m-%d %H:%M", + "--pretty=format:%H\x1f%h\x1f%P\x1f%D\x1f%an\x1f%ad\x1f%s", + ]; + let out = run_git(&root, &args)?; + let mut commits = Vec::new(); + for line in out.lines() { + let p: Vec<&str> = line.split('\u{1f}').collect(); + if p.len() >= 7 { + let parents = p[2] + .split_whitespace() + .map(|s| s.to_string()) + .collect::>(); + commits.push(GitGraphCommit { + hash: p[0].to_string(), + short: p[1].to_string(), + parents, + refs: p[3].to_string(), + author: p[4].to_string(), + date: p[5].to_string(), + subject: p[6].to_string(), + }); + } + } + Ok(commits) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +#[derive(Serialize)] +pub struct GitRemote { + name: String, + url: String, +} + +/// 列出远程(名称 + fetch URL)。 +#[tauri::command] +pub async fn git_remotes(root: String) -> Result, String> { + tokio::task::spawn_blocking(move || { + let out = run_git(&root, &["remote", "-v"])?; + let mut list: Vec = Vec::new(); + for line in out.lines() { + // 形如 "origin\tgit@...(fetch)",仅取 fetch 行 + if !line.contains("(fetch)") { + continue; + } + let mut it = line.split_whitespace(); + let name = it.next().unwrap_or("").to_string(); + let url = it.next().unwrap_or("").to_string(); + if !name.is_empty() { + list.push(GitRemote { name, url }); + } + } + Ok(list) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 添加远程。 +#[tauri::command] +pub async fn git_remote_add(root: String, name: String, url: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["remote", "add", &name, &url])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 删除远程。 +#[tauri::command] +pub async fn git_remote_remove(root: String, name: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["remote", "remove", &name])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 探测进行中的 git 操作:merge / rebase / cherry-pick / revert / none。 +#[tauri::command] +pub async fn git_op_state(root: String) -> Result { + tokio::task::spawn_blocking(move || { + let git_dir = run_git(&root, &["rev-parse", "--git-dir"])? + .trim() + .to_string(); + let p = std::path::Path::new(&git_dir); + let base = if p.is_absolute() { + std::path::PathBuf::from(&git_dir) + } else { + std::path::Path::new(&root).join(&git_dir) + }; + let state = if base.join("rebase-merge").exists() || base.join("rebase-apply").exists() { + "rebase" + } else if base.join("MERGE_HEAD").exists() { + "merge" + } else if base.join("CHERRY_PICK_HEAD").exists() { + "cherry-pick" + } else if base.join("REVERT_HEAD").exists() { + "revert" + } else { + "none" + }; + Ok(state.to_string()) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 校验进行中操作名,避免拼接任意子命令。 +fn valid_op(op: &str) -> bool { + matches!(op, "merge" | "rebase" | "cherry-pick" | "revert") +} + +/// 中止进行中的操作。 +#[tauri::command] +pub async fn git_op_abort(root: String, op: String) -> Result { + tokio::task::spawn_blocking(move || { + if !valid_op(&op) { + return Err(format!("未知操作: {}", op)); + } + run_git(&root, &[op.as_str(), "--abort"]) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 继续进行中的操作(冲突解决后)。用 core.editor=true 避免弹编辑器。 +#[tauri::command] +pub async fn git_op_continue(root: String, op: String) -> Result { + tokio::task::spawn_blocking(move || { + if !valid_op(&op) { + return Err(format!("未知操作: {}", op)); + } + run_git( + &root, + &["-c", "core.editor=true", op.as_str(), "--continue"], + ) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 跳过当前提交(rebase / cherry-pick / revert,merge 无此操作)。 +#[tauri::command] +pub async fn git_op_skip(root: String, op: String) -> Result { + tokio::task::spawn_blocking(move || { + if !matches!(op.as_str(), "rebase" | "cherry-pick" | "revert") { + return Err(format!("该操作不支持跳过: {}", op)); + } + run_git(&root, &[op.as_str(), "--skip"]) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +#[derive(Serialize)] +pub struct GitBisectState { + active: bool, + /// 当前待测 HEAD 短哈希 + head: String, + /// 当前待测提交主题 + subject: String, + /// git bisect 最近输出(剩余步数 / 首个坏提交等) + message: String, +} + +/// 查询二分定位状态:是否进行中、当前待测提交。 +#[tauri::command] +pub async fn git_bisect_state(root: String) -> Result { + tokio::task::spawn_blocking(move || { + let git_dir = run_git(&root, &["rev-parse", "--git-dir"])? + .trim() + .to_string(); + let p = std::path::Path::new(&git_dir); + let base = if p.is_absolute() { + std::path::PathBuf::from(&git_dir) + } else { + std::path::Path::new(&root).join(&git_dir) + }; + let active = base.join("BISECT_START").exists(); + let mut head = String::new(); + let mut subject = String::new(); + if active { + if let Ok(out) = run_git(&root, &["log", "-1", "--pretty=format:%h\x1f%s", "HEAD"]) { + let parts: Vec<&str> = out.split('\u{1f}').collect(); + if parts.len() >= 2 { + head = parts[0].to_string(); + subject = parts[1].to_string(); + } + } + } + Ok(GitBisectState { + active, + head, + subject, + message: String::new(), + }) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 二分定位子命令:start / good / bad / skip / reset。rev 可选,仅 good/bad 使用。 +#[tauri::command] +pub async fn git_bisect(root: String, action: String, rev: String) -> Result { + tokio::task::spawn_blocking(move || { + if !matches!(action.as_str(), "start" | "good" | "bad" | "skip" | "reset") { + return Err(format!("未知二分操作: {}", action)); + } + let mut args = vec!["bisect", action.as_str()]; + if matches!(action.as_str(), "good" | "bad") && !rev.trim().is_empty() { + args.push(rev.as_str()); + } + run_git(&root, &args) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +#[derive(Deserialize)] +pub struct RebaseTodo { + /// pick / squash / fixup / drop + action: String, + hash: String, +} + +/// 交互式 rebase(非交互执行):按 todos(须为旧→新顺序)改写 base 之上的提交。 +/// 通过 sequence.editor 注入 todo、core.editor=true 接受默认合并信息,避免弹编辑器。 +/// 仅在类 Unix 平台支持。 +#[tauri::command] +pub async fn git_rebase_interactive( + root: String, + base: String, + todos: Vec, +) -> Result { + tokio::task::spawn_blocking(move || { + if !cfg!(unix) { + return Err("当前平台暂不支持交互式 rebase".to_string()); + } + if todos.is_empty() { + return Err("没有可处理的提交".to_string()); + } + let mut lines = String::new(); + for t in &todos { + if !matches!(t.action.as_str(), "pick" | "squash" | "fixup" | "drop") { + return Err(format!("未知 rebase 动作: {}", t.action)); + } + lines.push_str(&t.action); + lines.push(' '); + lines.push_str(&t.hash); + lines.push('\n'); + } + + let stamp = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let tmp = std::env::temp_dir().join(format!( + "codeforge-rebase-{}-{}.txt", + std::process::id(), + stamp + )); + std::fs::write(&tmp, &lines).map_err(|e| format!("写入 rebase todo 失败: {}", e))?; + let tmp_str = tmp.to_string_lossy().to_string(); + + // git 会把待编辑文件路径追加到该命令后再经 shell 执行,故用 cp 覆盖之 + let seq_editor = format!("sequence.editor=cp '{}'", tmp_str); + let result = std::process::Command::new("git") + .args([ + "-C", + &root, + "-c", + seq_editor.as_str(), + "-c", + "core.editor=true", + "rebase", + "-i", + base.as_str(), + ]) + .output(); + let _ = std::fs::remove_file(&tmp); + + let output = result.map_err(|e| format!("执行 git 失败: {}", e))?; + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr); + let out = String::from_utf8_lossy(&output.stdout); + return Err(format!("{}{}", out, err).trim().to_string()); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 拣选某提交到当前分支(cherry-pick)。 +#[tauri::command] +pub async fn git_cherry_pick(root: String, hash: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["cherry-pick", &hash])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 把当前分支的上游设为 /。 +#[tauri::command] +pub async fn git_set_upstream( + root: String, + remote: String, + branch: String, +) -> Result { + tokio::task::spawn_blocking(move || { + let target = format!("{}/{}", remote, branch); + run_git(&root, &["branch", &format!("--set-upstream-to={}", target)]) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 列出标签(按创建时间倒序)。 +#[tauri::command] +pub async fn git_tags(root: String) -> Result, String> { + tokio::task::spawn_blocking(move || { + let out = run_git(&root, &["tag", "--sort=-creatordate"])?; + Ok(out + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect()) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 读取仓库本地身份配置 (user.name / user.email),返回 [name, email],未设置则为空串。 +#[tauri::command] +pub async fn git_get_identity(root: String) -> Result, String> { + tokio::task::spawn_blocking(move || { + let name = run_git(&root, &["config", "--local", "user.name"]).unwrap_or_default(); + let email = run_git(&root, &["config", "--local", "user.email"]).unwrap_or_default(); + Ok(vec![name.trim().to_string(), email.trim().to_string()]) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 设置仓库本地身份配置。传入空串则忽略对应项。 +#[tauri::command] +pub async fn git_set_identity(root: String, name: String, email: String) -> Result { + tokio::task::spawn_blocking(move || { + if !name.trim().is_empty() { + run_git(&root, &["config", "--local", "user.name", name.trim()])?; + } + if !email.trim().is_empty() { + run_git(&root, &["config", "--local", "user.email", email.trim()])?; + } + Ok(String::new()) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 创建标签。hash 为空则打在 HEAD;message 非空则创建附注标签(-a -m)。 +#[tauri::command] +pub async fn git_tag_create( + root: String, + name: String, + hash: String, + message: String, +) -> Result { + tokio::task::spawn_blocking(move || { + let mut args: Vec<&str> = vec!["tag"]; + if !message.trim().is_empty() { + args.push("-a"); + args.push(name.as_str()); + args.push("-m"); + args.push(message.as_str()); + } else { + args.push(name.as_str()); + } + if !hash.trim().is_empty() { + args.push(hash.as_str()); + } + run_git(&root, &args) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 删除标签。 +#[tauri::command] +pub async fn git_tag_delete(root: String, name: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["tag", "-d", &name])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 还原某次提交(生成一条反向提交,历史保留)。 +#[tauri::command] +pub async fn git_revert(root: String, hash: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["revert", "--no-edit", &hash])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 重置到某提交。mode 为 soft / mixed / hard,默认 mixed。 +#[tauri::command] +pub async fn git_reset(root: String, hash: String, mode: String) -> Result { + tokio::task::spawn_blocking(move || { + let flag = match mode.as_str() { + "soft" => "--soft", + "hard" => "--hard", + _ => "--mixed", + }; + run_git(&root, &["reset", flag, &hash]) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +#[derive(Serialize)] +pub struct GitCompareResult { + /// head 相对 base 领先 / 落后的提交数 + ahead: u32, + behind: u32, + /// base...head 的差异补丁(含 stat) + patch: String, +} + +/// 对比两个 ref:base...head 的领先/落后提交数与差异补丁。 +#[tauri::command] +pub async fn git_compare( + root: String, + base: String, + head: String, +) -> Result { + tokio::task::spawn_blocking(move || { + let range = format!("{}...{}", base, head); + // rev-list --left-right --count 输出 "左 右":左=base 独有(落后),右=head 独有(领先) + let counts = run_git(&root, &["rev-list", "--left-right", "--count", &range])?; + let mut it = counts.split_whitespace(); + let behind: u32 = it.next().unwrap_or("0").parse().unwrap_or(0); + let ahead: u32 = it.next().unwrap_or("0").parse().unwrap_or(0); + let mut patch = run_git(&root, &["diff", "--stat", "-p", &range])?; + const MAX: usize = 200_000; + if patch.len() > MAX { + patch.truncate(MAX); + patch.push_str("\n…(内容过长已截断)"); + } + Ok(GitCompareResult { + ahead, + behind, + patch, + }) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 某个文件的提交历史(分页)。 +#[tauri::command] +pub async fn git_log_file( + root: String, + rel_path: String, + limit: u32, + skip: u32, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + let n = format!("-n{}", limit); + let sk = format!("--skip={}", skip); + let args = vec![ + "log", + n.as_str(), + sk.as_str(), + "--date=format:%Y-%m-%d %H:%M", + "--pretty=format:%H\x1f%h\x1f%an\x1f%ad\x1f%s", + "--follow", + "--", + rel_path.as_str(), + ]; + let out = run_git(&root, &args)?; + let mut commits = Vec::new(); + for line in out.lines() { + let p: Vec<&str> = line.split('\u{1f}').collect(); + if p.len() >= 5 { + commits.push(GitCommit { + hash: p[0].to_string(), + short: p[1].to_string(), + author: p[2].to_string(), + date: p[3].to_string(), + subject: p[4].to_string(), + }); + } + } + Ok(commits) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +#[derive(Serialize)] +pub struct GitReflogEntry { + short: String, + selector: String, + subject: String, + date: String, +} + +/// HEAD 引用日志(reflog),用于误操作恢复。 +#[tauri::command] +pub async fn git_reflog(root: String, limit: u32) -> Result, String> { + tokio::task::spawn_blocking(move || { + let n = format!("-n{}", limit); + let args = vec![ + "reflog", + n.as_str(), + "--date=format:%Y-%m-%d %H:%M", + "--pretty=format:%h\x1f%gd\x1f%gs\x1f%ad", + ]; + let out = run_git(&root, &args)?; + let mut entries = Vec::new(); + for line in out.lines() { + let p: Vec<&str> = line.split('\u{1f}').collect(); + if p.len() >= 4 { + entries.push(GitReflogEntry { + short: p[0].to_string(), + selector: p[1].to_string(), + subject: p[2].to_string(), + date: p[3].to_string(), + }); + } + } + Ok(entries) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 某次提交的详情补丁(git show,含 stat 与 diff)。 +#[tauri::command] +pub async fn git_show(root: String, hash: String) -> Result { + tokio::task::spawn_blocking(move || { + let mut out = run_git(&root, &["show", "--stat", "-p", &hash])?; + // 超大提交截断,避免渲染卡顿 + const MAX_SHOW_LEN: usize = 200_000; + if out.len() > MAX_SHOW_LEN { + out.truncate(MAX_SHOW_LEN); + out.push_str("\n…(内容过长已截断)"); + } + Ok(out) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +#[derive(Serialize)] +pub struct GitBlameLine { + short: String, + author: String, + date: String, + content: String, +} + +/// 对某文件做 git blame,返回逐行的提交短哈希/作者/日期/内容。 +#[tauri::command] +pub async fn git_blame(root: String, rel_path: String) -> Result, String> { + tokio::task::spawn_blocking(move || { + let out = run_git(&root, &["blame", "--line-porcelain", "--", &rel_path])?; + let mut lines = Vec::new(); + let mut hash = String::new(); + let mut author = String::new(); + let mut date = String::new(); + for raw in out.lines() { + if let Some(content) = raw.strip_prefix('\t') { + // 一行内容收尾:推入当前累积的提交信息 + lines.push(GitBlameLine { + short: hash.chars().take(8).collect(), + author: author.clone(), + date: date.clone(), + content: content.to_string(), + }); + } else if let Some(a) = raw.strip_prefix("author ") { + author = a.to_string(); + } else if let Some(ts) = raw.strip_prefix("author-time ") { + // epoch 秒 → 仅取日期 + if let Ok(secs) = ts.trim().parse::() { + date = format_epoch_date(secs); + } + } else if raw.len() >= 40 && raw.as_bytes()[0].is_ascii_hexdigit() { + // 形如 "<40hash> []",取首个 token 作哈希 + hash = raw.split(' ').next().unwrap_or("").to_string(); + } + } + Ok(lines) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// epoch 秒转 YYYY-MM-DD(UTC,无需第三方库)。 +fn format_epoch_date(secs: i64) -> String { + let days = secs.div_euclid(86400); + // 1970-01-01 起的天数转公历日期 + let mut y = 1970i64; + let mut d = days; + loop { + let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0; + let dy = if leap { 366 } else { 365 }; + if d >= dy { + d -= dy; + y += 1; + } else { + break; + } + } + let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0; + let mdays = [ + 31, + if leap { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + let mut m = 0usize; + while m < 12 && d >= mdays[m] { + d -= mdays[m]; + m += 1; + } + format!("{:04}-{:02}-{:02}", y, m + 1, d + 1) +} + #[derive(Serialize)] pub struct GitHeadFile { /// 该文件是否存在于 HEAD(不存在则为新增/未跟踪文件) diff --git a/src-tauri/src/geo.rs b/src-tauri/src/geo.rs new file mode 100644 index 0000000..6aaa032 --- /dev/null +++ b/src-tauri/src/geo.rs @@ -0,0 +1,47 @@ +//! 地图下钻所需的省/市级 geojson 获取:按 adcode 从 DataV GeoAtlas 拉取, +//! 落盘缓存到 ~/.codeforge/cache/geo/.json,首次联网后即可离线复用。 + +use std::fs; +use std::path::PathBuf; + +fn geo_cache_dir() -> Result { + let home_dir = dirs::home_dir().ok_or("无法获取用户主目录")?; + let dir = home_dir.join(".codeforge").join("cache").join("geo"); + fs::create_dir_all(&dir).map_err(|e| format!("创建地图缓存目录失败: {}", e))?; + Ok(dir) +} + +/// 取某 adcode 的省/市级边界 geojson(字符串)。优先读本地缓存,缺失时联网拉取并缓存。 +#[tauri::command] +pub async fn fetch_area_geojson(adcode: String) -> Result { + // 仅允许纯数字 adcode,避免路径注入与无效请求 + if adcode.is_empty() || !adcode.chars().all(|c| c.is_ascii_digit()) { + return Err(format!("非法 adcode: {}", adcode)); + } + + let cache_path = geo_cache_dir()?.join(format!("{}.json", adcode)); + if let Ok(cached) = fs::read_to_string(&cache_path) { + if !cached.trim().is_empty() { + return Ok(cached); + } + } + + let url = format!( + "https://geo.datav.aliyun.com/areas_v3/bound/{}_full.json", + adcode + ); + let resp = reqwest::get(&url) + .await + .map_err(|e| format!("请求地图数据失败: {}", e))?; + if !resp.status().is_success() { + return Err(format!("地图数据 HTTP {}", resp.status())); + } + let body = resp + .text() + .await + .map_err(|e| format!("读取地图数据失败: {}", e))?; + + // 写缓存失败不阻断返回(内存里已有数据) + let _ = fs::write(&cache_path, &body); + Ok(body) +} diff --git a/src-tauri/src/lsp.rs b/src-tauri/src/lsp.rs index 9cb1495..2bc6e91 100644 --- a/src-tauri/src/lsp.rs +++ b/src-tauri/src/lsp.rs @@ -57,6 +57,8 @@ fn server_cmd(language: &str) -> Option<(&'static str, Vec<&'static str>)> { "yaml" => Some(("yaml-language-server", vec!["--stdio"])), "shell" => Some(("bash-language-server", vec!["start"])), "haskell" => Some(("haskell-language-server-wrapper", vec!["--lsp"])), + "dart" => Some(("dart", vec!["language-server"])), + "ocaml" => Some(("ocamllsp", vec![])), _ => None, } } @@ -151,6 +153,13 @@ fn server_defs() -> Vec<(&'static str, &'static str, &'static str, &'static str) "haskell-language-server-wrapper", "ghcup install hls", ), + ("dart", "Dart", "dart", "brew install dart"), + ( + "ocaml", + "OCaml (ocaml-lsp)", + "ocamllsp", + "opam install ocaml-lsp-server", + ), ] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 85bcdb7..527c5b6 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -17,6 +17,7 @@ mod example; mod execution; mod filesystem; mod font; +mod geo; mod kv; mod logger; mod lsp; @@ -57,10 +58,21 @@ use crate::execution::{ stop_execution, }; use crate::filesystem::{ - create_directory, create_file, delete_path, get_text_file_meta, git_branches, git_checkout, - git_commit, git_diff, git_file_head, git_push, git_stage, git_status, git_unstage, 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, + create_directory, create_file, delete_path, get_text_file_meta, git_apply_patch, git_bisect, + git_bisect_state, git_blame, git_branch_create, git_branch_delete, git_branch_rename, + 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_graph, 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_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, }; use crate::kv::{KvStore, kv_delete, kv_get_all, kv_set}; use crate::lsp::{ @@ -78,6 +90,7 @@ use crate::utils::logger::{ use config::{get_app_config, get_config_path, init_config, update_app_config}; use example::load_example; use font::open_font_picker; +use geo::fetch_area_geojson; use log::info; use plugins::PluginManager; use update::{check_for_updates, start_update}; @@ -189,6 +202,7 @@ fn main() { start_update, load_example, open_font_picker, + fetch_area_geojson, // 文件系统相关命令 read_directory_tree, read_file_text, @@ -205,14 +219,74 @@ fn main() { search_in_files, replace_in_files, git_diff, + git_file_diff, + git_apply_patch, git_status, git_stage, git_unstage, + git_discard, git_commit, git_push, + git_pull, + git_fetch, + git_pull_rebase, + git_push_force, + git_push_tags, + git_delete_remote_branch, git_branches, git_checkout, + git_branch_create, + git_branch_delete, + git_branch_rename, + git_remote_branches, + git_checkout_track, + git_merge, + git_blame, git_file_head, + git_log, + git_graph, + git_log_file, + git_show, + git_revert, + git_reset, + git_restore_file, + git_reflog, + git_cherry_pick, + git_compare, + git_op_state, + git_op_abort, + git_op_continue, + git_op_skip, + git_tags, + git_tag_create, + git_tag_delete, + git_get_identity, + git_set_identity, + git_remotes, + git_remote_add, + git_remote_remove, + git_set_upstream, + git_init, + git_ignore_add, + git_clone, + git_clean_preview, + git_clean, + git_stash_list, + git_stash_push, + git_stash_pop, + git_stash_drop, + git_stash_apply, + git_stash_show, + git_submodules, + git_submodule_update, + git_submodule_sync, + git_worktrees, + git_worktree_add, + git_worktree_remove, + git_worktree_prune, + git_bisect_state, + git_bisect, + git_rebase_interactive, // AI 助手 ai_chat, ai_chat_stream, diff --git a/src-tauri/src/plugins/commonlisp.rs b/src-tauri/src/plugins/commonlisp.rs new file mode 100644 index 0000000..781b205 --- /dev/null +++ b/src-tauri/src/plugins/commonlisp.rs @@ -0,0 +1,54 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct CommonLispPlugin; + +impl LanguagePlugin for CommonLispPlugin { + fn get_order(&self) -> i32 { + 22 + } + + fn get_language_name(&self) -> &'static str { + "Common Lisp" + } + + fn get_language_key(&self) -> &'static str { + "commonlisp" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "lisp".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--version"] + } + + fn get_path_command(&self) -> String { + "which sbcl".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("commonlisp"), + before_compile: None, + extension: String::from("lisp"), + execute_home: None, + run_command: Some(String::from("sbcl --script $filename")), + after_compile: None, + template: Some(String::from("(format t \"Hello, Common Lisp!~%\")")), + timeout: Some(30), + console_type: Some(String::from("console")), + icon_path: None, + } + } + + fn get_default_command(&self) -> String { + self.get_config() + .and_then(|config| config.run_command.clone()) + .unwrap_or_else(|| "sbcl".to_string()) + } +} diff --git a/src-tauri/src/plugins/crystal.rs b/src-tauri/src/plugins/crystal.rs new file mode 100644 index 0000000..d6c1180 --- /dev/null +++ b/src-tauri/src/plugins/crystal.rs @@ -0,0 +1,54 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct CrystalPlugin; + +impl LanguagePlugin for CrystalPlugin { + fn get_order(&self) -> i32 { + 19 + } + + fn get_language_name(&self) -> &'static str { + "Crystal" + } + + fn get_language_key(&self) -> &'static str { + "crystal" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "cr".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--version"] + } + + fn get_path_command(&self) -> String { + "which crystal".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("crystal"), + before_compile: None, + extension: String::from("cr"), + execute_home: None, + run_command: Some(String::from("crystal run $filename")), + after_compile: None, + template: Some(String::from("puts \"Hello, Crystal!\"")), + timeout: Some(30), + console_type: Some(String::from("console")), + icon_path: None, + } + } + + fn get_default_command(&self) -> String { + self.get_config() + .and_then(|config| config.run_command.clone()) + .unwrap_or_else(|| "crystal".to_string()) + } +} diff --git a/src-tauri/src/plugins/d.rs b/src-tauri/src/plugins/d.rs new file mode 100644 index 0000000..07b2a33 --- /dev/null +++ b/src-tauri/src/plugins/d.rs @@ -0,0 +1,57 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct DPlugin; + +impl LanguagePlugin for DPlugin { + fn get_order(&self) -> i32 { + 21 + } + + fn get_language_name(&self) -> &'static str { + "D" + } + + fn get_language_key(&self) -> &'static str { + "d" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "d".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--version"] + } + + fn get_path_command(&self) -> String { + "which rdmd".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("d"), + before_compile: None, + extension: String::from("d"), + execute_home: None, + // rdmd 直接编译并运行单文件 D 脚本 + run_command: Some(String::from("rdmd $filename")), + after_compile: None, + template: Some(String::from( + "import std.stdio;\n\nvoid main() {\n writeln(\"Hello, D!\");\n}", + )), + timeout: Some(30), + console_type: Some(String::from("console")), + icon_path: None, + } + } + + fn get_default_command(&self) -> String { + self.get_config() + .and_then(|config| config.run_command.clone()) + .unwrap_or_else(|| "rdmd".to_string()) + } +} diff --git a/src-tauri/src/plugins/dart.rs b/src-tauri/src/plugins/dart.rs new file mode 100644 index 0000000..8b1c181 --- /dev/null +++ b/src-tauri/src/plugins/dart.rs @@ -0,0 +1,54 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct DartPlugin; + +impl LanguagePlugin for DartPlugin { + fn get_order(&self) -> i32 { + 29 + } + + fn get_language_name(&self) -> &'static str { + "Dart" + } + + fn get_language_key(&self) -> &'static str { + "dart" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "dart".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--version"] + } + + fn get_path_command(&self) -> String { + "which dart".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("dart"), + before_compile: None, + extension: String::from("dart"), + execute_home: None, + run_command: Some(String::from("dart run $filename")), + after_compile: None, + template: Some(String::from("void main() {\n print('Hello, Dart!');\n}")), + timeout: Some(30), + console_type: Some(String::from("console")), + icon_path: None, + } + } + + fn get_default_command(&self) -> String { + self.get_config() + .and_then(|config| config.run_command.clone()) + .unwrap_or_else(|| "dart".to_string()) + } +} diff --git a/src-tauri/src/plugins/erlang.rs b/src-tauri/src/plugins/erlang.rs new file mode 100644 index 0000000..5262915 --- /dev/null +++ b/src-tauri/src/plugins/erlang.rs @@ -0,0 +1,57 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct ErlangPlugin; + +impl LanguagePlugin for ErlangPlugin { + fn get_order(&self) -> i32 { + 20 + } + + fn get_language_name(&self) -> &'static str { + "Erlang" + } + + fn get_language_key(&self) -> &'static str { + "erlang" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "erl".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--version"] + } + + fn get_path_command(&self) -> String { + "which escript".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("erlang"), + before_compile: None, + extension: String::from("erl"), + execute_home: None, + // escript 直接执行含 main/1 的脚本 + run_command: Some(String::from("escript $filename")), + after_compile: None, + template: Some(String::from( + "main(_) ->\n io:format(\"Hello, Erlang!~n\").", + )), + timeout: Some(30), + console_type: Some(String::from("console")), + icon_path: None, + } + } + + fn get_default_command(&self) -> String { + self.get_config() + .and_then(|config| config.run_command.clone()) + .unwrap_or_else(|| "escript".to_string()) + } +} diff --git a/src-tauri/src/plugins/fsharp.rs b/src-tauri/src/plugins/fsharp.rs new file mode 100644 index 0000000..fcf3d75 --- /dev/null +++ b/src-tauri/src/plugins/fsharp.rs @@ -0,0 +1,54 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct FSharpPlugin; + +impl LanguagePlugin for FSharpPlugin { + fn get_order(&self) -> i32 { + 18 + } + + fn get_language_name(&self) -> &'static str { + "F#" + } + + fn get_language_key(&self) -> &'static str { + "fsharp" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "fsx".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--version"] + } + + fn get_path_command(&self) -> String { + "which dotnet".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("fsharp"), + before_compile: None, + extension: String::from("fsx"), + execute_home: None, + run_command: Some(String::from("dotnet fsi $filename")), + after_compile: None, + template: Some(String::from("printfn \"Hello, F#!\"")), + timeout: Some(30), + console_type: Some(String::from("console")), + icon_path: None, + } + } + + fn get_default_command(&self) -> String { + self.get_config() + .and_then(|config| config.run_command.clone()) + .unwrap_or_else(|| "dotnet".to_string()) + } +} diff --git a/src-tauri/src/plugins/julia.rs b/src-tauri/src/plugins/julia.rs new file mode 100644 index 0000000..706024b --- /dev/null +++ b/src-tauri/src/plugins/julia.rs @@ -0,0 +1,54 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct JuliaPlugin; + +impl LanguagePlugin for JuliaPlugin { + fn get_order(&self) -> i32 { + 31 + } + + fn get_language_name(&self) -> &'static str { + "Julia" + } + + fn get_language_key(&self) -> &'static str { + "julia" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "jl".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--version"] + } + + fn get_path_command(&self) -> String { + "which julia".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("julia"), + before_compile: None, + extension: String::from("jl"), + execute_home: None, + run_command: Some(String::from("julia $filename")), + after_compile: None, + template: Some(String::from("println(\"Hello, Julia!\")")), + timeout: Some(30), + console_type: Some(String::from("console")), + icon_path: None, + } + } + + fn get_default_command(&self) -> String { + self.get_config() + .and_then(|config| config.run_command.clone()) + .unwrap_or_else(|| "julia".to_string()) + } +} diff --git a/src-tauri/src/plugins/manager.rs b/src-tauri/src/plugins/manager.rs index c079b08..badbf73 100644 --- a/src-tauri/src/plugins/manager.rs +++ b/src-tauri/src/plugins/manager.rs @@ -3,10 +3,16 @@ use crate::plugins::applescript::AppleScriptPlugin; use crate::plugins::c::CPlugin; use crate::plugins::cangjie::CangjiePlugin; use crate::plugins::clojure::ClojurePlugin; +use crate::plugins::commonlisp::CommonLispPlugin; use crate::plugins::cpp::CppPlugin; +use crate::plugins::crystal::CrystalPlugin; use crate::plugins::css::CssPlugin; use crate::plugins::csv::CsvPlugin; use crate::plugins::custom::CustomPlugin; +use crate::plugins::d::DPlugin; +use crate::plugins::dart::DartPlugin; +use crate::plugins::erlang::ErlangPlugin; +use crate::plugins::fsharp::FSharpPlugin; use crate::plugins::go::GoPlugin; use crate::plugins::groovy::GroovyPlugin; use crate::plugins::haskell::HaskellPlugin; @@ -16,6 +22,7 @@ use crate::plugins::javascript_browser::JavaScriptBrowserPlugin; use crate::plugins::javascript_jquery::JavaScriptJQueryPlugin; use crate::plugins::javascript_nodejs::JavaScriptNodeJsPlugin; use crate::plugins::json::JsonPlugin; +use crate::plugins::julia::JuliaPlugin; use crate::plugins::kotlin::KotlinPlugin; use crate::plugins::less::LessPlugin; use crate::plugins::lua::LuaPlugin; @@ -23,7 +30,11 @@ use crate::plugins::markdown::MarkdownPlugin; use crate::plugins::nodejs::NodeJSPlugin; use crate::plugins::objective_c::ObjectiveCPlugin; use crate::plugins::objective_cpp::ObjectiveCppPlugin; +use crate::plugins::ocaml::OCamlPlugin; +use crate::plugins::pascal::PascalPlugin; +use crate::plugins::perl::PerlPlugin; use crate::plugins::php::PHPPlugin; +use crate::plugins::powershell::PowerShellPlugin; use crate::plugins::python2::Python2Plugin; use crate::plugins::python3::Python3Plugin; use crate::plugins::r::RPlugin; @@ -31,15 +42,18 @@ use crate::plugins::react::ReactPlugin; use crate::plugins::ruby::RubyPlugin; use crate::plugins::rust::RustPlugin; use crate::plugins::scala::ScalaPlugin; +use crate::plugins::scheme::SchemePlugin; use crate::plugins::shell::ShellPlugin; use crate::plugins::sql::SqlPlugin; use crate::plugins::svg::SvgPlugin; use crate::plugins::swift::SwiftPlugin; +use crate::plugins::tcl::TclPlugin; use crate::plugins::text::TextPlugin; use crate::plugins::tsv::TsvPlugin; use crate::plugins::typescript::TypeScriptPlugin; use crate::plugins::typescript_browser::TypeScriptBrowserPlugin; use crate::plugins::typescript_nodejs::TypeScriptNodeJsPlugin; +use crate::plugins::vue::VuePlugin; use crate::plugins::xlsx::XlsxPlugin; use crate::plugins::xml::XmlPlugin; use crate::plugins::yaml::YamlPlugin; @@ -62,16 +76,22 @@ impl PluginManager { ("go".to_string(), Box::new(GoPlugin)), ("java".to_string(), Box::new(JavaPlugin)), ("shell".to_string(), Box::new(ShellPlugin)), + ("powershell".to_string(), Box::new(PowerShellPlugin)), ("rust".to_string(), Box::new(RustPlugin)), ("swift".to_string(), Box::new(SwiftPlugin)), ("scala".to_string(), Box::new(ScalaPlugin)), + ("fsharp".to_string(), Box::new(FSharpPlugin)), ("kotlin".to_string(), Box::new(KotlinPlugin)), ("clojure".to_string(), Box::new(ClojurePlugin)), ("c".to_string(), Box::new(CPlugin)), ("ruby".to_string(), Box::new(RubyPlugin)), + ("dart".to_string(), Box::new(DartPlugin)), + ("perl".to_string(), Box::new(PerlPlugin)), + ("julia".to_string(), Box::new(JuliaPlugin)), ("applescript".to_string(), Box::new(AppleScriptPlugin)), ("typescript".to_string(), Box::new(TypeScriptPlugin)), ("react".to_string(), Box::new(ReactPlugin)), + ("vue".to_string(), Box::new(VuePlugin)), ("cpp".to_string(), Box::new(CppPlugin)), ("groovy".to_string(), Box::new(GroovyPlugin)), ("html".to_string(), Box::new(HtmlPlugin)), @@ -91,6 +111,14 @@ impl PluginManager { ("r".to_string(), Box::new(RPlugin)), ("cangjie".to_string(), Box::new(CangjiePlugin)), ("haskell".to_string(), Box::new(HaskellPlugin)), + ("ocaml".to_string(), Box::new(OCamlPlugin)), + ("tcl".to_string(), Box::new(TclPlugin)), + ("crystal".to_string(), Box::new(CrystalPlugin)), + ("erlang".to_string(), Box::new(ErlangPlugin)), + ("d".to_string(), Box::new(DPlugin)), + ("commonlisp".to_string(), Box::new(CommonLispPlugin)), + ("scheme".to_string(), Box::new(SchemePlugin)), + ("pascal".to_string(), Box::new(PascalPlugin)), ("lua".to_string(), Box::new(LuaPlugin)), ("objective-c".to_string(), Box::new(ObjectiveCPlugin)), ("objective-cpp".to_string(), Box::new(ObjectiveCppPlugin)), diff --git a/src-tauri/src/plugins/mod.rs b/src-tauri/src/plugins/mod.rs index 66c0ff6..93c34f6 100644 --- a/src-tauri/src/plugins/mod.rs +++ b/src-tauri/src/plugins/mod.rs @@ -402,10 +402,16 @@ pub mod applescript; pub mod c; pub mod cangjie; pub mod clojure; +pub mod commonlisp; pub mod cpp; +pub mod crystal; pub mod css; pub mod csv; pub mod custom; +pub mod d; +pub mod dart; +pub mod erlang; +pub mod fsharp; pub mod go; pub mod groovy; pub mod haskell; @@ -415,6 +421,7 @@ pub mod javascript_browser; pub mod javascript_jquery; pub mod javascript_nodejs; pub mod json; +pub mod julia; pub mod kotlin; pub mod less; pub mod lua; @@ -423,7 +430,11 @@ pub mod markdown; pub mod nodejs; pub mod objective_c; pub mod objective_cpp; +pub mod ocaml; +pub mod pascal; +pub mod perl; pub mod php; +pub mod powershell; pub mod python2; pub mod python3; pub mod r; @@ -431,15 +442,18 @@ pub mod react; pub mod ruby; pub mod rust; pub mod scala; +pub mod scheme; pub mod shell; pub mod sql; pub mod svg; pub mod swift; +pub mod tcl; pub mod text; pub mod tsv; pub mod typescript; pub mod typescript_browser; pub mod typescript_nodejs; +pub mod vue; pub mod xlsx; pub mod xml; pub mod yaml; diff --git a/src-tauri/src/plugins/ocaml.rs b/src-tauri/src/plugins/ocaml.rs new file mode 100644 index 0000000..a90ab27 --- /dev/null +++ b/src-tauri/src/plugins/ocaml.rs @@ -0,0 +1,54 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct OCamlPlugin; + +impl LanguagePlugin for OCamlPlugin { + fn get_order(&self) -> i32 { + 25 + } + + fn get_language_name(&self) -> &'static str { + "OCaml" + } + + fn get_language_key(&self) -> &'static str { + "ocaml" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "ml".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["-version"] + } + + fn get_path_command(&self) -> String { + "which ocaml".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("ocaml"), + before_compile: None, + extension: String::from("ml"), + execute_home: None, + run_command: Some(String::from("ocaml $filename")), + after_compile: None, + template: Some(String::from("let () = print_endline \"Hello, OCaml!\"")), + timeout: Some(30), + console_type: Some(String::from("console")), + icon_path: None, + } + } + + fn get_default_command(&self) -> String { + self.get_config() + .and_then(|config| config.run_command.clone()) + .unwrap_or_else(|| "ocaml".to_string()) + } +} diff --git a/src-tauri/src/plugins/pascal.rs b/src-tauri/src/plugins/pascal.rs new file mode 100644 index 0000000..7b3ac41 --- /dev/null +++ b/src-tauri/src/plugins/pascal.rs @@ -0,0 +1,55 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct PascalPlugin; + +impl LanguagePlugin for PascalPlugin { + fn get_order(&self) -> i32 { + 24 + } + + fn get_language_name(&self) -> &'static str { + "Pascal" + } + + fn get_language_key(&self) -> &'static str { + "pascal" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "pas".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--version"] + } + + fn get_path_command(&self) -> String { + "which instantfpc".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("pascal"), + before_compile: None, + extension: String::from("pas"), + execute_home: None, + // InstantFPC 直接编译并运行单文件 Pascal 脚本 + run_command: Some(String::from("instantfpc $filename")), + after_compile: None, + template: Some(String::from("begin\n writeln('Hello, Pascal!');\nend.")), + timeout: Some(30), + console_type: Some(String::from("console")), + icon_path: None, + } + } + + fn get_default_command(&self) -> String { + self.get_config() + .and_then(|config| config.run_command.clone()) + .unwrap_or_else(|| "instantfpc".to_string()) + } +} diff --git a/src-tauri/src/plugins/perl.rs b/src-tauri/src/plugins/perl.rs new file mode 100644 index 0000000..7b96ab8 --- /dev/null +++ b/src-tauri/src/plugins/perl.rs @@ -0,0 +1,54 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct PerlPlugin; + +impl LanguagePlugin for PerlPlugin { + fn get_order(&self) -> i32 { + 30 + } + + fn get_language_name(&self) -> &'static str { + "Perl" + } + + fn get_language_key(&self) -> &'static str { + "perl" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "pl".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--version"] + } + + fn get_path_command(&self) -> String { + "which perl".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("perl"), + before_compile: None, + extension: String::from("pl"), + execute_home: None, + run_command: Some(String::from("perl $filename")), + after_compile: None, + template: Some(String::from("print \"Hello, Perl!\\n\";")), + timeout: Some(30), + console_type: Some(String::from("console")), + icon_path: None, + } + } + + fn get_default_command(&self) -> String { + self.get_config() + .and_then(|config| config.run_command.clone()) + .unwrap_or_else(|| "perl".to_string()) + } +} diff --git a/src-tauri/src/plugins/powershell.rs b/src-tauri/src/plugins/powershell.rs new file mode 100644 index 0000000..7974847 --- /dev/null +++ b/src-tauri/src/plugins/powershell.rs @@ -0,0 +1,54 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct PowerShellPlugin; + +impl LanguagePlugin for PowerShellPlugin { + fn get_order(&self) -> i32 { + 16 + } + + fn get_language_name(&self) -> &'static str { + "PowerShell" + } + + fn get_language_key(&self) -> &'static str { + "powershell" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "ps1".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--version"] + } + + fn get_path_command(&self) -> String { + "which pwsh".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("powershell"), + before_compile: None, + extension: String::from("ps1"), + execute_home: None, + run_command: Some(String::from("pwsh -File $filename")), + after_compile: None, + template: Some(String::from("Write-Output \"Hello, PowerShell!\"")), + timeout: Some(30), + console_type: Some(String::from("console")), + icon_path: None, + } + } + + fn get_default_command(&self) -> String { + self.get_config() + .and_then(|config| config.run_command.clone()) + .unwrap_or_else(|| "pwsh".to_string()) + } +} diff --git a/src-tauri/src/plugins/scheme.rs b/src-tauri/src/plugins/scheme.rs new file mode 100644 index 0000000..e94958f --- /dev/null +++ b/src-tauri/src/plugins/scheme.rs @@ -0,0 +1,54 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct SchemePlugin; + +impl LanguagePlugin for SchemePlugin { + fn get_order(&self) -> i32 { + 23 + } + + fn get_language_name(&self) -> &'static str { + "Scheme" + } + + fn get_language_key(&self) -> &'static str { + "scheme" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "scm".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--version"] + } + + fn get_path_command(&self) -> String { + "which guile".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("scheme"), + before_compile: None, + extension: String::from("scm"), + execute_home: None, + run_command: Some(String::from("guile $filename")), + after_compile: None, + template: Some(String::from("(display \"Hello, Scheme!\")\n(newline)")), + timeout: Some(30), + console_type: Some(String::from("console")), + icon_path: None, + } + } + + fn get_default_command(&self) -> String { + self.get_config() + .and_then(|config| config.run_command.clone()) + .unwrap_or_else(|| "guile".to_string()) + } +} diff --git a/src-tauri/src/plugins/tcl.rs b/src-tauri/src/plugins/tcl.rs new file mode 100644 index 0000000..5ffdd3e --- /dev/null +++ b/src-tauri/src/plugins/tcl.rs @@ -0,0 +1,54 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct TclPlugin; + +impl LanguagePlugin for TclPlugin { + fn get_order(&self) -> i32 { + 17 + } + + fn get_language_name(&self) -> &'static str { + "Tcl" + } + + fn get_language_key(&self) -> &'static str { + "tcl" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "tcl".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--version"] + } + + fn get_path_command(&self) -> String { + "which tclsh".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("tcl"), + before_compile: None, + extension: String::from("tcl"), + execute_home: None, + run_command: Some(String::from("tclsh $filename")), + after_compile: None, + template: Some(String::from("puts \"Hello, Tcl!\"")), + timeout: Some(30), + console_type: Some(String::from("console")), + icon_path: None, + } + } + + fn get_default_command(&self) -> String { + self.get_config() + .and_then(|config| config.run_command.clone()) + .unwrap_or_else(|| "tclsh".to_string()) + } +} diff --git a/src-tauri/src/plugins/vue.rs b/src-tauri/src/plugins/vue.rs new file mode 100644 index 0000000..7abafec --- /dev/null +++ b/src-tauri/src/plugins/vue.rs @@ -0,0 +1,61 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct VuePlugin; + +impl LanguagePlugin for VuePlugin { + fn get_order(&self) -> i32 { + 15 + } + + fn get_language_name(&self) -> &'static str { + "Vue" + } + + fn get_language_key(&self) -> &'static str { + "vue" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "vue".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--"] + } + + fn get_path_command(&self) -> String { + // 浏览器内运行时预览,无需本机依赖(与 HTML 一致) + "--".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("vue"), + before_compile: None, + extension: String::from("vue"), + execute_home: None, + // 浏览器预览:CDN 引入 Vue 3 全局构建(含运行时模板编译器), + // 用户代码用 Vue.createApp(...).mount('#app') 直接挂载。 + run_command: Some(String::from( + "echo
\n\n", + )), + after_compile: None, + template: Some(String::from( + "const { createApp, ref } = Vue\n\ncreateApp({\n setup() {\n const msg = ref('Hello, Vue!')\n return { msg }\n },\n template: `

{{ msg }}

`\n}).mount('#app')", + )), + timeout: Some(30), + console_type: Some(String::from("web")), + icon_path: None, + } + } + + fn get_default_command(&self) -> String { + self.get_config() + .and_then(|config| config.run_command.clone()) + .unwrap_or_else(|| "echo".to_string()) + } +} diff --git a/src/App.vue b/src/App.vue index a5897e6..e0bf6e1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -55,13 +55,15 @@ :active-path="currentFilePath" :recent-folders="recentFolders" :git-status="gitStatus" + :git-repo="gitRepo" class="flex-shrink-0" :style="{ width: `${sidebarWidth}px` }" @open-folder="openFolder" @open-recent="openFolderPath" @open-file="smartOpen" @renamed="(from, to) => updateTabPath(from, to)" - @deleted="(p) => detachTabPath(p)"/> + @deleted="(p) => detachTabPath(p)" + @git-refresh="refreshGitStatus"/>
@@ -373,25 +375,50 @@
- - - -
- - + +
+ + + + + +
{ const showDiagnostics = ref(false) // ===== 编辑器 LSP 右键菜单(跳转定义 / 重命名 / 格式化)===== -const editorCtx = reactive({visible: false, x: 0, y: 0}) +const editorCtx = reactive({visible: false, x: 0, y: 0, lsp: false}) const closeEditorCtx = () => { editorCtx.visible = false } + +// Git Blame:当前文件在已打开文件夹内时可用 +const blameInfo = ref<{ root: string; rel: string; name: string } | null>(null) +const canBlame = computed(() => !!rootDir.value && !!currentFilePath.value && currentFilePath.value.startsWith(rootDir.value)) +const openBlame = () => { + editorCtx.visible = false + const root = rootDir.value + const path = currentFilePath.value + if (!root || !path) { + return + } + const rel = path.slice(root.length).replace(/^[\\/]/, '') + blameInfo.value = {root, rel, name: rel.split(/[\\/]/).pop() || rel} +} + +// 文件提交历史 +const fileHistory = ref<{ root: string; rel: string; name: string } | null>(null) +const openFileHistory = () => { + editorCtx.visible = false + const root = rootDir.value + const path = currentFilePath.value + if (!root || !path) { + return + } + const rel = path.slice(root.length).replace(/^[\\/]/, '') + fileHistory.value = {root, rel, name: rel.split(/[\\/]/).pop() || rel} +} const onEditorContext = (e: MouseEvent) => { const target = e.target as HTMLElement | null - // 仅在编辑器内容区、且当前语言支持 LSP 时弹出 - if (!target?.closest('.cm-content') || !lspSupportsLanguage(currentLanguage.value) || !editorView.value) { + const lsp = lspSupportsLanguage(currentLanguage.value) && !!editorView.value + // 在编辑器内容区,且支持 LSP 或可 Blame 时弹出 + if (!target?.closest('.cm-content') || (!lsp && !canBlame.value)) { return } + editorCtx.lsp = lsp e.preventDefault() // 将光标移到右键处,使命令作用于点击位置 const view = editorView.value - const pos = view.posAtCoords({x: e.clientX, y: e.clientY}) - if (pos != null) { - view.dispatch({selection: {anchor: pos}}) + if (view) { + const pos = view.posAtCoords({x: e.clientX, y: e.clientY}) + if (pos != null) { + view.dispatch({selection: {anchor: pos}}) + } } // 夹取到视口内,避免贴边裁切(菜单约 180×180) editorCtx.x = Math.min(e.clientX, window.innerWidth - 190) @@ -1249,6 +1309,7 @@ const openGit = () => { // 文件树徽标用:绝对路径 → 状态字母(M/A/D/U) const gitStatus = ref>({}) +const gitRepo = ref(false) const refreshGitStatus = async () => { if (!rootDir.value) { gitStatus.value = {} @@ -1258,6 +1319,7 @@ const refreshGitStatus = async () => { const s = await invoke<{ is_repo: boolean, files: { path: string, index: string, worktree: string }[] }>( 'git_status', {root: rootDir.value} ) + gitRepo.value = s.is_repo const map: Record = {} if (s.is_repo) { for (const f of s.files) { @@ -1271,6 +1333,7 @@ const refreshGitStatus = async () => { } catch { gitStatus.value = {} + gitRepo.value = false } // HEAD 可能因提交/切换分支变化,刷新编辑器行内差异基线 fetchBaseline() diff --git a/src/components/BlameView.vue b/src/components/BlameView.vue new file mode 100644 index 0000000..6c37023 --- /dev/null +++ b/src/components/BlameView.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/components/GitBisect.vue b/src/components/GitBisect.vue new file mode 100644 index 0000000..52d7ec0 --- /dev/null +++ b/src/components/GitBisect.vue @@ -0,0 +1,91 @@ + + + diff --git a/src/components/GitCompare.vue b/src/components/GitCompare.vue new file mode 100644 index 0000000..b518356 --- /dev/null +++ b/src/components/GitCompare.vue @@ -0,0 +1,93 @@ + + + diff --git a/src/components/GitConfig.vue b/src/components/GitConfig.vue new file mode 100644 index 0000000..1c52d2e --- /dev/null +++ b/src/components/GitConfig.vue @@ -0,0 +1,82 @@ + + + diff --git a/src/components/GitGraph.vue b/src/components/GitGraph.vue new file mode 100644 index 0000000..2b47e64 --- /dev/null +++ b/src/components/GitGraph.vue @@ -0,0 +1,194 @@ + + + diff --git a/src/components/GitLog.vue b/src/components/GitLog.vue new file mode 100644 index 0000000..96241b8 --- /dev/null +++ b/src/components/GitLog.vue @@ -0,0 +1,277 @@ + + + diff --git a/src/components/GitPanel.vue b/src/components/GitPanel.vue index 10642e6..d83515a 100644 --- a/src/components/GitPanel.vue +++ b/src/components/GitPanel.vue @@ -15,8 +15,97 @@ ↑{{ status.ahead }} ↓{{ status.behind }} +
+ + +
+ + + + + + + + + + + + + + @@ -27,14 +116,33 @@
+ + + +
+

+ {{ t('git.discardConfirm', { file: discardTarget?.path }) }} +

+
+ + +
+
+
+ + + +
+ + +
+
+ + + +
+ +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/GitReflog.vue b/src/components/GitReflog.vue new file mode 100644 index 0000000..6641f56 --- /dev/null +++ b/src/components/GitReflog.vue @@ -0,0 +1,95 @@ + + + diff --git a/src/components/GitRemotes.vue b/src/components/GitRemotes.vue new file mode 100644 index 0000000..610392d --- /dev/null +++ b/src/components/GitRemotes.vue @@ -0,0 +1,152 @@ + + + diff --git a/src/components/GitStash.vue b/src/components/GitStash.vue new file mode 100644 index 0000000..2877c14 --- /dev/null +++ b/src/components/GitStash.vue @@ -0,0 +1,158 @@ + + + diff --git a/src/components/GitSubmodules.vue b/src/components/GitSubmodules.vue new file mode 100644 index 0000000..948a428 --- /dev/null +++ b/src/components/GitSubmodules.vue @@ -0,0 +1,99 @@ + + + diff --git a/src/components/GitTags.vue b/src/components/GitTags.vue new file mode 100644 index 0000000..d65f354 --- /dev/null +++ b/src/components/GitTags.vue @@ -0,0 +1,122 @@ + + + diff --git a/src/components/GitWorktrees.vue b/src/components/GitWorktrees.vue new file mode 100644 index 0000000..b068a44 --- /dev/null +++ b/src/components/GitWorktrees.vue @@ -0,0 +1,139 @@ + + + diff --git a/src/components/HunkStageView.vue b/src/components/HunkStageView.vue new file mode 100644 index 0000000..fcc5ad0 --- /dev/null +++ b/src/components/HunkStageView.vue @@ -0,0 +1,128 @@ + + + diff --git a/src/components/Sidebar.vue b/src/components/Sidebar.vue index 3fe1d2d..52174cf 100644 --- a/src/components/Sidebar.vue +++ b/src/components/Sidebar.vue @@ -21,7 +21,10 @@

{{ t('sidebar.noFolder') }}

- +
+ + +
@@ -60,10 +63,62 @@
+ + + + +
+ + + + + + + +
+

{{ t('git.discardConfirm', { file: discardGit.node?.name }) }}

+
+ + +
+
+
+
@@ -75,6 +130,17 @@
+ + +
+ +
+ + +
+
+
+
@@ -93,12 +159,16 @@ diff --git a/src/components/charts/MapChart.vue b/src/components/charts/MapChart.vue index 0f668fd..cb843cf 100644 --- a/src/components/charts/MapChart.vue +++ b/src/components/charts/MapChart.vue @@ -1,14 +1,25 @@