From 16f08138440219adaf4d7366e982ce470630b86a Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Fri, 5 Jun 2026 09:13:50 +0800 Subject: [PATCH 01/33] =?UTF-8?q?chore:=20=E7=89=88=E6=9C=AC=E5=8F=B7?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E8=87=B3=2026.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index f52ba8a..d997c93 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codeforge", "private": true, - "version": "26.0.0", + "version": "26.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9c21961..ec51ab3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "CodeForge" -version = "26.0.0" +version = "26.1.0" dependencies = [ "async-trait", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8ffc93d..42c3350 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "CodeForge" -version = "26.0.0" +version = "26.1.0" description = "CodeForge 是一款轻量级、高性能的桌面代码执行器,专为开发者、学生和编程爱好者设计。" authors = ["devlive-community"] edition = "2024" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 23854b3..1b89dd0 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.0.0", + "version": "26.1.0", "identifier": "org.devlive.codeforge", "build": { "beforeDevCommand": "pnpm dev", From b6bd17218359d4e353277328af06f81657eeaaed Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Fri, 5 Jun 2026 09:30:18 +0800 Subject: [PATCH 02/33] =?UTF-8?q?feat:=20=E9=80=89=E4=B8=AD=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=90=8E=20Cmd+K=20=E5=8F=AF=E8=AE=A9=20AI=20?= =?UTF-8?q?=E6=94=B9=E5=86=99=E9=80=89=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.vue | 11 ++++++++--- src/components/InlineGenerate.vue | 19 ++++++++++++++----- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/App.vue b/src/App.vue index be596d6..40ad913 100644 --- a/src/App.vue +++ b/src/App.vue @@ -86,7 +86,7 @@ :line-count="viewerFile.lineCount" :size-bytes="viewerFile.sizeBytes" @close="closeViewer"/> - + @@ -150,7 +150,7 @@ :line-count="viewerFile.lineCount" :size-bytes="viewerFile.sizeBytes" @close="closeViewer"/> - + @@ -557,9 +557,14 @@ const applyAiCode = (codeText: string) => { // 当前 CodeMirror view(用于在光标处插入生成的代码) const editorView = ref(null) -// AI 自然语言生成 +// AI 自然语言生成 / 选区改写 const showGenerate = ref(false) +const generateSelection = ref('') const openGenerate = () => { + const view = editorView.value + generateSelection.value = view + ? view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to) + : '' showGenerate.value = true } diff --git a/src/components/InlineGenerate.vue b/src/components/InlineGenerate.vue index 1275cc9..faf3b9b 100644 --- a/src/components/InlineGenerate.vue +++ b/src/components/InlineGenerate.vue @@ -3,10 +3,11 @@
+ 改写选中 @@ -41,18 +42,19 @@ diff --git a/src/composables/useShortcuts.ts b/src/composables/useShortcuts.ts index 09e54b9..1605bda 100644 --- a/src/composables/useShortcuts.ts +++ b/src/composables/useShortcuts.ts @@ -14,6 +14,7 @@ export const SHORTCUT_ACTIONS: ShortcutAction[] = [ {id: 'saveAs', label: '另存为', default: 'Mod+Shift+S'}, {id: 'open', label: '打开文件', default: 'Mod+O'}, {id: 'quickOpen', label: '快速打开文件', default: 'Mod+P'}, + {id: 'searchInFiles', label: '文件夹内搜索', default: 'Mod+Shift+F'}, {id: 'generate', label: 'AI 生成代码', default: 'Mod+K'}, {id: 'newTab', label: '新建标签', default: 'Mod+N'}, {id: 'closeTab', label: '关闭标签', default: 'Mod+W'}, From 581bc705c7a637774edca805799754c001ab5767 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Fri, 5 Jun 2026 10:46:56 +0800 Subject: [PATCH 04/33] =?UTF-8?q?fix:=20=E5=85=A8=E5=B1=80=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E8=B7=B3=E8=BF=87=E7=AC=A6=E5=8F=B7=E9=93=BE=E6=8E=A5?= =?UTF-8?q?=E5=B9=B6=E5=8A=A0=E6=89=AB=E6=8F=8F=E4=B8=8A=E9=99=90=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E5=8D=A1=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/filesystem.rs | 36 +++++++++++++++++++++++++++++----- src/components/SearchPanel.vue | 3 ++- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/filesystem.rs b/src-tauri/src/filesystem.rs index 716e2d9..29514eb 100644 --- a/src-tauri/src/filesystem.rs +++ b/src-tauri/src/filesystem.rs @@ -78,18 +78,26 @@ pub fn list_files(path: String) -> Result, String> { Err(_) => continue, }; for entry in read.flatten() { + // 用 file_type 不跟随符号链接,避免软链成环导致无限递归 + let ft = match entry.file_type() { + Ok(t) => t, + Err(_) => continue, + }; + if ft.is_symlink() { + continue; + } let name = entry.file_name().to_string_lossy().to_string(); if name == ".DS_Store" { continue; } let p = entry.path(); - if p.is_dir() { + if ft.is_dir() { // 跳过隐藏目录与常见重目录 if name.starts_with('.') || ignore.contains(&name.as_str()) { continue; } stack.push(p); - } else { + } else if ft.is_file() { files.push(p.to_string_lossy().to_string()); if files.len() >= MAX_LIST_FILES { break; @@ -110,6 +118,8 @@ pub struct SearchMatch { const MAX_SEARCH_MATCHES: usize = 1000; const MAX_SEARCH_FILE_SIZE: u64 = 2 * 1024 * 1024; +// 最多扫描的文件数,避免在超大目录中卡死 +const MAX_SEARCH_FILES_SCANNED: usize = 50000; /// 在文件夹内全局搜索文本(大小写不敏感的子串),返回匹配的文件/行号/行内容。 #[tauri::command] @@ -125,6 +135,7 @@ pub fn search_in_files(root: String, query: String) -> Result, let ignore = ["node_modules", "target", "dist", "build", ".next", ".cache"]; let mut matches: Vec = Vec::new(); + let mut scanned: usize = 0; let mut stack = vec![root_path.to_path_buf()]; 'outer: while let Some(dir) = stack.pop() { @@ -133,23 +144,38 @@ pub fn search_in_files(root: String, query: String) -> Result, Err(_) => continue, }; for entry in read.flatten() { - if matches.len() >= MAX_SEARCH_MATCHES { + if matches.len() >= MAX_SEARCH_MATCHES || scanned >= MAX_SEARCH_FILES_SCANNED { break 'outer; } + // 用 file_type 不跟随符号链接,避免软链成环导致无限递归 + let ft = match entry.file_type() { + Ok(t) => t, + Err(_) => continue, + }; + if ft.is_symlink() { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); if name == ".DS_Store" { continue; } let p = entry.path(); - if p.is_dir() { + + if ft.is_dir() { if name.starts_with('.') || ignore.contains(&name.as_str()) { continue; } stack.push(p); continue; } + if !ft.is_file() { + continue; + } + + scanned += 1; // 跳过过大文件 - if let Ok(meta) = fs::metadata(&p) { + if let Ok(meta) = entry.metadata() { if meta.len() > MAX_SEARCH_FILE_SIZE { continue; } diff --git a/src/components/SearchPanel.vue b/src/components/SearchPanel.vue index 48dcddc..3e27a8c 100644 --- a/src/components/SearchPanel.vue +++ b/src/components/SearchPanel.vue @@ -79,7 +79,8 @@ const groups = computed(() => { let timer: any = null const search = async () => { const q = query.value.trim() - if (!q) { + // 至少 2 个字符,避免在大目录上做无意义的重搜索 + if (q.length < 2) { results.value = [] return } From 169b2d335416631b6744cf947ff21d7f6d359a14 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Fri, 5 Jun 2026 10:53:10 +0800 Subject: [PATCH 05/33] =?UTF-8?q?fix:=20=E6=90=9C=E7=B4=A2/=E5=88=97?= =?UTF-8?q?=E6=96=87=E4=BB=B6/=E5=A4=A7=E6=96=87=E4=BB=B6=E5=85=83?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E6=94=B9=E4=B8=BA=E5=BC=82=E6=AD=A5=E9=98=BB?= =?UTF-8?q?=E5=A1=9E=E7=BA=BF=E7=A8=8B=EF=BC=8C=E9=81=BF=E5=85=8D=E9=98=BB?= =?UTF-8?q?=E5=A1=9E=E4=B8=BB=E7=BA=BF=E7=A8=8B=E5=8D=A1=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/filesystem.rs | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/filesystem.rs b/src-tauri/src/filesystem.rs index 29514eb..19ba004 100644 --- a/src-tauri/src/filesystem.rs +++ b/src-tauri/src/filesystem.rs @@ -58,8 +58,15 @@ const DEFAULT_MAX_FILE_SIZE_MB: u64 = 5; const MAX_LIST_FILES: usize = 20000; /// 递归列出目录下所有文件(用于 Cmd+P 快速打开)。跳过隐藏目录与常见重目录。 +/// 重 I/O 放到阻塞线程池,避免阻塞主线程。 #[tauri::command] -pub fn list_files(path: String) -> Result, String> { +pub async fn list_files(path: String) -> Result, String> { + tokio::task::spawn_blocking(move || run_list_files(path)) + .await + .map_err(|e| format!("列文件任务失败: {}", e))? +} + +fn run_list_files(path: String) -> Result, String> { let root = Path::new(&path); if !root.is_dir() { return Err(format!("不是有效目录: {}", path)); @@ -121,9 +128,16 @@ const MAX_SEARCH_FILE_SIZE: u64 = 2 * 1024 * 1024; // 最多扫描的文件数,避免在超大目录中卡死 const MAX_SEARCH_FILES_SCANNED: usize = 50000; -/// 在文件夹内全局搜索文本(大小写不敏感的子串),返回匹配的文件/行号/行内容。 +/// 在文件夹内全局搜索文本(大小写不敏感的子串)。 +/// 重 I/O 放到阻塞线程池,避免阻塞主线程导致应用无响应。 #[tauri::command] -pub fn search_in_files(root: String, query: String) -> Result, String> { +pub async fn search_in_files(root: String, query: String) -> Result, String> { + tokio::task::spawn_blocking(move || run_search(root, query)) + .await + .map_err(|e| format!("搜索任务失败: {}", e))? +} + +fn run_search(root: String, query: String) -> Result, String> { let q = query.trim().to_lowercase(); if q.is_empty() { return Ok(vec![]); @@ -421,13 +435,18 @@ fn with_index(path: &str, f: impl FnOnce(&FileIndex) -> T) -> Result Result { - with_index(&path, |idx| TextFileMeta { - size_bytes: idx.size, - line_count: idx.line_count, - is_text: idx.is_text, +pub async fn get_text_file_meta(path: String) -> Result { + tokio::task::spawn_blocking(move || { + with_index(&path, |idx| TextFileMeta { + size_bytes: idx.size, + line_count: idx.line_count, + is_text: idx.is_text, + }) }) + .await + .map_err(|e| format!("读取文件信息任务失败: {}", e))? } /// 按行范围读取文件(只读查看器虚拟滚动用)。借助行偏移索引随机定位,做到 O(窗口)。 From 03c2e2bdebb7cb7d05677828f33be8ce8dade8c3 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Fri, 5 Jun 2026 10:54:37 +0800 Subject: [PATCH 06/33] =?UTF-8?q?fix:=20=E5=87=8F=E5=B0=8F=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E7=AA=97=E5=8F=A3=E5=B0=BA=E5=AF=B8=E8=87=B3=201280x8?= =?UTF-8?q?00=EF=BC=8C=E9=81=BF=E5=85=8D=E5=9C=A8=E5=B0=8F=E5=B1=8F?= =?UTF-8?q?=E5=B9=95=E4=B8=8A=E8=B6=85=E5=87=BA=E5=8F=AF=E8=A7=86=E8=8C=83?= =?UTF-8?q?=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/tauri.conf.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1b89dd0..405ce35 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -15,8 +15,8 @@ "title": "CodeForge", "minWidth": 1000, "minHeight": 600, - "width": 1800, - "height": 1200, + "width": 1280, + "height": 800, "center": true, "devtools": true, "dragDropEnabled": false, From 76d83acf69f4a44166b372b6b055a216987bc6e4 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Fri, 5 Jun 2026 11:01:08 +0800 Subject: [PATCH 07/33] =?UTF-8?q?feat:=20=E8=BF=90=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A=E4=B9=89=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/execution.rs | 5 +++ src-tauri/src/plugins/mod.rs | 2 + src/App.vue | 59 ++++++++++++++++++++--------- src/composables/useCodeExecution.ts | 6 ++- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/execution.rs b/src-tauri/src/execution.rs index 54fa985..bf72c6b 100644 --- a/src-tauri/src/execution.rs +++ b/src-tauri/src/execution.rs @@ -353,6 +353,11 @@ pub async fn execute_code( command.stdin(Stdio::null()); } + // 自定义环境变量 + if let Some(env) = &request.env { + command.envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str()))); + } + // 设置工作目录(就地运行为文件目录,否则为插件 execute_home) if let Some(dir) = &cwd { command.current_dir(dir); diff --git a/src-tauri/src/plugins/mod.rs b/src-tauri/src/plugins/mod.rs index 4853b22..012b091 100644 --- a/src-tauri/src/plugins/mod.rs +++ b/src-tauri/src/plugins/mod.rs @@ -31,6 +31,8 @@ pub struct CodeExecutionRequest { pub stdin: Option, // 追加到运行命令后的参数 pub args: Option>, + // 运行时的环境变量 + pub env: Option>, } #[derive(Debug, Serialize, Deserialize)] diff --git a/src/App.vue b/src/App.vue index 36c3ab3..1e32f86 100644 --- a/src/App.vue +++ b/src/App.vue @@ -23,17 +23,23 @@
-
-
- - +
+
+
+ + +
+
+ + +
-
- - +
+ +
@@ -685,18 +691,37 @@ const handleLayoutChange = (mode: LayoutMode) => { } } -// 运行输入:参数 + stdin +// 运行输入:参数 + stdin + 环境变量 const showRunInput = ref(false) const runArgs = ref('') const runStdin = ref('') +const runEnv = ref('') + +// 解析环境变量文本(KEY=值,按换行或分号分隔) +const parseEnv = (text: string): Record => { + const env: Record = {} + for (const part of text.split(/[\n;]/)) { + const seg = part.trim() + if (!seg) continue + const eq = seg.indexOf('=') + if (eq > 0) { + env[seg.slice(0, eq).trim()] = seg.slice(eq + 1).trim() + } + } + return env +} -const buildRunBase = () => ({ - language: currentLanguage.value, - envInstalled: envInfo.value.installed, - envLanguage: envInfo.value.language, - args: runArgs.value.trim() ? runArgs.value.trim().split(/\s+/) : undefined, - stdin: runStdin.value || undefined -}) +const buildRunBase = () => { + const env = parseEnv(runEnv.value) + return { + language: currentLanguage.value, + envInstalled: envInfo.value.installed, + envLanguage: envInfo.value.language, + args: runArgs.value.trim() ? runArgs.value.trim().split(/\s+/) : undefined, + stdin: runStdin.value || undefined, + env: Object.keys(env).length ? env : undefined + } +} // 运行未保存文件的询问弹窗 const showRunPrompt = ref(false) diff --git a/src/composables/useCodeExecution.ts b/src/composables/useCodeExecution.ts index 41b65d7..d1e9928 100644 --- a/src/composables/useCodeExecution.ts +++ b/src/composables/useCodeExecution.ts @@ -27,10 +27,11 @@ export function useCodeExecution(toast: any) filePath?: string | null args?: string[] stdin?: string + env?: Record } const runCode = async (options: RunOptions) => { - const {language, envInstalled, envLanguage, filePath, args, stdin} = options + const {language, envInstalled, envLanguage, filePath, args, stdin, env} = options if (!envInstalled) { toast.error(`${ envLanguage } 环境未安装`) return @@ -57,7 +58,8 @@ export function useCodeExecution(toast: any) task_id: taskId, file_path: filePath || null, args: args && args.length ? args : null, - stdin: stdin ? stdin : null + stdin: stdin ? stdin : null, + env: env && Object.keys(env).length ? env : null } }) From 621a86e57bf0dd27f5ec459bfdd5013460aa195c Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Fri, 5 Jun 2026 11:07:29 +0800 Subject: [PATCH 08/33] =?UTF-8?q?feat:=20=E6=89=A7=E8=A1=8C=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E6=94=AF=E6=8C=81=E4=B8=80=E9=94=AE=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E8=BF=90=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.vue | 11 +++++++++++ src/components/ExecutionHistory.vue | 16 ++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/App.vue b/src/App.vue index 1e32f86..7fed3b1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -178,6 +178,7 @@ @@ -799,6 +800,16 @@ const restoreHistoryItem = (item: ExecutionResult) => { toast.success('已恢复历史代码') } +// 从执行历史一键重跑:恢复语言与代码后直接运行 +const rerunHistoryItem = async (item: ExecutionResult) => { + applyLanguage(item.language) + code.value = item.code || '' + resetFile() + // 确保环境信息已更新为该语言再运行 + await refreshEnvInfo() + handleRunCode() +} + // 监听编辑器配置变化 watch(editorConfig, (newConfig) => { if (newConfig) { diff --git a/src/components/ExecutionHistory.vue b/src/components/ExecutionHistory.vue index 9674394..70aeebe 100644 --- a/src/components/ExecutionHistory.vue +++ b/src/components/ExecutionHistory.vue @@ -83,9 +83,12 @@ - +
@@ -133,7 +136,7 @@ From e052330e4b4b8e52c63928558f7ad3ff0e3931fb Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Fri, 5 Jun 2026 15:20:40 +0800 Subject: [PATCH 21/33] =?UTF-8?q?feat:=20Git=20=E6=BA=90=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86=EF=BC=88=E7=8A=B6=E6=80=81/=E6=9A=82?= =?UTF-8?q?=E5=AD=98/=E6=8F=90=E4=BA=A4/=E6=8E=A8=E9=80=81/=E5=88=86?= =?UTF-8?q?=E6=94=AF=EF=BC=89=E4=B8=8E=E6=96=87=E4=BB=B6=E6=A0=91=E5=BE=BD?= =?UTF-8?q?=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Rust 命令:git_status/git_stage/git_unstage/git_commit/git_push/git_branches/git_checkout(均 spawn_blocking) - GitPanel:分支切换、暂存区/工作区改动分组、逐文件或全部暂存、提交、提交并推送、领先/落后提示 - 文件树按 Git 状态着色并显示 M/A/D/U 徽标 - 接入命令面板;打开文件夹/保存后自动刷新徽标 注:编辑器行内 gutter 级 diff 标记较复杂,留作后续迭代 --- src-tauri/src/filesystem.rs | 182 ++++++++++++++++++++++++ src-tauri/src/main.rs | 8 ++ src/App.vue | 53 ++++++- src/components/FileTreeNode.vue | 16 ++- src/components/GitPanel.vue | 236 ++++++++++++++++++++++++++++++++ src/components/Sidebar.vue | 3 + 6 files changed, 496 insertions(+), 2 deletions(-) create mode 100644 src/components/GitPanel.vue diff --git a/src-tauri/src/filesystem.rs b/src-tauri/src/filesystem.rs index ae10ba0..b66b1fb 100644 --- a/src-tauri/src/filesystem.rs +++ b/src-tauri/src/filesystem.rs @@ -469,6 +469,188 @@ pub async fn git_diff(root: String) -> Result { .map_err(|e| format!("git 任务失败: {}", e))? } +// ===== Git 源代码管理 ===== + +/// 同步执行 git 子命令,返回标准输出;失败时返回 stderr。 +fn run_git(root: &str, args: &[&str]) -> Result { + let mut full: Vec<&str> = vec!["-C", root]; + full.extend_from_slice(args); + let output = std::process::Command::new("git") + .args(&full) + .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()) +} + +#[derive(Serialize)] +pub struct GitFileStatus { + /// 相对仓库根的路径 + path: String, + /// 暂存区状态字符(X) + index: String, + /// 工作区状态字符(Y) + worktree: String, +} + +#[derive(Serialize)] +pub struct GitStatus { + is_repo: bool, + branch: String, + ahead: u32, + behind: u32, + files: Vec, +} + +/// 获取 git 状态(分支、领先/落后、各文件暂存/工作区状态)。 +#[tauri::command] +pub async fn git_status(root: String) -> Result { + tokio::task::spawn_blocking(move || { + // 先确认是否在 git 仓库中 + if run_git(&root, &["rev-parse", "--is-inside-work-tree"]).is_err() { + return Ok(GitStatus { + is_repo: false, + branch: String::new(), + ahead: 0, + behind: 0, + files: vec![], + }); + } + + let out = run_git(&root, &["status", "--porcelain", "--branch"])?; + let mut branch = String::new(); + let mut ahead = 0u32; + let mut behind = 0u32; + let mut files = Vec::new(); + + for line in out.lines() { + if let Some(rest) = line.strip_prefix("## ") { + // 形如:main...origin/main [ahead 1, behind 2] + let name_part = rest.split("...").next().unwrap_or(rest); + branch = name_part.trim().to_string(); + if let Some(start) = rest.find('[') { + let bracket = &rest[start + 1..rest.find(']').unwrap_or(rest.len())]; + for seg in bracket.split(',') { + let seg = seg.trim(); + if let Some(n) = seg.strip_prefix("ahead ") { + ahead = n.trim().parse().unwrap_or(0); + } + else if let Some(n) = seg.strip_prefix("behind ") { + behind = n.trim().parse().unwrap_or(0); + } + } + } + continue; + } + if line.len() < 3 { + continue; + } + let index = &line[0..1]; + let worktree = &line[1..2]; + let mut path = line[3..].to_string(); + // 重命名形如 "old -> new",取新路径 + if let Some(pos) = path.find(" -> ") { + path = path[pos + 4..].to_string(); + } + // 去除可能的引号包裹 + let path = path.trim_matches('"').to_string(); + files.push(GitFileStatus { + path, + index: index.to_string(), + worktree: worktree.to_string(), + }); + } + + Ok(GitStatus { + is_repo: true, + branch, + ahead, + behind, + files, + }) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 暂存指定文件(相对路径或绝对路径均可)。 +#[tauri::command] +pub async fn git_stage(root: String, paths: Vec) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + let mut args = vec!["add", "--"]; + 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> { + tokio::task::spawn_blocking(move || { + let mut args = vec!["reset", "-q", "HEAD", "--"]; + 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_commit(root: String, message: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["commit", "-m", &message])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 推送当前分支。 +#[tauri::command] +pub async fn git_push(root: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["push"])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +#[derive(Serialize)] +pub struct GitBranches { + current: String, + branches: Vec, +} + +/// 列出本地分支与当前分支。 +#[tauri::command] +pub async fn git_branches(root: String) -> Result { + tokio::task::spawn_blocking(move || { + let current = run_git(&root, &["rev-parse", "--abbrev-ref", "HEAD"])? + .trim() + .to_string(); + let out = run_git(&root, &["branch", "--format=%(refname:short)"])?; + let branches = out + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + Ok(GitBranches { current, branches }) + }) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + +/// 切换分支。 +#[tauri::command] +pub async fn git_checkout(root: String, branch: String) -> Result { + tokio::task::spawn_blocking(move || run_git(&root, &["checkout", &branch])) + .await + .map_err(|e| format!("git 任务失败: {}", e))? +} + /// 在系统文件管理器中显示该路径 #[tauri::command] pub fn reveal_path(path: String) -> Result<(), String> { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e1aab26..649f66a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -48,6 +48,7 @@ use crate::execution::{ }; use crate::filesystem::{ create_directory, create_file, delete_path, get_text_file_meta, git_diff, list_files, + git_branches, git_checkout, git_commit, git_push, git_stage, git_status, git_unstage, read_directory_tree, read_file_lines, read_file_text, rename_path, replace_in_files, reveal_path, search_in_files, watch_directory, write_file_text, }; @@ -181,6 +182,13 @@ fn main() { search_in_files, replace_in_files, git_diff, + git_status, + git_stage, + git_unstage, + git_commit, + git_push, + git_branches, + git_checkout, // AI 助手 ai_chat, ai_chat_stream, diff --git a/src/App.vue b/src/App.vue index bdc8bda..79099c0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -50,6 +50,7 @@ + + +
@@ -234,7 +242,7 @@ diff --git a/src/components/Sidebar.vue b/src/components/Sidebar.vue index 1c27557..967c607 100644 --- a/src/components/Sidebar.vue +++ b/src/components/Sidebar.vue @@ -112,6 +112,7 @@ const props = defineProps<{ rootDir: string | null activePath?: string | null recentFolders?: string[] + gitStatus?: Record }>() const emit = defineEmits<{ @@ -138,6 +139,8 @@ const toast = useToast() provide('treeOpenFile', (path: string) => emit('open-file', path)) // 当前激活文件路径(用于文件树高亮选中项) provide('treeActivePath', computed(() => props.activePath ?? null)) +// Git 状态映射(绝对路径 → 状态字母),供文件树徽标 +provide('treeGitStatus', computed(() => props.gitStatus ?? {})) const loadRoot = async () => { if (!props.rootDir) { From b93244ebdf3f3af7e729366ea3debf3c1f5e92f8 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Fri, 5 Jun 2026 15:28:02 +0800 Subject: [PATCH 22/33] =?UTF-8?q?feat:=20=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E8=A1=8C=E5=86=85=20Git=20=E5=B7=AE=E5=BC=82=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=EF=BC=88vs=20HEAD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Rust 命令 git_file_head 获取文件 HEAD 版本内容 - diffGutter 扩展:StateField + 自定义 gutter,绿(新增)/琥珀(修改)条与红色三角(删除)标记 - App 按文件取 HEAD 基线,编辑防抖重算并派发标记;提交/切分支后刷新基线 - 隐藏行号时改为仅隐藏行号与折叠列,保留差异标记列;无改动时该列零宽不占位 --- src-tauri/src/filesystem.rs | 29 ++++ src-tauri/src/main.rs | 4 +- src/App.vue | 47 +++++- src/composables/useCodeMirrorEditor.ts | 8 +- src/editor/diffGutter.ts | 189 +++++++++++++++++++++++++ 5 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 src/editor/diffGutter.ts diff --git a/src-tauri/src/filesystem.rs b/src-tauri/src/filesystem.rs index b66b1fb..4d4d268 100644 --- a/src-tauri/src/filesystem.rs +++ b/src-tauri/src/filesystem.rs @@ -651,6 +651,35 @@ pub async fn git_checkout(root: String, branch: String) -> Result Result { + tokio::task::spawn_blocking(move || { + let spec = format!("HEAD:{}", rel_path); + match run_git(&root, &["show", &spec]) { + Ok(content) => GitHeadFile { + exists: true, + content, + }, + // 文件不在 HEAD 中(新增/未跟踪):返回不存在 + Err(_) => GitHeadFile { + exists: false, + content: String::new(), + }, + } + }) + .await + .map_err(|e| format!("git 任务失败: {}", e)) +} + /// 在系统文件管理器中显示该路径 #[tauri::command] pub fn reveal_path(path: String) -> Result<(), String> { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 649f66a..0caa46b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -48,7 +48,8 @@ use crate::execution::{ }; use crate::filesystem::{ create_directory, create_file, delete_path, get_text_file_meta, git_diff, list_files, - git_branches, git_checkout, git_commit, git_push, git_stage, git_status, git_unstage, + git_branches, git_checkout, git_commit, git_file_head, git_push, git_stage, git_status, + git_unstage, read_directory_tree, read_file_lines, read_file_text, rename_path, replace_in_files, reveal_path, search_in_files, watch_directory, write_file_text, }; @@ -189,6 +190,7 @@ fn main() { git_push, git_branches, git_checkout, + git_file_head, // AI 助手 ai_chat, ai_chat_stream, diff --git a/src/App.vue b/src/App.vue index 79099c0..cfc8245 100644 --- a/src/App.vue +++ b/src/App.vue @@ -269,6 +269,7 @@ import CommandPalette, {type PaletteCommand} from './components/CommandPalette.v import DiffView from './components/DiffView.vue' import PreviewPanel from './components/PreviewPanel.vue' import GitPanel from './components/GitPanel.vue' +import {computeDiffMarkers, setDiffMarkers} from './editor/diffGutter' import AiAssistant from './components/AiAssistant.vue' import InlineGenerate from './components/InlineGenerate.vue' import SearchPanel from './components/SearchPanel.vue' @@ -753,9 +754,53 @@ const refreshGitStatus = async () => { catch { gitStatus.value = {} } + // HEAD 可能因提交/切换分支变化,刷新编辑器行内差异基线 + fetchBaseline() } -// 打开文件夹、保存文件后刷新文件树 Git 徽标 +// ===== 编辑器行内差异标记(vs HEAD)===== +// 当前文件在 HEAD 中的内容;null 表示无基线(新文件/非 git/未跟踪),不显示标记 +const gitBaseline = ref(null) + +const fetchBaseline = async () => { + if (!rootDir.value || !currentFilePath.value || !currentFilePath.value.startsWith(rootDir.value)) { + gitBaseline.value = null + applyDiffMarkers() + return + } + const rel = currentFilePath.value.slice(rootDir.value.length + 1) + try { + const head = await invoke<{ exists: boolean, content: string }>('git_file_head', { + root: rootDir.value, + relPath: rel + }) + gitBaseline.value = head.exists ? head.content : null + } + catch { + gitBaseline.value = null + } + applyDiffMarkers() +} + +// 计算并派发标记到编辑器 +const applyDiffMarkers = () => { + const view = editorView.value + if (!view) { + return + } + const markers = gitBaseline.value === null + ? {changed: new Map(), deleted: new Set()} + : computeDiffMarkers(gitBaseline.value, code.value) + view.dispatch({effects: setDiffMarkers.of(markers)}) +} +const applyDiffMarkersDebounced = debounce(applyDiffMarkers, 250) + +// 切换文件取新基线;编辑时重算;编辑器重挂时重新派发 +watch(currentFilePath, () => fetchBaseline()) +watch(code, () => applyDiffMarkersDebounced()) +watch(editorView, () => applyDiffMarkers()) + +// 打开文件夹、保存文件后刷新文件树 Git 徽标与差异基线 watch(rootDir, () => refreshGitStatus(), {immediate: true}) watch(savedContent, () => refreshGitStatus()) diff --git a/src/composables/useCodeMirrorEditor.ts b/src/composables/useCodeMirrorEditor.ts index bb55476..ef986aa 100644 --- a/src/composables/useCodeMirrorEditor.ts +++ b/src/composables/useCodeMirrorEditor.ts @@ -76,6 +76,7 @@ import {useCodeMirrorFunctionHelp} from './useCodeMirrorFunctionHelp' import {useCodeMirrorSpaceOmission} from './useCodeMirrorSpaceOmission.ts' import {EditorView, keymap} from "@codemirror/view"; import {useCodeMirrorFontFamily} from "./useCodeMirrorFontFamily.ts"; +import {diffGutterExtension} from "../editor/diffGutter"; interface Props { @@ -273,12 +274,12 @@ export function useCodeMirrorEditor(props: Props) } } - // 隐藏行号的主题扩展 + // 隐藏行号的主题扩展:仅隐藏行号与折叠列,保留 Git 差异标记列 const hideLineNumbersTheme = EditorView.theme({ '.cm-lineNumbers': { display: 'none !important' }, - '.cm-gutters': { + '.cm-foldGutter': { display: 'none !important' } }) @@ -297,6 +298,9 @@ export function useCodeMirrorEditor(props: Props) // 字体缩放快捷键(搜索/替换、折叠、括号匹配等由 vue-codemirror 的 basicSetup 提供) result.push(fontSizeKeymap) + // Git 行内差异标记(标记数据由外部 dispatch 填充,无 git 时为空) + result.push(diffGutterExtension) + // 设置字体 const {fontFamilyTheme} = useCodeMirrorFontFamily( editorConfig.value?.font_family diff --git a/src/editor/diffGutter.ts b/src/editor/diffGutter.ts new file mode 100644 index 0000000..a329d8b --- /dev/null +++ b/src/editor/diffGutter.ts @@ -0,0 +1,189 @@ +import {gutter, GutterMarker, EditorView} from '@codemirror/view' +import {StateEffect, StateField, RangeSet, RangeSetBuilder} from '@codemirror/state' + +export type LineKind = 'add' | 'mod' + +export interface DiffMarkers +{ + // 当前文档行号(1-based) → 标记类型 + changed: Map + // 在这些行之前发生了删除(显示三角形提示) + deleted: Set +} + +// 设置差异标记的 effect(由外部计算后派发) +export const setDiffMarkers = StateEffect.define() + +class DiffGutterMarker extends GutterMarker +{ + constructor(readonly cls: string) + { + super() + } + + eq(other: DiffGutterMarker) + { + return other.cls === this.cls + } + + toDOM() + { + const el = document.createElement('div') + el.className = this.cls + return el + } +} + +const addMarker = new DiffGutterMarker('cm-diff-add') +const modMarker = new DiffGutterMarker('cm-diff-mod') +const delMarker = new DiffGutterMarker('cm-diff-del') + +// 由 DiffMarkers 构建定位到各行起点的 RangeSet +const buildSet = (state: any, data: DiffMarkers): RangeSet => { + const lineCount = state.doc.lines + // 按行号排序后写入,RangeSetBuilder 要求位置递增 + const entries: { line: number, marker: GutterMarker }[] = [] + for (const [line, kind] of data.changed) { + if (line >= 1 && line <= lineCount) { + entries.push({line, marker: kind === 'add' ? addMarker : modMarker}) + } + } + for (const line of data.deleted) { + if (line >= 1 && line <= lineCount && !data.changed.has(line)) { + entries.push({line, marker: delMarker}) + } + } + entries.sort((a, b) => a.line - b.line) + + const builder = new RangeSetBuilder() + for (const e of entries) { + const from = state.doc.line(e.line).from + builder.add(from, from, e.marker) + } + return builder.finish() +} + +const diffField = StateField.define>({ + create: () => RangeSet.empty, + update(set, tr) { + // 文档变化时先随之平移,保证下次重算前位置不至于错乱 + set = set.map(tr.changes) + for (const e of tr.effects) { + if (e.is(setDiffMarkers)) { + set = buildSet(tr.state, e.value) + } + } + return set + } +}) + +const diffGutterTheme = EditorView.baseTheme({ + '.cm-diff-gutter .cm-gutterElement': { + padding: '0', + }, + '.cm-diff-add, .cm-diff-mod, .cm-diff-del': { + width: '3px', + height: '100%', + marginLeft: '2px', + }, + '.cm-diff-add': {background: '#2ea043'}, + '.cm-diff-mod': {background: '#d29922'}, + // 删除:用红色小三角提示 + '.cm-diff-del': { + background: 'transparent', + width: '0', + height: '0', + marginLeft: '1px', + borderLeft: '4px solid #f85149', + borderTop: '4px solid transparent', + borderBottom: '4px solid transparent', + }, +}) + +const diffGutterView = gutter({ + class: 'cm-diff-gutter', + markers: v => v.state.field(diffField), +}) + +// 编辑器差异标记扩展(默认无标记,由外部 dispatch setDiffMarkers 填充) +export const diffGutterExtension = [diffField, diffGutterView, diffGutterTheme] + +// 基于 LCS 的逐行差异,输出当前文档各行的标记 +export const computeDiffMarkers = (baseline: string, current: string): DiffMarkers => { + const a = baseline.length ? baseline.split('\n') : [] + const b = current.length ? current.split('\n') : [] + const n = a.length, m = b.length + const changed = new Map() + const deleted = new Set() + + // 空基线:全部视为新增 + if (n === 0) { + for (let i = 1; i <= m; i++) { + changed.set(i, 'add') + } + return {changed, deleted} + } + + // 超大文件不做精细 diff,避免 O(n*m) + if (n * m > 4_000_000) { + return {changed, deleted} + } + + const dp: number[][] = Array.from({length: n + 1}, () => new Array(m + 1).fill(0)) + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]) + } + } + + let i = 0, j = 0 + let curLine = 0 + let pendingDel = 0 + const step = (op: 'same' | 'add' | 'del') => { + if (op === 'del') { + pendingDel++ + return + } + if (op === 'add') { + curLine++ + if (pendingDel > 0) { + changed.set(curLine, 'mod') + pendingDel-- + } + else { + changed.set(curLine, 'add') + } + return + } + // same + curLine++ + if (pendingDel > 0) { + deleted.add(curLine) + pendingDel = 0 + } + } + + while (i < n && j < m) { + if (a[i] === b[j]) { + step('same'); i++; j++ + } + else if (dp[i + 1][j] >= dp[i][j + 1]) { + step('del'); i++ + } + else { + step('add'); j++ + } + } + while (i < n) { + step('del'); i++ + } + while (j < m) { + step('add'); j++ + } + // 文件末尾的删除:标记在最后一行 + if (pendingDel > 0) { + deleted.add(curLine > 0 ? curLine : 1) + } + + return {changed, deleted} +} From 55b616da61bbee0e06155a6e5f209e0a24e52b58 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Fri, 5 Jun 2026 15:34:51 +0800 Subject: [PATCH 23/33] =?UTF-8?q?fix:=20=E8=A1=8C=E5=86=85=20Git=20?= =?UTF-8?q?=E5=B7=AE=E5=BC=82=E6=A0=87=E8=AE=B0=E6=94=B9=E7=94=A8=E8=A1=8C?= =?UTF-8?q?=E8=A3=85=E9=A5=B0=EF=BC=8C=E6=B6=88=E9=99=A4=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E7=9A=84=E8=A1=8C=E5=8F=B7=20gutter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前用独立 gutter() 渲染差异条,与 vue-codemirror basicSetup 自带的行号 gutter 叠加导致出现两列行号。改为 Decoration.line 在行左缘绘制彩色竖条(新增绿/修改琥珀)与删除红三角,不新增 gutter 列;hideLineNumbersTheme 恢复原行为 --- src/composables/useCodeMirrorEditor.ts | 4 +- src/editor/diffGutter.ts | 103 ++++++++----------------- 2 files changed, 35 insertions(+), 72 deletions(-) diff --git a/src/composables/useCodeMirrorEditor.ts b/src/composables/useCodeMirrorEditor.ts index ef986aa..0ea0da8 100644 --- a/src/composables/useCodeMirrorEditor.ts +++ b/src/composables/useCodeMirrorEditor.ts @@ -274,12 +274,12 @@ export function useCodeMirrorEditor(props: Props) } } - // 隐藏行号的主题扩展:仅隐藏行号与折叠列,保留 Git 差异标记列 + // 隐藏行号的主题扩展 const hideLineNumbersTheme = EditorView.theme({ '.cm-lineNumbers': { display: 'none !important' }, - '.cm-foldGutter': { + '.cm-gutters': { display: 'none !important' } }) diff --git a/src/editor/diffGutter.ts b/src/editor/diffGutter.ts index a329d8b..775714d 100644 --- a/src/editor/diffGutter.ts +++ b/src/editor/diffGutter.ts @@ -1,5 +1,5 @@ -import {gutter, GutterMarker, EditorView} from '@codemirror/view' -import {StateEffect, StateField, RangeSet, RangeSetBuilder} from '@codemirror/state' +import {Decoration, DecorationSet, EditorView} from '@codemirror/view' +import {StateEffect, StateField, RangeSetBuilder} from '@codemirror/state' export type LineKind = 'add' | 'mod' @@ -7,66 +7,38 @@ export interface DiffMarkers { // 当前文档行号(1-based) → 标记类型 changed: Map - // 在这些行之前发生了删除(显示三角形提示) + // 在这些行之前发生了删除(在行首显示红色小三角) deleted: Set } // 设置差异标记的 effect(由外部计算后派发) export const setDiffMarkers = StateEffect.define() -class DiffGutterMarker extends GutterMarker -{ - constructor(readonly cls: string) - { - super() - } +// 行装饰:在行左缘绘制彩色竖条 / 删除三角(不新增 gutter 列,避免与行号列冲突) +const addLine = Decoration.line({class: 'cm-diff-add'}) +const modLine = Decoration.line({class: 'cm-diff-mod'}) +const delLine = Decoration.line({class: 'cm-diff-del'}) - eq(other: DiffGutterMarker) - { - return other.cls === this.cls - } - - toDOM() - { - const el = document.createElement('div') - el.className = this.cls - return el - } -} - -const addMarker = new DiffGutterMarker('cm-diff-add') -const modMarker = new DiffGutterMarker('cm-diff-mod') -const delMarker = new DiffGutterMarker('cm-diff-del') - -// 由 DiffMarkers 构建定位到各行起点的 RangeSet -const buildSet = (state: any, data: DiffMarkers): RangeSet => { +const buildSet = (state: any, data: DiffMarkers): DecorationSet => { const lineCount = state.doc.lines - // 按行号排序后写入,RangeSetBuilder 要求位置递增 - const entries: { line: number, marker: GutterMarker }[] = [] - for (const [line, kind] of data.changed) { - if (line >= 1 && line <= lineCount) { - entries.push({line, marker: kind === 'add' ? addMarker : modMarker}) + const builder = new RangeSetBuilder() + // 按行号升序写入,满足 RangeSetBuilder 的递增要求 + for (let n = 1; n <= lineCount; n++) { + const from = state.doc.line(n).from + if (data.changed.has(n)) { + builder.add(from, from, data.changed.get(n) === 'add' ? addLine : modLine) } - } - for (const line of data.deleted) { - if (line >= 1 && line <= lineCount && !data.changed.has(line)) { - entries.push({line, marker: delMarker}) + else if (data.deleted.has(n)) { + builder.add(from, from, delLine) } } - entries.sort((a, b) => a.line - b.line) - - const builder = new RangeSetBuilder() - for (const e of entries) { - const from = state.doc.line(e.line).from - builder.add(from, from, e.marker) - } return builder.finish() } -const diffField = StateField.define>({ - create: () => RangeSet.empty, +const diffField = StateField.define({ + create: () => Decoration.none, update(set, tr) { - // 文档变化时先随之平移,保证下次重算前位置不至于错乱 + // 文档变化时随之平移,下一次重算前位置不至错乱 set = set.map(tr.changes) for (const e of tr.effects) { if (e.is(setDiffMarkers)) { @@ -74,39 +46,30 @@ const diffField = StateField.define>({ } } return set - } + }, + provide: f => EditorView.decorations.from(f) }) -const diffGutterTheme = EditorView.baseTheme({ - '.cm-diff-gutter .cm-gutterElement': { - padding: '0', - }, - '.cm-diff-add, .cm-diff-mod, .cm-diff-del': { - width: '3px', - height: '100%', - marginLeft: '2px', - }, - '.cm-diff-add': {background: '#2ea043'}, - '.cm-diff-mod': {background: '#d29922'}, - // 删除:用红色小三角提示 - '.cm-diff-del': { - background: 'transparent', +const diffTheme = EditorView.baseTheme({ + '.cm-diff-add': {boxShadow: 'inset 2px 0 0 0 #2ea043'}, + '.cm-diff-mod': {boxShadow: 'inset 2px 0 0 0 #d29922'}, + // 删除:行首红色小三角 + '.cm-diff-del': {position: 'relative'}, + '.cm-diff-del::before': { + content: '""', + position: 'absolute', + left: '0', + top: '0', width: '0', height: '0', - marginLeft: '1px', - borderLeft: '4px solid #f85149', + borderLeft: '5px solid #f85149', borderTop: '4px solid transparent', borderBottom: '4px solid transparent', }, }) -const diffGutterView = gutter({ - class: 'cm-diff-gutter', - markers: v => v.state.field(diffField), -}) - // 编辑器差异标记扩展(默认无标记,由外部 dispatch setDiffMarkers 填充) -export const diffGutterExtension = [diffField, diffGutterView, diffGutterTheme] +export const diffGutterExtension = [diffField, diffTheme] // 基于 LCS 的逐行差异,输出当前文档各行的标记 export const computeDiffMarkers = (baseline: string, current: string): DiffMarkers => { From 15536df6e1776292a4a96284d4c13b0358707f18 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Fri, 5 Jun 2026 15:40:58 +0800 Subject: [PATCH 24/33] =?UTF-8?q?fix:=20=E5=88=A0=E9=99=A4=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=E6=94=B9=E7=94=A8=E5=BA=95=E7=BC=98=E7=BA=A2=E7=BA=BF?= =?UTF-8?q?=EF=BC=8C=E6=B6=88=E9=99=A4=E8=A1=8C=E5=8F=B7=E9=87=8D=E5=8F=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前删除用绝对定位 ::before 三角,导致对应行的行号渲染重叠错位。改为在该行底缘用 inset box-shadow 画红线(提示下方有删除),不引入定位与额外盒子,不影响行高与行号对齐 --- src/editor/diffGutter.ts | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/editor/diffGutter.ts b/src/editor/diffGutter.ts index 775714d..c3c7fc5 100644 --- a/src/editor/diffGutter.ts +++ b/src/editor/diffGutter.ts @@ -14,7 +14,8 @@ export interface DiffMarkers // 设置差异标记的 effect(由外部计算后派发) export const setDiffMarkers = StateEffect.define() -// 行装饰:在行左缘绘制彩色竖条 / 删除三角(不新增 gutter 列,避免与行号列冲突) +// 行装饰:在行左缘绘制彩色竖条(不新增 gutter 列,避免与行号列冲突) +// 新增=绿、修改=琥珀;删除在相邻行底缘用琥珀虚线提示,避免绝对定位三角影响行高 const addLine = Decoration.line({class: 'cm-diff-add'}) const modLine = Decoration.line({class: 'cm-diff-mod'}) const delLine = Decoration.line({class: 'cm-diff-del'}) @@ -51,21 +52,11 @@ const diffField = StateField.define({ }) const diffTheme = EditorView.baseTheme({ + // 左缘竖条:新增=绿、修改=琥珀 '.cm-diff-add': {boxShadow: 'inset 2px 0 0 0 #2ea043'}, '.cm-diff-mod': {boxShadow: 'inset 2px 0 0 0 #d29922'}, - // 删除:行首红色小三角 - '.cm-diff-del': {position: 'relative'}, - '.cm-diff-del::before': { - content: '""', - position: 'absolute', - left: '0', - top: '0', - width: '0', - height: '0', - borderLeft: '5px solid #f85149', - borderTop: '4px solid transparent', - borderBottom: '4px solid transparent', - }, + // 删除:该行底缘一条红线(提示其下方有内容被删除),不影响行高 + '.cm-diff-del': {boxShadow: 'inset 0 -2px 0 0 #f85149'}, }) // 编辑器差异标记扩展(默认无标记,由外部 dispatch setDiffMarkers 填充) From df548283b0c6fbd711f240cd27ecdd9588bbe74c Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Fri, 5 Jun 2026 15:44:16 +0800 Subject: [PATCH 25/33] =?UTF-8?q?revert:=20=E7=A7=BB=E9=99=A4=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E8=A1=8C=E5=86=85=E5=B7=AE=E5=BC=82=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=EF=BC=88=E8=A1=8C=E5=8F=B7=E9=87=8D=E5=8F=A0=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=8C=E5=BE=85=E9=87=8D=E5=81=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 行内差异标记多次尝试仍导致行号重叠,先移除该子功能解除编辑器异常;GitPanel、文件树徽标、DiffView 弹窗等不受影响。后端 git_file_head 保留备用 --- src/App.vue | 47 +------- src/composables/useCodeMirrorEditor.ts | 4 - src/editor/diffGutter.ts | 143 ------------------------- 3 files changed, 1 insertion(+), 193 deletions(-) delete mode 100644 src/editor/diffGutter.ts diff --git a/src/App.vue b/src/App.vue index cfc8245..79099c0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -269,7 +269,6 @@ import CommandPalette, {type PaletteCommand} from './components/CommandPalette.v import DiffView from './components/DiffView.vue' import PreviewPanel from './components/PreviewPanel.vue' import GitPanel from './components/GitPanel.vue' -import {computeDiffMarkers, setDiffMarkers} from './editor/diffGutter' import AiAssistant from './components/AiAssistant.vue' import InlineGenerate from './components/InlineGenerate.vue' import SearchPanel from './components/SearchPanel.vue' @@ -754,53 +753,9 @@ const refreshGitStatus = async () => { catch { gitStatus.value = {} } - // HEAD 可能因提交/切换分支变化,刷新编辑器行内差异基线 - fetchBaseline() } -// ===== 编辑器行内差异标记(vs HEAD)===== -// 当前文件在 HEAD 中的内容;null 表示无基线(新文件/非 git/未跟踪),不显示标记 -const gitBaseline = ref(null) - -const fetchBaseline = async () => { - if (!rootDir.value || !currentFilePath.value || !currentFilePath.value.startsWith(rootDir.value)) { - gitBaseline.value = null - applyDiffMarkers() - return - } - const rel = currentFilePath.value.slice(rootDir.value.length + 1) - try { - const head = await invoke<{ exists: boolean, content: string }>('git_file_head', { - root: rootDir.value, - relPath: rel - }) - gitBaseline.value = head.exists ? head.content : null - } - catch { - gitBaseline.value = null - } - applyDiffMarkers() -} - -// 计算并派发标记到编辑器 -const applyDiffMarkers = () => { - const view = editorView.value - if (!view) { - return - } - const markers = gitBaseline.value === null - ? {changed: new Map(), deleted: new Set()} - : computeDiffMarkers(gitBaseline.value, code.value) - view.dispatch({effects: setDiffMarkers.of(markers)}) -} -const applyDiffMarkersDebounced = debounce(applyDiffMarkers, 250) - -// 切换文件取新基线;编辑时重算;编辑器重挂时重新派发 -watch(currentFilePath, () => fetchBaseline()) -watch(code, () => applyDiffMarkersDebounced()) -watch(editorView, () => applyDiffMarkers()) - -// 打开文件夹、保存文件后刷新文件树 Git 徽标与差异基线 +// 打开文件夹、保存文件后刷新文件树 Git 徽标 watch(rootDir, () => refreshGitStatus(), {immediate: true}) watch(savedContent, () => refreshGitStatus()) diff --git a/src/composables/useCodeMirrorEditor.ts b/src/composables/useCodeMirrorEditor.ts index 0ea0da8..bb55476 100644 --- a/src/composables/useCodeMirrorEditor.ts +++ b/src/composables/useCodeMirrorEditor.ts @@ -76,7 +76,6 @@ import {useCodeMirrorFunctionHelp} from './useCodeMirrorFunctionHelp' import {useCodeMirrorSpaceOmission} from './useCodeMirrorSpaceOmission.ts' import {EditorView, keymap} from "@codemirror/view"; import {useCodeMirrorFontFamily} from "./useCodeMirrorFontFamily.ts"; -import {diffGutterExtension} from "../editor/diffGutter"; interface Props { @@ -298,9 +297,6 @@ export function useCodeMirrorEditor(props: Props) // 字体缩放快捷键(搜索/替换、折叠、括号匹配等由 vue-codemirror 的 basicSetup 提供) result.push(fontSizeKeymap) - // Git 行内差异标记(标记数据由外部 dispatch 填充,无 git 时为空) - result.push(diffGutterExtension) - // 设置字体 const {fontFamilyTheme} = useCodeMirrorFontFamily( editorConfig.value?.font_family diff --git a/src/editor/diffGutter.ts b/src/editor/diffGutter.ts deleted file mode 100644 index c3c7fc5..0000000 --- a/src/editor/diffGutter.ts +++ /dev/null @@ -1,143 +0,0 @@ -import {Decoration, DecorationSet, EditorView} from '@codemirror/view' -import {StateEffect, StateField, RangeSetBuilder} from '@codemirror/state' - -export type LineKind = 'add' | 'mod' - -export interface DiffMarkers -{ - // 当前文档行号(1-based) → 标记类型 - changed: Map - // 在这些行之前发生了删除(在行首显示红色小三角) - deleted: Set -} - -// 设置差异标记的 effect(由外部计算后派发) -export const setDiffMarkers = StateEffect.define() - -// 行装饰:在行左缘绘制彩色竖条(不新增 gutter 列,避免与行号列冲突) -// 新增=绿、修改=琥珀;删除在相邻行底缘用琥珀虚线提示,避免绝对定位三角影响行高 -const addLine = Decoration.line({class: 'cm-diff-add'}) -const modLine = Decoration.line({class: 'cm-diff-mod'}) -const delLine = Decoration.line({class: 'cm-diff-del'}) - -const buildSet = (state: any, data: DiffMarkers): DecorationSet => { - const lineCount = state.doc.lines - const builder = new RangeSetBuilder() - // 按行号升序写入,满足 RangeSetBuilder 的递增要求 - for (let n = 1; n <= lineCount; n++) { - const from = state.doc.line(n).from - if (data.changed.has(n)) { - builder.add(from, from, data.changed.get(n) === 'add' ? addLine : modLine) - } - else if (data.deleted.has(n)) { - builder.add(from, from, delLine) - } - } - return builder.finish() -} - -const diffField = StateField.define({ - create: () => Decoration.none, - update(set, tr) { - // 文档变化时随之平移,下一次重算前位置不至错乱 - set = set.map(tr.changes) - for (const e of tr.effects) { - if (e.is(setDiffMarkers)) { - set = buildSet(tr.state, e.value) - } - } - return set - }, - provide: f => EditorView.decorations.from(f) -}) - -const diffTheme = EditorView.baseTheme({ - // 左缘竖条:新增=绿、修改=琥珀 - '.cm-diff-add': {boxShadow: 'inset 2px 0 0 0 #2ea043'}, - '.cm-diff-mod': {boxShadow: 'inset 2px 0 0 0 #d29922'}, - // 删除:该行底缘一条红线(提示其下方有内容被删除),不影响行高 - '.cm-diff-del': {boxShadow: 'inset 0 -2px 0 0 #f85149'}, -}) - -// 编辑器差异标记扩展(默认无标记,由外部 dispatch setDiffMarkers 填充) -export const diffGutterExtension = [diffField, diffTheme] - -// 基于 LCS 的逐行差异,输出当前文档各行的标记 -export const computeDiffMarkers = (baseline: string, current: string): DiffMarkers => { - const a = baseline.length ? baseline.split('\n') : [] - const b = current.length ? current.split('\n') : [] - const n = a.length, m = b.length - const changed = new Map() - const deleted = new Set() - - // 空基线:全部视为新增 - if (n === 0) { - for (let i = 1; i <= m; i++) { - changed.set(i, 'add') - } - return {changed, deleted} - } - - // 超大文件不做精细 diff,避免 O(n*m) - if (n * m > 4_000_000) { - return {changed, deleted} - } - - const dp: number[][] = Array.from({length: n + 1}, () => new Array(m + 1).fill(0)) - for (let i = n - 1; i >= 0; i--) { - for (let j = m - 1; j >= 0; j--) { - dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]) - } - } - - let i = 0, j = 0 - let curLine = 0 - let pendingDel = 0 - const step = (op: 'same' | 'add' | 'del') => { - if (op === 'del') { - pendingDel++ - return - } - if (op === 'add') { - curLine++ - if (pendingDel > 0) { - changed.set(curLine, 'mod') - pendingDel-- - } - else { - changed.set(curLine, 'add') - } - return - } - // same - curLine++ - if (pendingDel > 0) { - deleted.add(curLine) - pendingDel = 0 - } - } - - while (i < n && j < m) { - if (a[i] === b[j]) { - step('same'); i++; j++ - } - else if (dp[i + 1][j] >= dp[i][j + 1]) { - step('del'); i++ - } - else { - step('add'); j++ - } - } - while (i < n) { - step('del'); i++ - } - while (j < m) { - step('add'); j++ - } - // 文件末尾的删除:标记在最后一行 - if (pendingDel > 0) { - deleted.add(curLine > 0 ? curLine : 1) - } - - return {changed, deleted} -} From 5f726ca9c4f6bd01319d07ea65dfacac3afccc83 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Fri, 5 Jun 2026 15:45:47 +0800 Subject: [PATCH 26/33] =?UTF-8?q?feat:=20=E6=81=A2=E5=A4=8D=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E8=A1=8C=E5=86=85=E5=B7=AE=E5=BC=82=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=EF=BC=8C=E5=88=A0=E9=99=A4=E6=A0=87=E8=AE=B0=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E4=B8=BA=E5=B7=A6=E4=BE=A7=E7=BA=A2=E8=89=B2=E7=AB=96?= =?UTF-8?q?=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 撤销上一次的移除;三种标记统一在行左缘绘制竖条:新增=绿、修改=琥珀、删除=红,均为 inset box-shadow,不影响行高与行号对齐 --- src/App.vue | 47 +++++++- src/composables/useCodeMirrorEditor.ts | 4 + src/editor/diffGutter.ts | 142 +++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 src/editor/diffGutter.ts diff --git a/src/App.vue b/src/App.vue index 79099c0..cfc8245 100644 --- a/src/App.vue +++ b/src/App.vue @@ -269,6 +269,7 @@ import CommandPalette, {type PaletteCommand} from './components/CommandPalette.v import DiffView from './components/DiffView.vue' import PreviewPanel from './components/PreviewPanel.vue' import GitPanel from './components/GitPanel.vue' +import {computeDiffMarkers, setDiffMarkers} from './editor/diffGutter' import AiAssistant from './components/AiAssistant.vue' import InlineGenerate from './components/InlineGenerate.vue' import SearchPanel from './components/SearchPanel.vue' @@ -753,9 +754,53 @@ const refreshGitStatus = async () => { catch { gitStatus.value = {} } + // HEAD 可能因提交/切换分支变化,刷新编辑器行内差异基线 + fetchBaseline() } -// 打开文件夹、保存文件后刷新文件树 Git 徽标 +// ===== 编辑器行内差异标记(vs HEAD)===== +// 当前文件在 HEAD 中的内容;null 表示无基线(新文件/非 git/未跟踪),不显示标记 +const gitBaseline = ref(null) + +const fetchBaseline = async () => { + if (!rootDir.value || !currentFilePath.value || !currentFilePath.value.startsWith(rootDir.value)) { + gitBaseline.value = null + applyDiffMarkers() + return + } + const rel = currentFilePath.value.slice(rootDir.value.length + 1) + try { + const head = await invoke<{ exists: boolean, content: string }>('git_file_head', { + root: rootDir.value, + relPath: rel + }) + gitBaseline.value = head.exists ? head.content : null + } + catch { + gitBaseline.value = null + } + applyDiffMarkers() +} + +// 计算并派发标记到编辑器 +const applyDiffMarkers = () => { + const view = editorView.value + if (!view) { + return + } + const markers = gitBaseline.value === null + ? {changed: new Map(), deleted: new Set()} + : computeDiffMarkers(gitBaseline.value, code.value) + view.dispatch({effects: setDiffMarkers.of(markers)}) +} +const applyDiffMarkersDebounced = debounce(applyDiffMarkers, 250) + +// 切换文件取新基线;编辑时重算;编辑器重挂时重新派发 +watch(currentFilePath, () => fetchBaseline()) +watch(code, () => applyDiffMarkersDebounced()) +watch(editorView, () => applyDiffMarkers()) + +// 打开文件夹、保存文件后刷新文件树 Git 徽标与差异基线 watch(rootDir, () => refreshGitStatus(), {immediate: true}) watch(savedContent, () => refreshGitStatus()) diff --git a/src/composables/useCodeMirrorEditor.ts b/src/composables/useCodeMirrorEditor.ts index bb55476..0ea0da8 100644 --- a/src/composables/useCodeMirrorEditor.ts +++ b/src/composables/useCodeMirrorEditor.ts @@ -76,6 +76,7 @@ import {useCodeMirrorFunctionHelp} from './useCodeMirrorFunctionHelp' import {useCodeMirrorSpaceOmission} from './useCodeMirrorSpaceOmission.ts' import {EditorView, keymap} from "@codemirror/view"; import {useCodeMirrorFontFamily} from "./useCodeMirrorFontFamily.ts"; +import {diffGutterExtension} from "../editor/diffGutter"; interface Props { @@ -297,6 +298,9 @@ export function useCodeMirrorEditor(props: Props) // 字体缩放快捷键(搜索/替换、折叠、括号匹配等由 vue-codemirror 的 basicSetup 提供) result.push(fontSizeKeymap) + // Git 行内差异标记(标记数据由外部 dispatch 填充,无 git 时为空) + result.push(diffGutterExtension) + // 设置字体 const {fontFamilyTheme} = useCodeMirrorFontFamily( editorConfig.value?.font_family diff --git a/src/editor/diffGutter.ts b/src/editor/diffGutter.ts new file mode 100644 index 0000000..7c1cd6e --- /dev/null +++ b/src/editor/diffGutter.ts @@ -0,0 +1,142 @@ +import {Decoration, DecorationSet, EditorView} from '@codemirror/view' +import {StateEffect, StateField, RangeSetBuilder} from '@codemirror/state' + +export type LineKind = 'add' | 'mod' + +export interface DiffMarkers +{ + // 当前文档行号(1-based) → 标记类型 + changed: Map + // 在这些行之前发生了删除(在行首显示红色小三角) + deleted: Set +} + +// 设置差异标记的 effect(由外部计算后派发) +export const setDiffMarkers = StateEffect.define() + +// 行装饰:在行左缘绘制彩色竖条(不新增 gutter 列,避免与行号列冲突) +// 新增=绿、修改=琥珀;删除在相邻行底缘用琥珀虚线提示,避免绝对定位三角影响行高 +const addLine = Decoration.line({class: 'cm-diff-add'}) +const modLine = Decoration.line({class: 'cm-diff-mod'}) +const delLine = Decoration.line({class: 'cm-diff-del'}) + +const buildSet = (state: any, data: DiffMarkers): DecorationSet => { + const lineCount = state.doc.lines + const builder = new RangeSetBuilder() + // 按行号升序写入,满足 RangeSetBuilder 的递增要求 + for (let n = 1; n <= lineCount; n++) { + const from = state.doc.line(n).from + if (data.changed.has(n)) { + builder.add(from, from, data.changed.get(n) === 'add' ? addLine : modLine) + } + else if (data.deleted.has(n)) { + builder.add(from, from, delLine) + } + } + return builder.finish() +} + +const diffField = StateField.define({ + create: () => Decoration.none, + update(set, tr) { + // 文档变化时随之平移,下一次重算前位置不至错乱 + set = set.map(tr.changes) + for (const e of tr.effects) { + if (e.is(setDiffMarkers)) { + set = buildSet(tr.state, e.value) + } + } + return set + }, + provide: f => EditorView.decorations.from(f) +}) + +const diffTheme = EditorView.baseTheme({ + // 左缘竖条:新增=绿、修改=琥珀、删除=红(提示其下方有内容被删除) + '.cm-diff-add': {boxShadow: 'inset 2px 0 0 0 #2ea043'}, + '.cm-diff-mod': {boxShadow: 'inset 2px 0 0 0 #d29922'}, + '.cm-diff-del': {boxShadow: 'inset 2px 0 0 0 #f85149'}, +}) + +// 编辑器差异标记扩展(默认无标记,由外部 dispatch setDiffMarkers 填充) +export const diffGutterExtension = [diffField, diffTheme] + +// 基于 LCS 的逐行差异,输出当前文档各行的标记 +export const computeDiffMarkers = (baseline: string, current: string): DiffMarkers => { + const a = baseline.length ? baseline.split('\n') : [] + const b = current.length ? current.split('\n') : [] + const n = a.length, m = b.length + const changed = new Map() + const deleted = new Set() + + // 空基线:全部视为新增 + if (n === 0) { + for (let i = 1; i <= m; i++) { + changed.set(i, 'add') + } + return {changed, deleted} + } + + // 超大文件不做精细 diff,避免 O(n*m) + if (n * m > 4_000_000) { + return {changed, deleted} + } + + const dp: number[][] = Array.from({length: n + 1}, () => new Array(m + 1).fill(0)) + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]) + } + } + + let i = 0, j = 0 + let curLine = 0 + let pendingDel = 0 + const step = (op: 'same' | 'add' | 'del') => { + if (op === 'del') { + pendingDel++ + return + } + if (op === 'add') { + curLine++ + if (pendingDel > 0) { + changed.set(curLine, 'mod') + pendingDel-- + } + else { + changed.set(curLine, 'add') + } + return + } + // same + curLine++ + if (pendingDel > 0) { + deleted.add(curLine) + pendingDel = 0 + } + } + + while (i < n && j < m) { + if (a[i] === b[j]) { + step('same'); i++; j++ + } + else if (dp[i + 1][j] >= dp[i][j + 1]) { + step('del'); i++ + } + else { + step('add'); j++ + } + } + while (i < n) { + step('del'); i++ + } + while (j < m) { + step('add'); j++ + } + // 文件末尾的删除:标记在最后一行 + if (pendingDel > 0) { + deleted.add(curLine > 0 ? curLine : 1) + } + + return {changed, deleted} +} From c5ee551942cab3e10b58f123a431c73c5bf08ca1 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Fri, 5 Jun 2026 15:49:54 +0800 Subject: [PATCH 27/33] =?UTF-8?q?feat:=20=E8=B7=B3=E8=BD=AC=E5=88=B0?= =?UTF-8?q?=E8=A1=8C=20(Cmd+G)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 GoToLine 浮层输入行号回车跳转,复用编辑器 gotoLine;接入快捷键(Mod+G)与命令面板 --- src/App.vue | 19 +++++++++++++-- src/components/GoToLine.vue | 42 +++++++++++++++++++++++++++++++++ src/composables/useShortcuts.ts | 1 + 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 src/components/GoToLine.vue diff --git a/src/App.vue b/src/App.vue index cfc8245..99fb07c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -213,6 +213,12 @@ :commands="paletteCommands" @close="showCommandPalette = false"/> + + + import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue' import {debounce} from 'lodash-es' -import {ChevronRight, Eye, FolderOpen, GitBranch, GitCompare, History, Maximize2, Monitor, Moon, PanelBottom, PanelLeft, PanelRight, Play, Plus, Save, Search, Settings as SettingsIcon, Sparkles, Sun, X} from 'lucide-vue-next' +import {ChevronRight, CornerDownRight, Eye, FolderOpen, GitBranch, GitCompare, History, Maximize2, Monitor, Moon, PanelBottom, PanelLeft, PanelRight, Play, Plus, Save, Search, Settings as SettingsIcon, Sparkles, Sun, 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' @@ -269,6 +275,7 @@ import CommandPalette, {type PaletteCommand} from './components/CommandPalette.v import DiffView from './components/DiffView.vue' import PreviewPanel from './components/PreviewPanel.vue' import GitPanel from './components/GitPanel.vue' +import GoToLine from './components/GoToLine.vue' import {computeDiffMarkers, setDiffMarkers} from './editor/diffGutter' import AiAssistant from './components/AiAssistant.vue' import InlineGenerate from './components/InlineGenerate.vue' @@ -650,6 +657,12 @@ const gotoLine = (line: number) => { view.focus() } +// 跳转到行(Cmd+G) +const showGoToLine = ref(false) +const openGoToLine = () => { + showGoToLine.value = true +} + const openSearchResult = async (path: string, line: number) => { showSearch.value = false await smartOpen(path) @@ -1065,7 +1078,7 @@ const isOverlayOpen = () => showSettings.value || showAbout.value || showUpdate.value || showHistory.value || showViewer.value || showRunPrompt.value || showQuickOpen.value || showGenerate.value || showSearch.value - || showCommandPalette.value || showDiff.value + || showCommandPalette.value || showDiff.value || showGoToLine.value // 全局快捷键(绑定可在设置中自定义) const {matchAction: matchShortcut, reload: reloadShortcuts, getBinding, formatCombo} = useShortcuts() @@ -1074,6 +1087,7 @@ const shortcutDispatch: Record void> = { run: () => handleRunCode(), quickOpen: () => openQuickOpen(), commandPalette: () => openCommandPalette(), + gotoLine: () => openGoToLine(), searchInFiles: () => openSearch(), generate: () => openGenerate(), save: () => saveFile(), @@ -1108,6 +1122,7 @@ const paletteCommands = computed(() => [ {id: 'newTab', label: '新建标签', icon: Plus, hint: hintOf('newTab'), run: () => handleNewTab()}, {id: 'closeTab', label: '关闭标签', icon: X, hint: hintOf('closeTab'), run: () => handleCloseTab(activeTabId.value)}, {id: 'quickOpen', label: '快速打开文件', icon: Search, hint: hintOf('quickOpen'), run: () => openQuickOpen()}, + {id: 'gotoLine', label: '跳转到行', icon: CornerDownRight, hint: hintOf('gotoLine'), run: () => openGoToLine()}, {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()}, diff --git a/src/components/GoToLine.vue b/src/components/GoToLine.vue new file mode 100644 index 0000000..eb0b9e0 --- /dev/null +++ b/src/components/GoToLine.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/composables/useShortcuts.ts b/src/composables/useShortcuts.ts index 8e37502..0d5fed4 100644 --- a/src/composables/useShortcuts.ts +++ b/src/composables/useShortcuts.ts @@ -15,6 +15,7 @@ export const SHORTCUT_ACTIONS: ShortcutAction[] = [ {id: 'open', label: '打开文件', default: 'Mod+O'}, {id: 'quickOpen', label: '快速打开文件', default: 'Mod+P'}, {id: 'commandPalette', label: '命令面板', default: 'Mod+Shift+P'}, + {id: 'gotoLine', label: '跳转到行', default: 'Mod+G'}, {id: 'searchInFiles', label: '文件夹内搜索', default: 'Mod+Shift+F'}, {id: 'generate', label: 'AI 生成代码', default: 'Mod+K'}, {id: 'newTab', label: '新建标签', default: 'Mod+N'}, From e0991206359822873d95da0b5358c65f4ae993fd Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Fri, 5 Jun 2026 15:52:35 +0800 Subject: [PATCH 28/33] =?UTF-8?q?feat:=20=E7=AC=A6=E5=8F=B7=E5=A4=A7?= =?UTF-8?q?=E7=BA=B2=20(Cmd+Shift+O)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按语言族(py/js-ts/rust/go/java/c-cpp/ruby/php + 通用)启发式正则提取函数/类/方法/结构等符号,浮层可筛选并跳转;接入快捷键与命令面板 --- src/App.vue | 20 +++- src/components/Outline.vue | 190 ++++++++++++++++++++++++++++++++ src/composables/useShortcuts.ts | 1 + 3 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 src/components/Outline.vue diff --git a/src/App.vue b/src/App.vue index 99fb07c..05ee5f8 100644 --- a/src/App.vue +++ b/src/App.vue @@ -219,6 +219,13 @@ @go="gotoLine" @close="showGoToLine = false"/> + + + import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue' import {debounce} from 'lodash-es' -import {ChevronRight, CornerDownRight, Eye, FolderOpen, GitBranch, GitCompare, History, Maximize2, Monitor, Moon, PanelBottom, PanelLeft, PanelRight, Play, Plus, Save, Search, Settings as SettingsIcon, Sparkles, Sun, X} from 'lucide-vue-next' +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 {ExecutionResult, LayoutMode, SplitDirection} from './types/app.ts' import AppHeader from './components/AppHeader.vue' import CodeEditor from './components/CodeEditor.vue' @@ -276,6 +283,7 @@ import DiffView from './components/DiffView.vue' 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 {computeDiffMarkers, setDiffMarkers} from './editor/diffGutter' import AiAssistant from './components/AiAssistant.vue' import InlineGenerate from './components/InlineGenerate.vue' @@ -663,6 +671,12 @@ const openGoToLine = () => { showGoToLine.value = true } +// 符号大纲(Cmd+Shift+O) +const showOutline = ref(false) +const openOutline = () => { + showOutline.value = true +} + const openSearchResult = async (path: string, line: number) => { showSearch.value = false await smartOpen(path) @@ -1078,7 +1092,7 @@ 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 + || showCommandPalette.value || showDiff.value || showGoToLine.value || showOutline.value // 全局快捷键(绑定可在设置中自定义) const {matchAction: matchShortcut, reload: reloadShortcuts, getBinding, formatCombo} = useShortcuts() @@ -1088,6 +1102,7 @@ const shortcutDispatch: Record void> = { quickOpen: () => openQuickOpen(), commandPalette: () => openCommandPalette(), gotoLine: () => openGoToLine(), + outline: () => openOutline(), searchInFiles: () => openSearch(), generate: () => openGenerate(), save: () => saveFile(), @@ -1123,6 +1138,7 @@ const paletteCommands = computed(() => [ {id: 'closeTab', label: '关闭标签', icon: X, hint: hintOf('closeTab'), run: () => handleCloseTab(activeTabId.value)}, {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: '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()}, diff --git a/src/components/Outline.vue b/src/components/Outline.vue new file mode 100644 index 0000000..5e08720 --- /dev/null +++ b/src/components/Outline.vue @@ -0,0 +1,190 @@ + + + diff --git a/src/composables/useShortcuts.ts b/src/composables/useShortcuts.ts index 0d5fed4..ddb9aa6 100644 --- a/src/composables/useShortcuts.ts +++ b/src/composables/useShortcuts.ts @@ -16,6 +16,7 @@ export const SHORTCUT_ACTIONS: ShortcutAction[] = [ {id: 'quickOpen', label: '快速打开文件', default: 'Mod+P'}, {id: 'commandPalette', label: '命令面板', default: 'Mod+Shift+P'}, {id: 'gotoLine', label: '跳转到行', default: 'Mod+G'}, + {id: 'outline', label: '符号大纲', default: 'Mod+Shift+O'}, {id: 'searchInFiles', label: '文件夹内搜索', default: 'Mod+Shift+F'}, {id: 'generate', label: 'AI 生成代码', default: 'Mod+K'}, {id: 'newTab', label: '新建标签', default: 'Mod+N'}, From ff62ed1279691d0e029e5ef793df4a719bb3dc76 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Fri, 5 Jun 2026 15:55:02 +0800 Subject: [PATCH 29/33] =?UTF-8?q?fix:=20=E5=91=BD=E4=BB=A4=E7=B1=BB?= =?UTF-8?q?=E6=B5=AE=E5=B1=82=E6=8C=89=E5=86=85=E5=AE=B9=E9=AB=98=E5=BA=A6?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=EF=BC=8C=E4=B8=8D=E5=86=8D=E8=A2=AB=E6=8B=89?= =?UTF-8?q?=E6=BB=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 外层 flex 默认 align-items:stretch 会把无固定高度的浮层在竖直方向拉满。各命令类浮层(跳转到行/符号大纲/命令面板/快速打开/搜索)统一加 items-start,按内容高度自适应(仍受 max-h 限制) --- src/components/CommandPalette.vue | 2 +- src/components/GoToLine.vue | 2 +- src/components/Outline.vue | 2 +- src/components/QuickOpen.vue | 2 +- src/components/SearchPanel.vue | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/CommandPalette.vue b/src/components/CommandPalette.vue index 26c6253..32c8c01 100644 --- a/src/components/CommandPalette.vue +++ b/src/components/CommandPalette.vue @@ -1,5 +1,5 @@