diff --git a/package.json b/package.json index d997c93..1f6fa75 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,13 @@ "@codemirror/lang-html": "^6.4.9", "@codemirror/lang-java": "^6.0.2", "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-php": "^6.0.2", "@codemirror/lang-python": "^6.2.1", "@codemirror/lang-rust": "^6.0.2", "@codemirror/lang-xml": "^6.1.0", + "@codemirror/lang-yaml": "^6.1.3", "@codemirror/language": "^6.11.2", "@codemirror/legacy-modes": "^6.5.1", "@codemirror/state": "^6.5.2", @@ -32,6 +35,8 @@ "@tauri-apps/plugin-shell": "^2.3.0", "@uiw/codemirror-themes-all": "^4.24.2", "@vueuse/core": "^13.6.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "codemirror": "^6.0.2", "lodash-es": "^4.17.21", "lucide-vue-next": "^0.539.0", diff --git a/public/icons/json.svg b/public/icons/json.svg new file mode 100644 index 0000000..c8eb10d --- /dev/null +++ b/public/icons/json.svg @@ -0,0 +1,4 @@ + + + { } + diff --git a/public/icons/markdown.svg b/public/icons/markdown.svg new file mode 100644 index 0000000..ad14936 --- /dev/null +++ b/public/icons/markdown.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons/text.svg b/public/icons/text.svg new file mode 100644 index 0000000..c6006d1 --- /dev/null +++ b/public/icons/text.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/icons/xml.svg b/public/icons/xml.svg new file mode 100644 index 0000000..d468c0c --- /dev/null +++ b/public/icons/xml.svg @@ -0,0 +1,4 @@ + + + </> + diff --git a/public/icons/yaml.svg b/public/icons/yaml.svg new file mode 100644 index 0000000..fc707e5 --- /dev/null +++ b/public/icons/yaml.svg @@ -0,0 +1,4 @@ + + + YML + diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ec51ab3..dad6d15 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -15,6 +15,7 @@ dependencies = [ "futures-util", "log", "notify", + "portable-pty", "regex", "reqwest 0.11.27", "rfd 0.15.4", @@ -1237,10 +1238,21 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" dependencies = [ - "memoffset", + "memoffset 0.9.1", "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.26" @@ -2192,6 +2204,15 @@ dependencies = [ "libc", ] +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2343,6 +2364,12 @@ dependencies = [ "libc", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libappindicator" version = "0.9.0" @@ -2480,6 +2507,15 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2596,6 +2632,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", + "pin-utils", +] + [[package]] name = "nix" version = "0.30.1" @@ -2606,7 +2656,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", - "memoffset", + "memoffset 0.9.1", ] [[package]] @@ -3215,6 +3265,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.25.1", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.2" @@ -3905,6 +3976,48 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -3969,6 +4082,22 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -4651,6 +4780,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -5013,7 +5151,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ - "memoffset", + "memoffset 0.9.1", "tempfile", "winapi", ] @@ -5942,6 +6080,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.50.0" @@ -6105,7 +6252,7 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix", + "nix 0.30.1", "ordered-stream", "serde", "serde_repr", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 42c3350..3372903 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ futures-util = "0.3" rfd = "0.15" fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" } async-trait = "0.1" +portable-pty = "0.8" zip = "2.2.2" flate2 = "1.0" tar = "0.4" diff --git a/src-tauri/src/kv.rs b/src-tauri/src/kv.rs new file mode 100644 index 0000000..a0bf793 --- /dev/null +++ b/src-tauri/src/kv.rs @@ -0,0 +1,73 @@ +use crate::execution::get_codeforge_db_path; +use rusqlite::{Connection, params}; +use std::collections::HashMap; +use std::sync::Mutex as StdMutex; +use tauri::State; + +/// 通用键值存储,存于同一个 codeforge.sqlite 库。 +/// 用于替代前端 localStorage,集中持久化所有应用配置/状态。 +pub struct KvStore { + conn: StdMutex, +} + +impl KvStore { + pub fn new() -> Result { + let db_path = get_codeforge_db_path()?; + let conn = Connection::open(&db_path).map_err(|e| format!("打开数据库失败: {}", e))?; + let _ = conn.pragma_update(None, "journal_mode", "WAL"); + conn.execute( + "CREATE TABLE IF NOT EXISTS kv_store ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )", + [], + ) + .map_err(|e| format!("初始化键值表失败: {}", e))?; + + Ok(Self { + conn: StdMutex::new(conn), + }) + } +} + +/// 读取所有键值(启动时一次性载入到前端缓存) +#[tauri::command] +pub async fn kv_get_all(state: State<'_, KvStore>) -> Result, String> { + let conn = state.conn.lock().map_err(|_| "数据库锁错误".to_string())?; + let mut stmt = conn + .prepare("SELECT key, value FROM kv_store") + .map_err(|e| format!("查询键值失败: {}", e))?; + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|e| format!("读取键值失败: {}", e))?; + let mut map = HashMap::new(); + for r in rows { + let (k, v) = r.map_err(|e| format!("读取键值失败: {}", e))?; + map.insert(k, v); + } + Ok(map) +} + +/// 写入一个键值 +#[tauri::command] +pub async fn kv_set(key: String, value: String, state: State<'_, KvStore>) -> Result<(), String> { + let conn = state.conn.lock().map_err(|_| "数据库锁错误".to_string())?; + conn.execute( + "INSERT INTO kv_store (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value=?2", + params![key, value], + ) + .map_err(|e| format!("保存键值失败: {}", e))?; + Ok(()) +} + +/// 删除一个键 +#[tauri::command] +pub async fn kv_delete(key: String, state: State<'_, KvStore>) -> Result<(), String> { + let conn = state.conn.lock().map_err(|_| "数据库锁错误".to_string())?; + conn.execute("DELETE FROM kv_store WHERE key = ?1", params![key]) + .map_err(|e| format!("删除键值失败: {}", e))?; + Ok(()) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e4c9576..3f815d4 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -15,10 +15,13 @@ mod example; mod execution; mod filesystem; mod font; +mod kv; mod logger; mod plugin; mod plugins; mod setup; +mod snippets; +mod terminal; mod update; mod utils; @@ -52,8 +55,13 @@ use crate::filesystem::{ 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::plugin::{get_info, get_supported_languages}; use crate::setup::app::get_app_info; +use crate::snippets::{Snippets, delete_snippet, get_snippets, save_snippet}; +use crate::terminal::{ + TerminalState, terminal_create, terminal_kill, terminal_resize, terminal_write, +}; use crate::utils::logger::{ clear_logs, get_log_directory, get_log_files, reset_log_directory, set_log_directory, }; @@ -84,6 +92,9 @@ fn main() { .plugin(tauri_plugin_fs::init()) .manage(ExecutionHistory::new().expect("failed to initialize execution history database")) .manage(AiHistory::new().expect("failed to initialize ai history database")) + .manage(Snippets::new().expect("failed to initialize snippets database")) + .manage(KvStore::new().expect("failed to initialize kv store database")) + .manage(TerminalState::new()) .manage(ExecutionPluginManagerState::new(PluginManager::new())) .manage(EnvironmentManagerState::new(env_manager)) .setup(|app| { @@ -198,7 +209,20 @@ fn main() { save_ai_conversation, list_ai_conversation_ids, get_ai_conversation, - delete_ai_conversation + delete_ai_conversation, + // 代码片段 + get_snippets, + save_snippet, + delete_snippet, + // 通用键值存储(替代 localStorage) + kv_get_all, + kv_set, + kv_delete, + // 集成终端 + terminal_create, + terminal_write, + terminal_resize, + terminal_kill ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/plugins/json.rs b/src-tauri/src/plugins/json.rs new file mode 100644 index 0000000..e228d3a --- /dev/null +++ b/src-tauri/src/plugins/json.rs @@ -0,0 +1,54 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct JsonPlugin; + +impl LanguagePlugin for JsonPlugin { + fn get_order(&self) -> i32 { + 23 + } + + fn get_language_name(&self) -> &'static str { + "JSON" + } + + fn get_language_key(&self) -> &'static str { + "json" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "json".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--"] + } + + fn get_path_command(&self) -> String { + "--".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("json"), + before_compile: None, + extension: String::from("json"), + execute_home: None, + run_command: Some(String::from("cat $filename")), + after_compile: None, + template: Some(String::from("{\n \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(|| "cat".to_string()) + } +} diff --git a/src-tauri/src/plugins/manager.rs b/src-tauri/src/plugins/manager.rs index 9511366..374d7d9 100644 --- a/src-tauri/src/plugins/manager.rs +++ b/src-tauri/src/plugins/manager.rs @@ -14,8 +14,10 @@ use crate::plugins::java::JavaPlugin; 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::kotlin::KotlinPlugin; use crate::plugins::lua::LuaPlugin; +use crate::plugins::markdown::MarkdownPlugin; use crate::plugins::nodejs::NodeJSPlugin; use crate::plugins::objective_c::ObjectiveCPlugin; use crate::plugins::objective_cpp::ObjectiveCppPlugin; @@ -29,9 +31,12 @@ use crate::plugins::scala::ScalaPlugin; use crate::plugins::shell::ShellPlugin; use crate::plugins::svg::SvgPlugin; use crate::plugins::swift::SwiftPlugin; +use crate::plugins::text::TextPlugin; use crate::plugins::typescript::TypeScriptPlugin; use crate::plugins::typescript_browser::TypeScriptBrowserPlugin; use crate::plugins::typescript_nodejs::TypeScriptNodeJsPlugin; +use crate::plugins::xml::XmlPlugin; +use crate::plugins::yaml::YamlPlugin; use std::collections::HashMap; pub struct PluginManager { @@ -65,6 +70,11 @@ impl PluginManager { ("html".to_string(), Box::new(HtmlPlugin)), ("css".to_string(), Box::new(CssPlugin)), ("svg".to_string(), Box::new(SvgPlugin)), + ("json".to_string(), Box::new(JsonPlugin)), + ("xml".to_string(), Box::new(XmlPlugin)), + ("yaml".to_string(), Box::new(YamlPlugin)), + ("markdown".to_string(), Box::new(MarkdownPlugin)), + ("text".to_string(), Box::new(TextPlugin)), ("php".to_string(), Box::new(PHPPlugin)), ("r".to_string(), Box::new(RPlugin)), ("cangjie".to_string(), Box::new(CangjiePlugin)), diff --git a/src-tauri/src/plugins/markdown.rs b/src-tauri/src/plugins/markdown.rs new file mode 100644 index 0000000..6336c8c --- /dev/null +++ b/src-tauri/src/plugins/markdown.rs @@ -0,0 +1,54 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct MarkdownPlugin; + +impl LanguagePlugin for MarkdownPlugin { + fn get_order(&self) -> i32 { + 26 + } + + fn get_language_name(&self) -> &'static str { + "Markdown" + } + + fn get_language_key(&self) -> &'static str { + "markdown" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "md".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--"] + } + + fn get_path_command(&self) -> String { + "--".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("markdown"), + before_compile: None, + extension: String::from("md"), + execute_home: None, + run_command: Some(String::from("cat $filename")), + after_compile: None, + template: Some(String::from("# 标题\n\n在这里输入 Markdown 内容\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(|| "cat".to_string()) + } +} diff --git a/src-tauri/src/plugins/mod.rs b/src-tauri/src/plugins/mod.rs index 012b091..eed10ee 100644 --- a/src-tauri/src/plugins/mod.rs +++ b/src-tauri/src/plugins/mod.rs @@ -413,9 +413,11 @@ pub mod java; pub mod javascript_browser; pub mod javascript_jquery; pub mod javascript_nodejs; +pub mod json; pub mod kotlin; pub mod lua; pub mod manager; +pub mod markdown; pub mod nodejs; pub mod objective_c; pub mod objective_cpp; @@ -429,8 +431,11 @@ pub mod scala; pub mod shell; pub mod svg; pub mod swift; +pub mod text; pub mod typescript; pub mod typescript_browser; pub mod typescript_nodejs; +pub mod xml; +pub mod yaml; pub use manager::PluginManager; diff --git a/src-tauri/src/plugins/text.rs b/src-tauri/src/plugins/text.rs new file mode 100644 index 0000000..bff3b20 --- /dev/null +++ b/src-tauri/src/plugins/text.rs @@ -0,0 +1,54 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct TextPlugin; + +impl LanguagePlugin for TextPlugin { + fn get_order(&self) -> i32 { + 27 + } + + fn get_language_name(&self) -> &'static str { + "纯文本" + } + + fn get_language_key(&self) -> &'static str { + "text" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "txt".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--"] + } + + fn get_path_command(&self) -> String { + "--".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("text"), + before_compile: None, + extension: String::from("txt"), + execute_home: None, + run_command: Some(String::from("cat $filename")), + after_compile: None, + template: Some(String::from("")), + 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(|| "cat".to_string()) + } +} diff --git a/src-tauri/src/plugins/xml.rs b/src-tauri/src/plugins/xml.rs new file mode 100644 index 0000000..31e4eeb --- /dev/null +++ b/src-tauri/src/plugins/xml.rs @@ -0,0 +1,54 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct XmlPlugin; + +impl LanguagePlugin for XmlPlugin { + fn get_order(&self) -> i32 { + 24 + } + + fn get_language_name(&self) -> &'static str { + "XML" + } + + fn get_language_key(&self) -> &'static str { + "xml" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "xml".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--"] + } + + fn get_path_command(&self) -> String { + "--".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("xml"), + before_compile: None, + extension: String::from("xml"), + execute_home: None, + run_command: Some(String::from("cat $filename")), + after_compile: None, + template: Some(String::from("\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(|| "cat".to_string()) + } +} diff --git a/src-tauri/src/plugins/yaml.rs b/src-tauri/src/plugins/yaml.rs new file mode 100644 index 0000000..bd04999 --- /dev/null +++ b/src-tauri/src/plugins/yaml.rs @@ -0,0 +1,63 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct YamlPlugin; + +impl LanguagePlugin for YamlPlugin { + fn get_order(&self) -> i32 { + 25 + } + + fn get_language_name(&self) -> &'static str { + "YAML" + } + + fn get_language_key(&self) -> &'static str { + "yaml" + } + + fn get_file_extension(&self) -> String { + // extension 可能是多个(如 "yaml,yml"),文件名取第一个 + self.get_config() + .map(|config| { + config + .extension + .split(',') + .next() + .unwrap_or("yaml") + .trim() + .to_string() + }) + .unwrap_or_else(|| "yaml".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--"] + } + + fn get_path_command(&self) -> String { + "--".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("yaml"), + before_compile: None, + extension: String::from("yaml,yml"), + execute_home: None, + run_command: Some(String::from("cat $filename")), + after_compile: None, + template: Some(String::from("# 在这里输入 YAML 内容\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(|| "cat".to_string()) + } +} diff --git a/src-tauri/src/snippets.rs b/src-tauri/src/snippets.rs new file mode 100644 index 0000000..8056db6 --- /dev/null +++ b/src-tauri/src/snippets.rs @@ -0,0 +1,105 @@ +use crate::execution::get_codeforge_db_path; +use rusqlite::{Connection, params}; +use serde::{Deserialize, Serialize}; +use std::sync::Mutex as StdMutex; +use tauri::State; + +/// 用户代码片段,存于同一个 codeforge.sqlite 库。 +pub struct Snippets { + conn: StdMutex, +} + +#[derive(Serialize, Deserialize)] +pub struct Snippet { + pub id: String, + pub prefix: String, + pub body: String, + #[serde(default)] + pub description: String, + /// 适用语言;'*' 或空表示所有语言 + #[serde(default)] + pub language: String, +} + +impl Snippets { + pub fn new() -> Result { + let db_path = get_codeforge_db_path()?; + let conn = Connection::open(&db_path).map_err(|e| format!("打开数据库失败: {}", e))?; + let _ = conn.pragma_update(None, "journal_mode", "WAL"); + conn.execute( + "CREATE TABLE IF NOT EXISTS snippets ( + id TEXT PRIMARY KEY, + prefix TEXT NOT NULL, + body TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + language TEXT NOT NULL DEFAULT '*', + updated_at INTEGER NOT NULL DEFAULT 0 + )", + [], + ) + .map_err(|e| format!("初始化代码片段表失败: {}", e))?; + + Ok(Self { + conn: StdMutex::new(conn), + }) + } +} + +/// 列出所有代码片段(按前缀排序) +#[tauri::command] +pub async fn get_snippets(state: State<'_, Snippets>) -> Result, String> { + let conn = state.conn.lock().map_err(|_| "数据库锁错误".to_string())?; + let mut stmt = conn + .prepare("SELECT id, prefix, body, description, language FROM snippets ORDER BY prefix") + .map_err(|e| format!("查询代码片段失败: {}", e))?; + let rows = stmt + .query_map([], |row| { + Ok(Snippet { + id: row.get(0)?, + prefix: row.get(1)?, + body: row.get(2)?, + description: row.get(3)?, + language: row.get(4)?, + }) + }) + .map_err(|e| format!("读取代码片段失败: {}", e))?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(|e| format!("读取代码片段失败: {}", e))?); + } + Ok(out) +} + +/// 新增/更新一条代码片段(按 id upsert) +#[tauri::command] +pub async fn save_snippet(snippet: Snippet, state: State<'_, Snippets>) -> Result<(), String> { + let conn = state.conn.lock().map_err(|_| "数据库锁错误".to_string())?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + conn.execute( + "INSERT INTO snippets (id, prefix, body, description, language, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(id) DO UPDATE SET prefix=?2, body=?3, description=?4, language=?5, updated_at=?6", + params![ + snippet.id, + snippet.prefix, + snippet.body, + snippet.description, + snippet.language, + now + ], + ) + .map_err(|e| format!("保存代码片段失败: {}", e))?; + Ok(()) +} + +/// 删除一条代码片段 +#[tauri::command] +pub async fn delete_snippet(id: String, state: State<'_, Snippets>) -> Result<(), String> { + let conn = state.conn.lock().map_err(|_| "数据库锁错误".to_string())?; + conn.execute("DELETE FROM snippets WHERE id = ?1", params![id]) + .map_err(|e| format!("删除代码片段失败: {}", e))?; + Ok(()) +} diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs new file mode 100644 index 0000000..a586ccc --- /dev/null +++ b/src-tauri/src/terminal.rs @@ -0,0 +1,178 @@ +use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system}; +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::sync::Mutex as StdMutex; +use tauri::{AppHandle, Emitter, State}; + +/// 单个终端会话:保留 master(用于改尺寸)与 writer(用于写入) +struct TermSession { + master: Box, + writer: Box, +} + +#[derive(Default)] +pub struct TerminalState { + sessions: StdMutex>, +} + +impl TerminalState { + pub fn new() -> Self { + Self::default() + } +} + +#[derive(Clone, serde::Serialize)] +struct TerminalOutput { + id: String, + data: Vec, +} + +/// 创建一个终端会话:启动 shell,并在后台线程把输出通过事件推给前端。 +#[tauri::command] +pub fn terminal_create( + id: String, + cwd: Option, + cols: u16, + rows: u16, + app: AppHandle, + state: State<'_, TerminalState>, +) -> Result<(), String> { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| format!("打开 PTY 失败: {}", e))?; + + // 选择 shell:优先 $SHELL,其次按平台默认 + let shell = std::env::var("SHELL").unwrap_or_else(|_| { + if cfg!(target_os = "windows") { + "powershell.exe".to_string() + } else { + "/bin/bash".to_string() + } + }); + let mut cmd = CommandBuilder::new(shell); + if let Some(dir) = cwd.as_ref().filter(|d| !d.is_empty()) { + cmd.cwd(dir); + } + cmd.env("TERM", "xterm-256color"); + + let mut child = pair + .slave + .spawn_command(cmd) + .map_err(|e| format!("启动 shell 失败: {}", e))?; + + let mut reader = pair + .master + .try_clone_reader() + .map_err(|e| format!("读取 PTY 失败: {}", e))?; + let writer = pair + .master + .take_writer() + .map_err(|e| format!("写入 PTY 失败: {}", e))?; + + { + let mut sessions = state + .sessions + .lock() + .map_err(|_| "终端状态锁错误".to_string())?; + sessions.insert( + id.clone(), + TermSession { + master: pair.master, + writer, + }, + ); + } + + // 后台读输出 → 事件 + let app_reader = app.clone(); + let read_id = id.clone(); + std::thread::spawn(move || { + let mut buf = [0u8; 4096]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + let _ = app_reader.emit( + "terminal-output", + TerminalOutput { + id: read_id.clone(), + data: buf[..n].to_vec(), + }, + ); + } + Err(_) => break, + } + } + // 进程结束,通知前端 + let _ = app_reader.emit("terminal-exit", read_id.clone()); + }); + + // 等待子进程结束(独立线程,避免僵尸进程) + std::thread::spawn(move || { + let _ = child.wait(); + }); + + Ok(()) +} + +/// 写入终端(用户键入) +#[tauri::command] +pub fn terminal_write( + id: String, + data: String, + state: State<'_, TerminalState>, +) -> Result<(), String> { + let mut sessions = state + .sessions + .lock() + .map_err(|_| "终端状态锁错误".to_string())?; + if let Some(s) = sessions.get_mut(&id) { + s.writer + .write_all(data.as_bytes()) + .map_err(|e| format!("写入终端失败: {}", e))?; + let _ = s.writer.flush(); + } + Ok(()) +} + +/// 调整终端尺寸 +#[tauri::command] +pub fn terminal_resize( + id: String, + cols: u16, + rows: u16, + state: State<'_, TerminalState>, +) -> Result<(), String> { + let sessions = state + .sessions + .lock() + .map_err(|_| "终端状态锁错误".to_string())?; + if let Some(s) = sessions.get(&id) { + s.master + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| format!("调整终端尺寸失败: {}", e))?; + } + Ok(()) +} + +/// 关闭终端会话 +#[tauri::command] +pub fn terminal_kill(id: String, state: State<'_, TerminalState>) -> Result<(), String> { + let mut sessions = state + .sessions + .lock() + .map_err(|_| "终端状态锁错误".to_string())?; + sessions.remove(&id); + Ok(()) +} diff --git a/src/App.vue b/src/App.vue index d750d3d..d60189a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -76,16 +76,28 @@
-
- -

{{ getLanguageDisplayName(currentLanguage) }} 代码编辑器

- +
+ +

{{ getLanguageDisplayName(currentLanguage) }} 代码编辑器

+ + · {{ currentFileName }}
-
+
+ + AI 预测中… + + Tab 接受 · Esc 取消 + 行 {{ cursorInfo.line }}, 列 {{ cursorInfo.col }} + 已选 {{ cursorInfo.selLen }} {{ (code || '').length }} 字符 {{ (code || '').split('\n').length }}
@@ -140,16 +152,28 @@
-
- -

{{ getLanguageDisplayName(currentLanguage) }} 代码编辑器

- +
+ +

{{ getLanguageDisplayName(currentLanguage) }} 代码编辑器

+ + · {{ currentFileName }}
-
+
+ + AI 预测中… + + Tab 接受 · Esc 取消 + 行 {{ cursorInfo.line }}, 列 {{ cursorInfo.col }} + 已选 {{ cursorInfo.selLen }} {{ (code || '').length }} 字符 {{ (code || '').split('\n').length }}
@@ -167,6 +191,14 @@
+ + + @@ -209,6 +241,7 @@ @@ -230,6 +263,9 @@ @go="gotoLine" @close="showOutline = false"/> + + + + + + import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue' import {debounce} from 'lodash-es' -import {ChevronRight, CornerDownRight, Eye, FolderOpen, GitBranch, GitCompare, History, ListTree, Maximize2, Monitor, Moon, PanelBottom, PanelLeft, PanelRight, Play, Plus, Save, Search, Settings as SettingsIcon, Sparkles, Sun, X} from 'lucide-vue-next' +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 {ExecutionResult, LayoutMode, SplitDirection} from './types/app.ts' import AppHeader from './components/AppHeader.vue' import CodeEditor from './components/CodeEditor.vue' @@ -288,6 +334,14 @@ import PreviewPanel from './components/PreviewPanel.vue' import GitPanel from './components/GitPanel.vue' import GoToLine from './components/GoToLine.vue' import Outline from './components/Outline.vue' +import SnippetManager from './components/SnippetManager.vue' +import Terminal from './components/Terminal.vue' +import Breadcrumbs from './components/Breadcrumbs.vue' +import {initSnippets} from './composables/useSnippets' +import {kvGet, kvGetJSON, kvSet, kvSetJSON} from './composables/useKvStore' +import {useAiConfig} from './composables/useAiConfig' +import {setGhost, clearGhostIn, ghostActive} from './editor/aiComplete' +import {cursorInfo} from './editor/cursorInfo' import {computeDiffMarkers, setDiffMarkers} from './editor/diffGutter' import AiAssistant from './components/AiAssistant.vue' import InlineGenerate from './components/InlineGenerate.vue' @@ -430,28 +484,29 @@ const handleCopyPath = async (path: string) => { // ===== 侧栏 / 文件夹 ===== const rootDir = ref(null) -const sidebarVisible = ref(localStorage.getItem('sidebar-visible') === 'true') -const sidebarWidth = ref(Number(localStorage.getItem('sidebar-width')) || 240) +const sidebarVisible = ref(kvGet('sidebar-visible') === 'true') +const sidebarWidth = ref(Number(kvGet('sidebar-width')) || 240) // 最近打开的文件夹 const RECENT_FOLDERS_KEY = 'recent-folders' const LAST_ROOT_KEY = 'last-root-dir' -const loadRecentFolders = (): string[] => { - try { - return JSON.parse(localStorage.getItem(RECENT_FOLDERS_KEY) || '[]') - } - catch { - return [] - } +const recentFolders = ref(kvGetJSON(RECENT_FOLDERS_KEY, [])) + +// 最近打开的文件(用于 Cmd+P 优先展示) +const RECENT_FILES_KEY = 'recent-files' +const recentFiles = ref(kvGetJSON(RECENT_FILES_KEY, [])) +const addRecentFile = (path: string) => { + const list = [path, ...recentFiles.value.filter(p => p !== path)].slice(0, 30) + recentFiles.value = list + kvSetJSON(RECENT_FILES_KEY, list) } -const recentFolders = ref(loadRecentFolders()) // 记住打开的文件夹(去重、置顶、最多 8 个),并记录为上次文件夹 const rememberFolder = (path: string) => { const list = [path, ...recentFolders.value.filter(p => p !== path)].slice(0, 8) recentFolders.value = list - localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(list)) - localStorage.setItem(LAST_ROOT_KEY, path) + kvSetJSON(RECENT_FOLDERS_KEY, list) + kvSet(LAST_ROOT_KEY, path) } const openFolderPath = (path: string) => { @@ -466,7 +521,7 @@ const SESSION_TABS_KEY = 'session-tabs' const persistSession = () => { const paths = editorTabs.value.map(t => t.filePath).filter((p): p is string => !!p) const activePath = editorTabs.value.find(t => t.id === activeTabId.value)?.filePath || null - localStorage.setItem(SESSION_TABS_KEY, JSON.stringify({paths, activePath})) + kvSetJSON(SESSION_TABS_KEY, {paths, activePath}) } // 标签集合/文件/激活项变化时持久化(不含正文编辑,避免频繁写入) @@ -477,13 +532,7 @@ watch( // 启动时恢复上次打开的文件标签(仅已保存且可读的文本文件) const restoreSession = async () => { - let saved: { paths: string[], activePath: string | null } | null = null - try { - saved = JSON.parse(localStorage.getItem(SESSION_TABS_KEY) || 'null') - } - catch { - saved = null - } + const saved = kvGetJSON<{ paths: string[], activePath: string | null } | null>(SESSION_TABS_KEY, null) if (!saved || !saved.paths?.length) { return } @@ -509,7 +558,7 @@ const restoreSession = async () => { } } -watch(sidebarVisible, (v) => localStorage.setItem('sidebar-visible', String(v))) +watch(sidebarVisible, (v) => kvSet('sidebar-visible', String(v))) // 拖拽改变侧栏宽度 let resizeStartX = 0 @@ -523,7 +572,7 @@ const stopSidebarResize = () => { document.removeEventListener('mouseup', stopSidebarResize) document.body.style.userSelect = '' document.body.style.cursor = '' - localStorage.setItem('sidebar-width', String(sidebarWidth.value)) + kvSet('sidebar-width', String(sidebarWidth.value)) } const startSidebarResize = (e: MouseEvent) => { e.preventDefault() @@ -567,6 +616,8 @@ const smartOpen = async (filePath: string) => { return } + addRecentFile(filePath) + // 已打开则切换到对应标签,否则在新标签打开 const existing = editorTabs.value.find(t => t.filePath === filePath) if (existing) { @@ -646,6 +697,39 @@ const generateTests = () => { openAiWithPrompt(`请为下面这段 ${currentLanguage.value} 代码生成单元测试,覆盖主要分支与边界情况,使用该语言常用的测试框架,只输出测试代码:\n\n\`\`\`${currentLanguage.value}\n${snippet}\n\`\`\``) } +// AI 格式化/整理代码:请求 AI 返回整理后的完整代码,再走应用前差异预览确认 +const formatWithAi = async () => { + reloadAiCfg() + if (!aiActive.value.apiKey) { + toast.warning('请先在「设置 → AI」中配置 API Key') + return + } + const src = code.value + if (!src.trim()) { + return + } + toast.info('正在请求 AI 整理代码…') + try { + const res = await invoke('ai_chat', { + provider: aiActive.value.provider, + baseUrl: aiActive.value.baseUrl, + apiKey: aiActive.value.apiKey, + model: aiActive.value.model, + system: '你是代码格式化与整理工具。只输出整理后的完整代码,保持逻辑与行为不变,规范缩进与风格,不要解释、不要使用代码块标记。', + messages: [{role: 'user', content: `语言:${currentLanguage.value}\n请整理/格式化下面的代码:\n${src}`}] + }) + const formatted = cleanCompletion(res) + if (!formatted || formatted === src) { + toast.info('代码无需调整') + return + } + applyAiCode(formatted) + } + catch (error) { + toast.error('格式化失败: ' + error) + } +} + const openAiForExecution = (item: ExecutionResult) => { aiExecutionId.value = item.id ?? null aiErrorContext.value = item.success @@ -654,9 +738,117 @@ const openAiForExecution = (item: ExecutionResult) => { showAi.value = true } -// 把 AI 代码块应用到编辑器(替换当前内容,可撤销) +// ===== AI 代码预测(幽灵补全,Tab 接受)===== +const {active: aiActive, reload: reloadAiCfg} = useAiConfig() +const aiCompletion = ref(kvGet('ai-completion') === 'true') + +const toggleAiCompletion = () => { + aiCompletion.value = !aiCompletion.value + kvSet('ai-completion', String(aiCompletion.value)) + if (!aiCompletion.value) { + clearGhostIn(editorView.value) + toast.info('AI 代码预测已关闭') + return + } + // 开启时若未配置 API Key,明确提示(否则预测会静默不工作) + reloadAiCfg() + if (!aiActive.value.apiKey) { + toast.warning('AI 代码预测已开启,但尚未配置 API Key,请在「设置 → AI」中填写') + } + else { + toast.info('AI 代码预测已开启,停顿打字即可看到灰色补全,Tab 接受') + } +} + +// 清洗补全结果:去掉代码块围栏与开头多余换行 +const cleanCompletion = (raw: string): string => { + let s = raw.replace(/^```[a-z]*\n?/i, '').replace(/```\s*$/i, '') + s = s.replace(/^\n+/, '') + return s +} + +let predictNonce = 0 +let predictInflight = 0 +// 是否正在请求 AI 预测(用于状态提示) +const predicting = ref(false) +const requestPrediction = async () => { + if (!aiCompletion.value || isRunning.value) { + return + } + const view = editorView.value + if (!view) { + return + } + reloadAiCfg() + if (!aiActive.value.apiKey) { + return + } + const sel = view.state.selection.main + if (!sel.empty) { + return + } + const pos = sel.head + const prefix = view.state.sliceDoc(0, pos) + if (!prefix.trim()) { + return + } + const suffix = view.state.sliceDoc(pos) + const nonce = ++predictNonce + predictInflight++ + predicting.value = true + try { + const res = await invoke('ai_chat', { + provider: aiActive.value.provider, + baseUrl: aiActive.value.baseUrl, + apiKey: aiActive.value.apiKey, + model: aiActive.value.model, + system: '你是代码自动补全引擎。只输出应插入在光标处的后续代码,不要解释、不要重复已有代码、不要使用代码块标记。若无合适补全则输出空。', + messages: [{ + role: 'user', + content: `语言:${currentLanguage.value}\n光标前代码:\n${prefix}\n\n光标后代码:\n${suffix}\n\n请仅输出应插入光标处的后续代码:` + }] + }) + // 丢弃过期结果或光标已移动的情况 + if (nonce !== predictNonce) { + return + } + const v = editorView.value + if (!v || v.state.selection.main.head !== pos) { + return + } + const text = cleanCompletion(res) + if (text) { + v.dispatch({effects: setGhost.of({text, pos})}) + } + } + catch { + // 静默失败,不打扰编辑 + } + finally { + predictInflight-- + if (predictInflight === 0) { + predicting.value = false + } + } +} +const requestPredictionDebounced = debounce(requestPrediction, 600) +watch(code, () => { + if (aiCompletion.value) { + requestPredictionDebounced() + } +}) + +// 应用 AI 代码块:先弹出差异预览,确认后再替换(避免直接覆盖) +const applyPreview = ref<{ modified: string } | null>(null) const applyAiCode = (codeText: string) => { - code.value = codeText + applyPreview.value = {modified: codeText} +} +const confirmApplyAi = () => { + if (applyPreview.value) { + code.value = applyPreview.value.modified + applyPreview.value = null + toast.success('已应用 AI 代码') + } } // 当前 CodeMirror view(用于在光标处插入生成的代码) @@ -722,6 +914,35 @@ const openOutline = () => { showOutline.value = true } +// 代码片段管理 +const showSnippets = ref(false) + +// 语言图标缺失时回落到通用文本图标 +const onIconError = (e: Event) => { + const t = e.target as HTMLImageElement + if (!t.src.endsWith('/icons/text.svg')) { + t.src = '/icons/text.svg' + } +} + +// 面包屑点击:在系统文件管理器中显示该路径 +const revealInFinder = (path: string) => { + invoke('reveal_path', {path}).catch((error) => toast.error('打开失败: ' + error)) +} + +// 集成终端:首次打开后保持挂载(保留会话),仅切换显示 +const showTerminal = ref(false) +const terminalMounted = ref(false) +const toggleTerminal = () => { + if (showTerminal.value) { + showTerminal.value = false + } + else { + terminalMounted.value = true + showTerminal.value = true + } +} + const openSearchResult = async (path: string, line: number) => { showSearch.value = false await smartOpen(path) @@ -942,8 +1163,8 @@ const runStdin = ref('') const runEnv = ref('') // 监听模式:保存后自动运行 -const watchMode = ref(localStorage.getItem('watch-mode') === 'true') -watch(watchMode, (v) => localStorage.setItem('watch-mode', String(v))) +const watchMode = ref(kvGet('watch-mode') === 'true') +watch(watchMode, (v) => kvSet('watch-mode', String(v))) // 保存包装:保存后若开启监听模式则自动运行 const handleSave = async () => { @@ -956,14 +1177,8 @@ const handleSave = async () => { // ===== 按文件记忆运行配置(args/stdin/env)===== const RUN_CONFIGS_KEY = 'run-configs' type RunConfig = { args: string, stdin: string, env: string } -const loadRunConfigs = (): Record => { - try { - return JSON.parse(localStorage.getItem(RUN_CONFIGS_KEY) || '{}') - } - catch { - return {} - } -} +const loadRunConfigs = (): Record => + kvGetJSON>(RUN_CONFIGS_KEY, {}) // 把当前输入写入指定文件的配置(全空则删除该条) const saveRunConfig = (path: string) => { const map = loadRunConfigs() @@ -973,7 +1188,7 @@ const saveRunConfig = (path: string) => { else { map[path] = {args: runArgs.value, stdin: runStdin.value, env: runEnv.value} } - localStorage.setItem(RUN_CONFIGS_KEY, JSON.stringify(map)) + kvSetJSON(RUN_CONFIGS_KEY, map) } // 载入指定文件的配置(无则清空) const loadRunConfig = (path: string | null) => { @@ -1167,7 +1382,8 @@ const isOverlayOpen = () => showSettings.value || showAbout.value || showUpdate.value || showHistory.value || showViewer.value || showRunPrompt.value || showQuickOpen.value || showGenerate.value || showSearch.value - || showCommandPalette.value || showDiff.value || showGoToLine.value || showOutline.value + || showCommandPalette.value || showDiff.value || showGoToLine.value || showOutline.value || showSnippets.value + || applyPreview.value != null // 全局快捷键(绑定可在设置中自定义) const {matchAction: matchShortcut, reload: reloadShortcuts, getBinding, formatCombo} = useShortcuts() @@ -1186,7 +1402,8 @@ const shortcutDispatch: Record void> = { open: () => handleOpenFileClick(), newTab: () => handleNewTab(), closeTab: () => handleCloseTab(activeTabId.value), - toggleSidebar: () => toggleSidebar() + toggleSidebar: () => toggleSidebar(), + toggleTerminal: () => toggleTerminal() } // 切换并持久化外观主题 @@ -1208,6 +1425,7 @@ const paletteCommands = computed(() => [ {id: 'run', label: '运行代码', icon: Play, hint: hintOf('run'), run: () => handleRunCode()}, {id: 'runSelection', label: '运行选中片段', icon: Play, hint: hintOf('runSelection'), run: () => runSelection()}, {id: 'watchMode', label: watchMode.value ? '关闭监听模式(保存自动运行)' : '开启监听模式(保存自动运行)', icon: Eye, run: () => { watchMode.value = !watchMode.value }}, + {id: 'aiCompletion', label: aiCompletion.value ? '关闭 AI 代码预测' : '开启 AI 代码预测(Tab 补全)', icon: Sparkles, run: () => toggleAiCompletion()}, {id: 'open', label: '打开文件', icon: FolderOpen, hint: hintOf('open'), run: () => handleOpenFileClick()}, {id: 'openFolder', label: '打开文件夹', icon: FolderOpen, run: () => openFolder()}, {id: 'save', label: '保存文件', icon: Save, hint: hintOf('save'), run: () => saveFile()}, @@ -1217,11 +1435,14 @@ const paletteCommands = computed(() => [ {id: 'quickOpen', label: '快速打开文件', icon: Search, hint: hintOf('quickOpen'), run: () => openQuickOpen()}, {id: 'gotoLine', label: '跳转到行', icon: CornerDownRight, hint: hintOf('gotoLine'), run: () => openGoToLine()}, {id: 'outline', label: '符号大纲', icon: ListTree, hint: hintOf('outline'), run: () => openOutline()}, + {id: 'snippets', label: '管理代码片段', icon: Code2, run: () => { showSnippets.value = true }}, + {id: 'terminal', label: '切换终端', icon: TerminalIcon, hint: hintOf('toggleTerminal'), run: () => toggleTerminal()}, {id: 'searchInFiles', label: '在文件夹内搜索', icon: Search, hint: hintOf('searchInFiles'), run: () => openSearch()}, {id: 'generate', label: 'AI 生成代码', icon: Sparkles, hint: hintOf('generate'), run: () => openGenerate()}, {id: 'showAi', label: 'AI 助手', icon: Sparkles, run: () => handleShowAi()}, {id: 'explainCode', label: 'AI 解释代码(选中或全文)', icon: Sparkles, run: () => explainCode()}, {id: 'generateTests', label: 'AI 生成测试(选中或全文)', icon: Sparkles, run: () => generateTests()}, + {id: 'formatWithAi', label: 'AI 格式化代码', icon: Sparkles, run: () => formatWithAi()}, {id: 'history', label: '执行历史', icon: History, run: () => { showHistory.value = true }}, {id: 'diff', label: '差异对比(当前 vs 已保存)', icon: GitCompare, run: () => openDiff()}, {id: 'preview', label: '实时预览(Markdown / HTML)', icon: Eye, run: () => togglePreview()}, @@ -1260,9 +1481,11 @@ onMounted(async () => { await loadEditorConfig() await initializeEventListeners() consoleType.value = getCurrentConsoleType() + // 从数据库载入代码片段 + initSnippets() // 恢复上次打开的文件夹 - const lastRoot = localStorage.getItem(LAST_ROOT_KEY) + const lastRoot = kvGet(LAST_ROOT_KEY) if (lastRoot) { rootDir.value = lastRoot } diff --git a/src/components/Breadcrumbs.vue b/src/components/Breadcrumbs.vue new file mode 100644 index 0000000..d8cf1f4 --- /dev/null +++ b/src/components/Breadcrumbs.vue @@ -0,0 +1,55 @@ + + + diff --git a/src/components/DiffView.vue b/src/components/DiffView.vue index 6abafc6..5356231 100644 --- a/src/components/DiffView.vue +++ b/src/components/DiffView.vue @@ -1,16 +1,21 @@