diff --git a/package.json b/package.json index 37b63fe1..cf21fd6a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codeforge", "private": true, - "version": "26.2.0", + "version": "26.3.0", "type": "module", "scripts": { "dev": "vite", @@ -32,6 +32,7 @@ "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.38.1", "@open-rpc/client-js": "^2.0.0", + "@replit/codemirror-minimap": "^0.5.2", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.3.2", "@tauri-apps/plugin-fs": "^2.4.2", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5fda3dfc..247ed67b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "CodeForge" -version = "26.2.0" +version = "26.3.0" dependencies = [ "async-trait", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6bd79286..f0eac932 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "CodeForge" -version = "26.2.0" +version = "26.3.0" description = "CodeForge 是一款轻量级、高性能的桌面代码执行器,专为开发者、学生和编程爱好者设计。" authors = ["devlive-community"] edition = "2024" diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 6f742db8..361f5f6b 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -20,6 +20,8 @@ pub struct EditorConfig { pub show_line_numbers: Option, // 是否显示行号 pub show_function_help: Option, // 是否显示函数帮助 pub space_dot_omission: Option, // 是否显示空格省略 + pub show_minimap: Option, // 是否显示代码缩略图 + pub show_sticky_scroll: Option, // 是否启用粘性滚动 pub layout: Option, // 编辑器/控制台布局: horizontal | vertical | editor pub last_direction: Option, // 仅编辑器模式下控制台弹出方向: horizontal | vertical pub max_open_file_size: Option, // 打开文件大小上限(MB),超过则拒绝打开 @@ -69,6 +71,8 @@ impl Default for AppConfig { show_line_numbers: Some(true), show_function_help: Some(false), space_dot_omission: Some(false), + show_minimap: Some(false), + show_sticky_scroll: Some(false), layout: Some("horizontal".to_string()), last_direction: Some("horizontal".to_string()), max_open_file_size: Some(5), @@ -139,6 +143,8 @@ impl ConfigManager { show_line_numbers: Some(true), show_function_help: Some(false), space_dot_omission: Some(false), + show_minimap: Some(false), + show_sticky_scroll: Some(false), layout: Some("horizontal".to_string()), last_direction: Some("horizontal".to_string()), max_open_file_size: Some(5), @@ -262,6 +268,8 @@ impl ConfigManager { show_line_numbers: Some(true), show_function_help: Some(false), space_dot_omission: Some(false), + show_minimap: Some(false), + show_sticky_scroll: Some(false), layout: Some("horizontal".to_string()), last_direction: Some("horizontal".to_string()), max_open_file_size: Some(5), diff --git a/src-tauri/src/dap.rs b/src-tauri/src/dap.rs new file mode 100644 index 00000000..e8543071 --- /dev/null +++ b/src-tauri/src/dap.rs @@ -0,0 +1,306 @@ +//! DAP 桥接:按语言拉起调试适配器进程,转发 DAP 消息(与 LSP 同为 Content-Length 帧)。 +//! 后端只做透明转发:前端发送/接收原始 JSON 字符串,握手与协议由前端的 DAP 客户端负责。 +//! 复用 lsp.rs 的 find_in_path / augmented_path / read_message。 + +use crate::lsp::{augmented_path, find_in_path, read_message, read_raw_line}; +use serde::Serialize; +use std::collections::HashMap; +use std::io::{BufReader, Read, Write}; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex as StdMutex; +use tauri::{AppHandle, Emitter, State}; + +struct Adapter { + child: Child, + // 写入走独立线程,避免 stdin 写阻塞主线程 + tx: std::sync::mpsc::Sender>, +} + +pub struct DapState { + // 以会话 key 区分(前端通常传语言名,单语言单会话) + adapters: StdMutex>, +} + +impl DapState { + pub fn new() -> Self { + Self { + adapters: StdMutex::new(HashMap::new()), + } + } +} + +#[derive(Clone, Serialize)] +struct DapBatch { + session: String, + messages: Vec, +} + +/// 语言 -> (适配器可执行名, 启动参数)。适配器仅负责说 DAP; +/// 具体调试目标(program/args/cwd)由前端在 launch 请求中给出。新增语言在此加一行。 +fn adapter_cmd(language: &str) -> Option<(&'static str, Vec<&'static str>)> { + match language { + // debugpy:python -m debugpy.adapter(需 pip install debugpy) + "python" | "python3" | "python2" => Some(("python3", vec!["-m", "debugpy.adapter"])), + // delve:dlv dap + "go" => Some(("dlv", vec!["dap"])), + // lldb-dap(LLVM 自带):适用于 Rust / C / C++ + "rust" | "c" | "cpp" => Some(("lldb-dap", vec![])), + _ => None, + } +} + +/// 该语言是否有可用的调试适配器。Python 进一步校验 debugpy 模块是否可导入。 +#[tauri::command] +pub fn dap_available(language: String) -> bool { + let Some((prog, _)) = adapter_cmd(&language) else { + return false; + }; + let Some(exe) = find_in_path(prog) else { + return false; + }; + if matches!(language.as_str(), "python" | "python3" | "python2") { + return Command::new(&exe) + .args(["-c", "import debugpy"]) + .env("PATH", augmented_path()) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + } + true +} + +/// 启动调试适配器;已启动则直接返回 true。session 作为多会话区分键(前端传语言名即可)。 +#[tauri::command] +pub fn dap_start( + app: AppHandle, + state: State<'_, DapState>, + session: String, + language: String, +) -> Result { + { + let adapters = state.adapters.lock().map_err(|e| e.to_string())?; + if adapters.contains_key(&session) { + return Ok(true); + } + } + let (prog, args) = adapter_cmd(&language).ok_or_else(|| "该语言暂不支持调试".to_string())?; + let exe = find_in_path(prog) + .ok_or_else(|| format!("未找到调试适配器:{}(请先安装并确保在 PATH 中)", prog))?; + + let mut cmd = Command::new(&exe); + cmd.args(&args) + .env("PATH", augmented_path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| format!("启动 {} 失败: {}", prog, e))?; + let stdin = child.stdin.take().ok_or("无法获取 stdin")?; + let stdout = child.stdout.take().ok_or("无法获取 stdout")?; + let stderr = child.stderr.take(); + + // 读取线程:解析 Content-Length 帧,丢进 channel + let (msg_tx, msg_rx) = std::sync::mpsc::channel::(); + std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + while let Some(body) = read_message(&mut reader) { + if msg_tx.send(body).is_err() { + break; + } + } + }); + + // 发射线程:合批转发(output/variables 等可能突发),会话结束发 dap:exit + let app_reader = app.clone(); + let sess_emit = session.clone(); + std::thread::spawn(move || { + while let Ok(first) = msg_rx.recv() { + let mut batch = vec![first]; + while let Ok(m) = msg_rx.try_recv() { + batch.push(m); + if batch.len() >= 256 { + break; + } + } + let _ = app_reader.emit( + "dap:messages", + DapBatch { + session: sess_emit.clone(), + messages: batch, + }, + ); + } + let _ = app_reader.emit("dap:exit", sess_emit.clone()); + }); + + // 排空 stderr,避免阻塞 + if let Some(mut err) = stderr { + std::thread::spawn(move || { + let mut buf = [0u8; 4096]; + while let Ok(n) = err.read(&mut buf) { + if n == 0 { + break; + } + } + }); + } + + // 写入线程:独占 stdin,从 channel 取帧写入 + let (tx, rx) = std::sync::mpsc::channel::>(); + std::thread::spawn(move || { + let mut stdin = stdin; + while let Ok(frame) = rx.recv() { + if stdin.write_all(&frame).is_err() { + break; + } + if stdin.flush().is_err() { + break; + } + } + }); + + state + .adapters + .lock() + .map_err(|e| e.to_string())? + .insert(session, Adapter { child, tx }); + Ok(true) +} + +/// 向调试适配器发送一条 DAP 消息(已是完整 JSON 字符串) +#[tauri::command] +pub fn dap_send( + state: State<'_, DapState>, + session: String, + message: String, +) -> Result<(), String> { + let adapters = state.adapters.lock().map_err(|e| e.to_string())?; + let adapter = adapters + .get(&session) + .ok_or_else(|| "调试会话未启动".to_string())?; + let frame = format!("Content-Length: {}\r\n\r\n{}", message.len(), message); + adapter + .tx + .send(frame.into_bytes()) + .map_err(|_| "调试适配器写入通道已关闭".to_string())?; + Ok(()) +} + +/// 停止调试会话 +#[tauri::command] +pub fn dap_stop(state: State<'_, DapState>, session: String) -> Result<(), String> { + if let Some(mut adapter) = state + .adapters + .lock() + .map_err(|e| e.to_string())? + .remove(&session) + { + let _ = adapter.child.kill(); + } + Ok(()) +} + +// 可安装的调试适配器:(id, 展示名, 安装命令) +fn adapter_defs() -> Vec<(&'static str, &'static str, &'static str)> { + vec![ + ( + "debugpy", + "Python (debugpy)", + "python3 -m pip install debugpy", + ), + ( + "delve", + "Go (delve)", + "go install github.com/go-delve/delve/cmd/dlv@latest", + ), + ] +} + +fn adapter_installed(id: &str) -> bool { + match id { + // debugpy 校验模块可导入 + "debugpy" => dap_available("python".to_string()), + "delve" => find_in_path("dlv").is_some(), + _ => false, + } +} + +#[derive(Serialize)] +pub struct DapAdapterInfo { + id: String, + label: String, + installed: bool, + install: String, +} + +/// 列出可安装的调试适配器及其安装状态 +#[tauri::command] +pub fn dap_adapter_list() -> Vec { + adapter_defs() + .into_iter() + .map(|(id, label, install)| DapAdapterInfo { + id: id.to_string(), + label: label.to_string(), + installed: adapter_installed(id), + install: install.to_string(), + }) + .collect() +} + +/// 一键安装某调试适配器:执行安装命令并实时输出日志(事件 dap:install / dap:install-done) +#[tauri::command] +pub fn dap_install(app: AppHandle, id: String) -> Result<(), String> { + let def = adapter_defs() + .into_iter() + .find(|(d, ..)| *d == id) + .ok_or_else(|| "未知的调试适配器".to_string())?; + let cmd_str = def.2.to_string(); + + let (shell, flag) = if cfg!(windows) { + ("cmd", "/C") + } else { + ("sh", "-c") + }; + let mut child = Command::new(shell) + .arg(flag) + .arg(&cmd_str) + .env("PATH", augmented_path()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("无法执行安装命令: {}", e))?; + + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let id_done = id.clone(); + let app_done = app.clone(); + + let emit_lines = |app: AppHandle, id: String, reader: Option>| { + if let Some(r) = reader { + std::thread::spawn(move || { + let mut buf = BufReader::new(r); + while let Some(line) = read_raw_line(&mut buf) { + let _ = app.emit("dap:install", (id.clone(), line)); + } + }); + } + }; + emit_lines( + app.clone(), + id.clone(), + stdout.map(|s| Box::new(s) as Box), + ); + emit_lines( + app.clone(), + id.clone(), + stderr.map(|s| Box::new(s) as Box), + ); + + std::thread::spawn(move || { + let success = child.wait().map(|s| s.success()).unwrap_or(false); + let _ = app_done.emit("dap:install-done", (id_done, success)); + }); + Ok(()) +} diff --git a/src-tauri/src/filesystem.rs b/src-tauri/src/filesystem.rs index a99fa323..d842aed6 100644 --- a/src-tauri/src/filesystem.rs +++ b/src-tauri/src/filesystem.rs @@ -1036,10 +1036,16 @@ pub async fn git_log( limit: u32, skip: u32, revision: Option, + grep: Option, + author: Option, ) -> Result, String> { tokio::task::spawn_blocking(move || { let n = format!("-n{}", limit); let sk = format!("--skip={}", skip); + let grep = grep.unwrap_or_default(); + let author = author.unwrap_or_default(); + let grep_arg = format!("--grep={}", grep.trim()); + let author_arg = format!("--author={}", author.trim()); // 字段以 \x1f 分隔、每提交一行;%s 为单行主题 let mut args = vec![ "log", @@ -1048,6 +1054,14 @@ pub async fn git_log( "--date=format:%Y-%m-%d %H:%M", "--pretty=format:%H\x1f%h\x1f%an\x1f%ad\x1f%s", ]; + // 提交信息搜索(大小写不敏感)与作者过滤 + if !grep.trim().is_empty() { + args.push("-i"); + args.push(grep_arg.as_str()); + } + if !author.trim().is_empty() { + args.push(author_arg.as_str()); + } let rev = revision.unwrap_or_default(); if !rev.trim().is_empty() { args.push(rev.as_str()); @@ -1615,6 +1629,187 @@ pub async fn git_set_identity(root: String, name: String, email: String) -> Resu .map_err(|e| format!("git 任务失败: {}", e))? } +/// 读取仓库本地签名配置,返回 [gpgsign("true"/""), signingkey]。 +#[tauri::command] +pub async fn git_get_signing(root: String) -> Result, String> { + tokio::task::spawn_blocking(move || { + let sign = run_git(&root, &["config", "--local", "commit.gpgsign"]) + .unwrap_or_default() + .trim() + .to_string(); + let key = run_git(&root, &["config", "--local", "user.signingkey"]) + .unwrap_or_default() + .trim() + .to_string(); + let enabled = if sign == "true" { + "true".to_string() + } else { + String::new() + }; + Ok(vec![enabled, key]) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 设置仓库本地签名配置:commit.gpgsign 开关;key 非空则设 user.signingkey。 +#[tauri::command] +pub async fn git_set_signing(root: String, enabled: bool, key: String) -> Result { + tokio::task::spawn_blocking(move || { + run_git( + &root, + &[ + "config", + "--local", + "commit.gpgsign", + if enabled { "true" } else { "false" }, + ], + )?; + if !key.trim().is_empty() { + run_git(&root, &["config", "--local", "user.signingkey", key.trim()])?; + } + Ok(String::new()) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 常见客户端 git 钩子名(用于校验,避免路径穿越)。 +const GIT_HOOK_NAMES: [&str; 13] = [ + "applypatch-msg", + "pre-applypatch", + "post-applypatch", + "pre-commit", + "pre-merge-commit", + "prepare-commit-msg", + "commit-msg", + "post-commit", + "pre-rebase", + "post-checkout", + "post-merge", + "pre-push", + "post-rewrite", +]; + +fn git_hooks_dir(root: &str) -> Result { + 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) + }; + Ok(base.join("hooks")) +} + +#[cfg(unix)] +fn is_executable(path: &std::path::Path) -> bool { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path) + .map(|m| m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(not(unix))] +fn is_executable(path: &std::path::Path) -> bool { + path.exists() +} + +#[derive(Serialize)] +pub struct GitHook { + name: String, + /// 同名钩子文件已存在 + active: bool, + /// 具备可执行权限(类 Unix) + executable: bool, +} + +/// 列出常见客户端钩子及其状态。 +#[tauri::command] +pub async fn git_hooks(root: String) -> Result, String> { + tokio::task::spawn_blocking(move || { + let dir = git_hooks_dir(&root)?; + let mut list = Vec::new(); + for name in GIT_HOOK_NAMES { + let path = dir.join(name); + let active = path.is_file(); + list.push(GitHook { + name: name.to_string(), + active, + executable: active && is_executable(&path), + }); + } + Ok(list) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 读取某钩子内容(不存在返回空串)。 +#[tauri::command] +pub async fn git_hook_read(root: String, name: String) -> Result { + tokio::task::spawn_blocking(move || { + if !GIT_HOOK_NAMES.contains(&name.as_str()) { + return Err(format!("未知钩子: {}", name)); + } + let path = git_hooks_dir(&root)?.join(&name); + if path.is_file() { + std::fs::read_to_string(&path).map_err(|e| format!("读取钩子失败: {}", e)) + } else { + Ok(String::new()) + } + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 写入某钩子内容并按 executable 设置可执行权限(类 Unix)。 +#[tauri::command] +pub async fn git_hook_save( + root: String, + name: String, + content: String, + executable: bool, +) -> Result { + tokio::task::spawn_blocking(move || { + if !GIT_HOOK_NAMES.contains(&name.as_str()) { + return Err(format!("未知钩子: {}", name)); + } + let dir = git_hooks_dir(&root)?; + std::fs::create_dir_all(&dir).map_err(|e| format!("创建 hooks 目录失败: {}", e))?; + let path = dir.join(&name); + std::fs::write(&path, &content).map_err(|e| format!("写入钩子失败: {}", e))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = if executable { 0o755 } else { 0o644 }; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)); + } + Ok(String::new()) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 删除某钩子文件。 +#[tauri::command] +pub async fn git_hook_delete(root: String, name: String) -> Result { + tokio::task::spawn_blocking(move || { + if !GIT_HOOK_NAMES.contains(&name.as_str()) { + return Err(format!("未知钩子: {}", name)); + } + let path = git_hooks_dir(&root)?.join(&name); + if path.is_file() { + std::fs::remove_file(&path).map_err(|e| format!("删除钩子失败: {}", e))?; + } + Ok(String::new()) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + /// 创建标签。hash 为空则打在 HEAD;message 非空则创建附注标签(-a -m)。 #[tauri::command] pub async fn git_tag_create( diff --git a/src-tauri/src/lsp.rs b/src-tauri/src/lsp.rs index 2bc6e91c..fd9eb105 100644 --- a/src-tauri/src/lsp.rs +++ b/src-tauri/src/lsp.rs @@ -244,7 +244,7 @@ pub fn lsp_install(app: AppHandle, id: String) -> Result<(), String> { } /// 读取一行普通文本(以 \n 结尾,用于安装日志) -fn read_raw_line(reader: &mut BufReader) -> Option { +pub(crate) fn read_raw_line(reader: &mut BufReader) -> Option { let mut buf = Vec::new(); let mut byte = [0u8; 1]; loop { @@ -289,7 +289,7 @@ fn dirs_home() -> Option { } /// 在 PATH 与常见目录中查找可执行文件全路径 -fn find_in_path(prog: &str) -> Option { +pub(crate) fn find_in_path(prog: &str) -> Option { let exts: Vec<&str> = if cfg!(windows) { vec!["", ".cmd", ".exe", ".bat"] } else { @@ -312,7 +312,7 @@ fn find_in_path(prog: &str) -> Option { } /// 给子进程增广 PATH(语言服务器常依赖 node 等) -fn augmented_path() -> String { +pub(crate) fn augmented_path() -> String { let mut parts: Vec = Vec::new(); if let Some(path) = std::env::var_os("PATH") { parts.push(path.to_string_lossy().to_string()); @@ -474,7 +474,7 @@ pub fn lsp_stop(state: State<'_, LspState>, language: String) -> Result<(), Stri } /// 读取一条 LSP 消息(Content-Length 帧);EOF 返回 None -fn read_message(reader: &mut BufReader) -> Option { +pub(crate) fn read_message(reader: &mut BufReader) -> Option { let mut content_length: usize = 0; // 逐字节读 header 行直到空行 loop { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 527c5b67..affbb8e6 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8,6 +8,7 @@ mod ai_history; mod cache; mod config; mod custom_plugin_commands; +mod dap; mod db; mod db_connections; mod env_commands; @@ -39,6 +40,9 @@ use crate::custom_plugin_commands::{ add_custom_plugin, get_custom_plugins, remove_custom_plugin, save_custom_icon, update_custom_plugin, }; +use crate::dap::{ + DapState, dap_adapter_list, dap_available, dap_install, dap_send, dap_start, dap_stop, +}; use crate::db::{run_sql, run_sql_paged}; use crate::db_connections::{ DbConnStore, db_connection_delete, db_connection_save, db_connections_list, @@ -62,17 +66,17 @@ use crate::filesystem::{ 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, + git_file_diff, git_file_head, git_get_identity, git_get_signing, git_graph, git_hook_delete, + git_hook_read, git_hook_save, git_hooks, git_ignore_add, git_init, git_log, git_log_file, + git_merge, git_op_abort, git_op_continue, git_op_skip, git_op_state, git_pull, git_pull_rebase, + git_push, git_push_force, git_push_tags, git_rebase_interactive, git_reflog, git_remote_add, + git_remote_branches, git_remote_remove, git_remotes, git_reset, git_restore_file, git_revert, + git_set_identity, git_set_signing, git_set_upstream, git_show, git_stage, git_stash_apply, + git_stash_drop, git_stash_list, git_stash_pop, git_stash_push, git_stash_show, git_status, + git_submodule_sync, git_submodule_update, git_submodules, git_tag_create, git_tag_delete, + git_tags, git_unstage, git_worktree_add, git_worktree_prune, git_worktree_remove, + git_worktrees, list_files, read_directory_tree, read_file_lines, read_file_text, rename_path, + replace_in_files, reveal_path, search_in_files, watch_directory, write_file_text, }; use crate::kv::{KvStore, kv_delete, kv_get_all, kv_set}; use crate::lsp::{ @@ -120,6 +124,7 @@ fn main() { .manage(DbConnStore::new().expect("failed to initialize db connections database")) .manage(TerminalState::new()) .manage(LspState::new()) + .manage(DapState::new()) .manage(ExecutionPluginManagerState::new(PluginManager::new())) .manage(EnvironmentManagerState::new(env_manager)) .setup(|app| { @@ -262,6 +267,12 @@ fn main() { git_tag_delete, git_get_identity, git_set_identity, + git_get_signing, + git_set_signing, + git_hooks, + git_hook_read, + git_hook_save, + git_hook_delete, git_remotes, git_remote_add, git_remote_remove, @@ -322,7 +333,14 @@ fn main() { lsp_send, lsp_stop, lsp_server_list, - lsp_install + lsp_install, + // DAP 调试桥接 + dap_available, + dap_start, + dap_send, + dap_stop, + dap_adapter_list, + dap_install ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 11ab8f66..eb07e4d7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CodeForge", - "version": "26.2.0", + "version": "26.3.0", "identifier": "org.devlive.codeforge", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/App.vue b/src/App.vue index e0bf6e1c..d015904c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -15,6 +15,7 @@ @save-file="handleSave" @show-history="showHistory = true" @show-ai="handleShowAi" + @show-git="openGit" @show-settings="showSettings = true" @load-example="loadExample"> @@ -261,6 +262,7 @@ + + + + + + + + + +
+ @@ -443,8 +458,9 @@ import {computed, nextTick, onMounted, onUnmounted, reactive, ref, shallowRef, w import {useI18n} from 'vue-i18n' import {debounce} from 'lodash-es' import {formatDocument, formatSelection, renameSymbol} from 'codemirror-languageserver' -import {runGotoDefinition, lspSupportsLanguage, triggerCodeActions, applyCodeAction} from './editor/lspExtension' -import {ChevronRight, Code2, CornerDownRight, Eye, FolderOpen, GitBranch, GitCompare, History, ListTree, Maximize2, Monitor, Moon, PanelBottom, PanelLeft, PanelRight, Play, Plus, Save, Search, Settings as SettingsIcon, Sparkles, Sun, Terminal as TerminalIcon, X} from 'lucide-vue-next' +import {runGotoDefinition, lspSupportsLanguage, triggerCodeActions, applyCodeAction, formatDocumentAsync} from './editor/lspExtension' +import {dapSupportsLanguage} from './debug/dapClient' +import {ChevronRight, Code2, CornerDownRight, Eye, FolderOpen, GitBranch, GitCompare, History, ListChecks, ListTree, Maximize2, Monitor, Moon, PanelBottom, PanelLeft, PanelRight, Play, Plus, Save, Search, Settings as SettingsIcon, Sparkles, Sun, Terminal as TerminalIcon, X} from 'lucide-vue-next' import {ExecutionResult, LayoutMode, SplitDirection} from './types/app.ts' import AppHeader from './components/AppHeader.vue' import CodeEditor from './components/CodeEditor.vue' @@ -484,6 +500,9 @@ import PreviewPanel from './components/PreviewPanel.vue' import BlameView from './components/BlameView.vue' import GitLog from './components/GitLog.vue' import GitPanel from './components/GitPanel.vue' +import TaskRunner from './components/TaskRunner.vue' +import DebugToolbar from './components/DebugToolbar.vue' +import DebugPanel from './components/DebugPanel.vue' import GoToLine from './components/GoToLine.vue' import Outline from './components/Outline.vue' import SnippetManager from './components/SnippetManager.vue' @@ -496,6 +515,8 @@ import {useAiConfig} from './composables/useAiConfig' import {setGhost, clearGhostIn, ghostActive} from './editor/aiComplete' import {cursorInfo} from './editor/cursorInfo' import {computeDiffMarkers, setDiffMarkers} from './editor/diffGutter' +import {setBreakpointData} from './editor/breakpointGutter' +import {useDebug} from './composables/useDebug' import AiAssistant from './components/AiAssistant.vue' import InlineGenerate from './components/InlineGenerate.vue' import SearchPanel from './components/SearchPanel.vue' @@ -908,6 +929,14 @@ const openAiForExecution = (item: ExecutionResult) => { showAi.value = true } +// ===== 保存时格式化(走 LSP textDocument/formatting)===== +const formatOnSave = ref(kvGet('format-on-save') === 'true') +const toggleFormatOnSave = () => { + formatOnSave.value = !formatOnSave.value + kvSet('format-on-save', String(formatOnSave.value)) + toast.info(formatOnSave.value ? t('app.formatOnSaveOn') : t('app.formatOnSaveOff')) +} + // ===== AI 代码预测(幽灵补全,Tab 接受)===== const {active: aiActive, reload: reloadAiCfg} = useAiConfig() const aiCompletion = ref(kvGet('ai-completion') === 'true') @@ -1109,6 +1138,7 @@ const revealInFinder = (path: string) => { // 集成终端:首次打开后保持挂载(保留会话),仅切换显示 const showTerminal = ref(false) const terminalMounted = ref(false) +const terminalRef = ref(null) const toggleTerminal = () => { if (showTerminal.value) { showTerminal.value = false @@ -1119,6 +1149,134 @@ const toggleTerminal = () => { } } +// ===== 运行任务(B4):在集成终端中执行预设命令 ===== +const showTasks = ref(false) +const openTasks = () => { + if (!rootDir.value) { + toast.info(t('app.openFolderFirst')) + return + } + showTasks.value = true +} +const runTask = async (command: string) => { + terminalMounted.value = true + showTerminal.value = true + await nextTick() + terminalRef.value?.runCommand(command) +} + +// B1-P3:开始调试当前文件(需已保存 + 语言可调试) +const startDebug = async () => { + const path = currentFilePath.value + if (!path) { + toast.info(t('debug.saveFirst')) + return + } + const lang = currentLanguage.value + if (!dapSupportsLanguage(lang)) { + toast.info(t('debug.langUnsupported')) + return + } + // 适配器可用性检查(Python 含 debugpy 模块校验),不可用则给安装提示 + let ok = false + try { + ok = await invoke('dap_available', {language: lang}) + } + catch { + ok = false + } + if (!ok) { + toast.error(lang === 'go' ? t('debug.installGo') : t('debug.installPython')) + return + } + try { + await debug.startSession({filePath: path, language: lang, cwd: rootDir.value}) + } + catch (error) { + toast.error(t('debug.startFailed') + ': ' + error) + } +} + +// B2:按项目类型识别测试命令(读取根目录顶层标记文件) +const detectTestCommand = async (): Promise => { + const root = rootDir.value + if (!root) { + return null + } + let names: string[] = [] + try { + const nodes = await invoke<{ name: string; is_dir: boolean }[]>('read_directory_tree', {path: root}) + names = nodes.map(n => n.name) + } + catch { + return null + } + const has = (n: string) => names.includes(n) + if (has('Cargo.toml')) { + return 'cargo test' + } + if (has('go.mod')) { + return 'go test ./...' + } + if (has('package.json')) { + if (has('pnpm-lock.yaml')) { + return 'pnpm test' + } + if (has('yarn.lock')) { + return 'yarn test' + } + return 'npm test' + } + if (has('pyproject.toml') || has('pytest.ini') || has('setup.py') || has('tox.ini')) { + return 'pytest' + } + if (has('pom.xml')) { + return 'mvn test' + } + if (has('build.gradle') || has('build.gradle.kts')) { + return 'gradle test' + } + if (has('Gemfile')) { + return 'bundle exec rake test' + } + if (has('composer.json')) { + return 'composer test' + } + if (has('Makefile')) { + return 'make test' + } + return null +} +const runTests = async () => { + if (!rootDir.value) { + toast.info(t('app.openFolderFirst')) + return + } + const cmd = await detectTestCommand() + if (!cmd) { + toast.info(t('app.testCmdNotFound')) + return + } + await runTask(cmd) +} + +// B3:发送选区(无选区则当前行)到集成终端,用于 REPL 式交互 +const sendToTerminal = async () => { + closeEditorCtx() + const view = editorView.value + if (!view) { + return + } + const sel = view.state.selection.main + const text = sel.empty + ? view.state.doc.lineAt(sel.head).text + : view.state.sliceDoc(sel.from, sel.to) + if (!text.trim()) { + return + } + await runTask(text) +} + const openSearchResult = async (path: string, line: number) => { showSearch.value = false await smartOpen(path) @@ -1181,11 +1339,13 @@ const onEditorContext = (e: MouseEvent) => { } editorCtx.lsp = lsp e.preventDefault() - // 将光标移到右键处,使命令作用于点击位置 const view = editorView.value if (view) { + const cur = view.state.selection.main const pos = view.posAtCoords({x: e.clientX, y: e.clientY}) - if (pos != null) { + // 仅在无选区、或右键点在选区之外时才移动光标;点在选区内则保留选区(不清除高亮) + const insideSel = !cur.empty && pos != null && pos >= cur.from && pos <= cur.to + if (pos != null && !insideSel) { view.dispatch({selection: {anchor: pos}}) } } @@ -1381,6 +1541,39 @@ watch(currentFilePath, () => fetchBaseline()) watch(code, () => applyDiffMarkersDebounced()) watch(editorView, () => applyDiffMarkers()) +// ===== 断点(B1-P2):把当前文件的断点 + 执行行派发到编辑器 ===== +const debug = useDebug() +const applyBreakpoints = () => { + const view = editorView.value + if (!view) { + return + } + const path = currentFilePath.value + const lines = debug.fileBreakpoints(path) + const exec = debug.stopped.value && debug.stopped.value.path === path ? debug.stopped.value.line : null + view.dispatch({effects: setBreakpointData.of({lines, exec})}) +} +watch(() => debug.bpVersion.value, () => { + applyBreakpoints() + // 会话进行中:实时下发当前文件断点 + debug.syncBreakpoints(currentFilePath.value) +}) +watch(() => debug.stopped.value, () => applyBreakpoints()) +watch(editorView, () => applyBreakpoints()) +watch(currentFilePath, () => applyBreakpoints()) + +// 停驻 / 选择调用栈帧时打开对应文件并跳转 +watch(() => debug.reveal.value, async (loc) => { + if (!loc) { + return + } + if (loc.path !== currentFilePath.value) { + await smartOpen(loc.path) + await nextTick() + } + gotoLine(loc.line) +}) + // 打开文件夹、保存文件后刷新文件树 Git 徽标与差异基线 watch(rootDir, () => refreshGitStatus(), {immediate: true}) watch(savedContent, () => refreshGitStatus()) @@ -1461,6 +1654,14 @@ watch(watchMode, (v) => kvSet('watch-mode', String(v))) // 保存包装:保存后若开启监听模式则自动运行 const handleSave = async () => { + // 保存前格式化:仅当开启、编辑器就绪且当前语言支持 LSP;失败/无能力则静默跳过 + if (formatOnSave.value && editorView.value && lspSupportsLanguage(currentLanguage.value)) { + try { + await formatDocumentAsync(editorView.value) + await nextTick() + } + catch { /* 格式化失败不阻断保存 */ } + } await saveFile() if (watchMode.value && currentFilePath.value && !isDirty.value) { handleRunCode() @@ -1835,6 +2036,7 @@ const paletteCommands = computed(() => [ {id: 'runSelection', label: t('command.runSelection'), icon: Play, hint: hintOf('runSelection'), run: () => runSelection()}, {id: 'watchMode', label: watchMode.value ? t('command.watchModeOff') : t('command.watchModeOn'), icon: Eye, run: () => { watchMode.value = !watchMode.value }}, {id: 'aiCompletion', label: aiCompletion.value ? t('command.aiCompletionOff') : t('command.aiCompletionOn'), icon: Sparkles, run: () => toggleAiCompletion()}, + {id: 'formatOnSave', label: formatOnSave.value ? t('command.formatOnSaveOff') : t('command.formatOnSaveOn'), icon: Save, run: () => toggleFormatOnSave()}, {id: 'open', label: t('command.open'), icon: FolderOpen, hint: hintOf('open'), run: () => handleOpenFileClick()}, {id: 'openFolder', label: t('command.openFolder'), icon: FolderOpen, run: () => openFolder()}, {id: 'save', label: t('command.save'), icon: Save, hint: hintOf('save'), run: () => saveFile()}, @@ -1856,6 +2058,10 @@ const paletteCommands = computed(() => [ {id: 'diff', label: t('command.diff'), icon: GitCompare, run: () => openDiff()}, {id: 'preview', label: t('command.preview'), icon: Eye, run: () => togglePreview()}, {id: 'git', label: t('command.git'), icon: GitBranch, run: () => openGit()}, + {id: 'tasks', label: t('command.tasks'), icon: ListChecks, run: () => openTasks()}, + {id: 'runTests', label: t('command.runTests'), icon: ListChecks, run: () => runTests()}, + {id: 'startDebug', label: t('command.startDebug'), icon: Play, run: () => startDebug()}, + {id: 'sendToTerminal', label: t('command.sendToTerminal'), icon: TerminalIcon, run: () => sendToTerminal()}, {id: 'toggleSidebar', label: t('command.toggleSidebar'), icon: PanelLeft, hint: hintOf('toggleSidebar'), run: () => toggleSidebar()}, {id: 'layoutHorizontal', label: t('command.layoutHorizontal'), group: t('command.groupLayout'), icon: PanelRight, run: () => handleLayoutChange('horizontal')}, {id: 'layoutVertical', label: t('command.layoutVertical'), group: t('command.groupLayout'), icon: PanelBottom, run: () => handleLayoutChange('vertical')}, diff --git a/src/components/AppHeader.vue b/src/components/AppHeader.vue index 079ebd3d..ebe56edf 100644 --- a/src/components/AppHeader.vue +++ b/src/components/AppHeader.vue @@ -43,6 +43,9 @@ -
-

{{ t('git.identityHint') }}

- - +
+ +
+
{{ t('git.identitySection') }}
+

{{ t('git.identityHint') }}

+ + +
+ + +
+
{{ t('git.signingSection') }}
+

{{ t('git.signingHint') }}

+ + +
+
- +
@@ -50,6 +71,8 @@ const toast = useToast() const {t} = useI18n() const name = ref('') const email = ref('') +const gpgsign = ref(false) +const signingKey = ref('') const busy = ref(false) const load = async () => { @@ -57,6 +80,9 @@ const load = async () => { const [n, e] = await invoke('git_get_identity', {root: props.rootDir}) name.value = n || '' email.value = e || '' + const [sign, key] = await invoke('git_get_signing', {root: props.rootDir}) + gpgsign.value = sign === 'true' + signingKey.value = key || '' } catch (error) { toast.error(t('git.identityFailed') + ': ' + error) @@ -67,6 +93,7 @@ const save = async () => { busy.value = true try { await invoke('git_set_identity', {root: props.rootDir, name: name.value.trim(), email: email.value.trim()}) + await invoke('git_set_signing', {root: props.rootDir, enabled: gpgsign.value, key: signingKey.value.trim()}) toast.success(t('git.identitySaved')) emit('close') } diff --git a/src/components/GitHooks.vue b/src/components/GitHooks.vue new file mode 100644 index 00000000..e38b60ad --- /dev/null +++ b/src/components/GitHooks.vue @@ -0,0 +1,129 @@ +