From e6aac2d15da238635734e073fd5d21f83fec4295 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sun, 14 Jun 2026 23:20:37 +0800 Subject: [PATCH 01/25] =?UTF-8?q?fix(git):=20=E4=BF=AE=E5=A4=8D=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=97=B6=E6=8C=89=E9=92=AE=E5=9C=A8=20WKWebView=20?= =?UTF-8?q?=E4=B8=8B=E7=9A=84=E9=87=8D=E7=BB=98=E6=AE=8B=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 加载态左侧插入 spinner 使文字右移,WKWebView 在持续旋转动画下未清除 旧绘制,导致按钮看似重复。给按钮加 translateZ(0) 提升到独立合成层, 强制整体原子重绘后消除。 --- src/components/GitPanel.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/GitPanel.vue b/src/components/GitPanel.vue index 612f03e..73348e8 100644 --- a/src/components/GitPanel.vue +++ b/src/components/GitPanel.vue @@ -67,13 +67,13 @@
- - -
From 6ac89372289945cfcc4fc2c09e3b8fc56aec368c Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Mon, 15 Jun 2026 08:18:45 +0800 Subject: [PATCH 02/25] =?UTF-8?q?feat(lsp):=20=E6=8E=A5=E5=85=A5=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E6=93=8D=E4=BD=9C=20/=20=E5=BF=AB=E9=80=9F=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D(Code=20Action)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 通过 client.request 发 textDocument/codeAction,附带与选区重叠的诊断作为 context - Cmd+. 或右键菜单「代码操作 / 快速修复」触发,结果弹菜单供选择 - 应用当前文件的 WorkspaceEdit;无 edit 时走 codeAction/resolve; 含 command 时走 workspace/executeCommand - 跨文件编辑 v1 暂提示未自动应用 --- src/App.vue | 54 ++++++++++++++- src/editor/lspExtension.ts | 130 ++++++++++++++++++++++++++++++++++++- 2 files changed, 182 insertions(+), 2 deletions(-) diff --git a/src/App.vue b/src/App.vue index b4b79b2..777ae8f 100644 --- a/src/App.vue +++ b/src/App.vue @@ -376,6 +376,9 @@ +
+ + + @@ -395,7 +412,7 @@ import {computed, nextTick, onMounted, onUnmounted, reactive, ref, shallowRef, watch} from 'vue' import {debounce} from 'lodash-es' import {formatDocument, formatSelection, renameSymbol} from 'codemirror-languageserver' -import {runGotoDefinition, lspSupportsLanguage} from './editor/lspExtension' +import {runGotoDefinition, lspSupportsLanguage, triggerCodeActions, applyCodeAction} from './editor/lspExtension' import {ChevronRight, Code2, CornerDownRight, Eye, FolderOpen, GitBranch, GitCompare, History, ListTree, Maximize2, Monitor, Moon, PanelBottom, PanelLeft, PanelRight, Play, Plus, Save, Search, Settings as SettingsIcon, Sparkles, Sun, Terminal as TerminalIcon, X} from 'lucide-vue-next' import {ExecutionResult, LayoutMode, SplitDirection} from './types/app.ts' import AppHeader from './components/AppHeader.vue' @@ -1120,6 +1137,39 @@ const runEditorCommand = (cmd: (v: any) => boolean) => { } } +// ===== LSP 代码操作选择菜单 ===== +const codeActionMenu = reactive<{visible: boolean; x: number; y: number; actions: any[]}>({ + visible: false, x: 0, y: 0, actions: [] +}) +// 编辑器扩展请求完成后派发 lsp:code-actions:有结果则弹菜单,无则提示 +const onLspCodeActions = (e: Event) => { + const detail = (e as CustomEvent).detail as {actions: any[]; x: number; y: number} + const actions = detail?.actions ?? [] + if (!actions.length) { + toast.info('当前位置没有可用的代码操作') + return + } + codeActionMenu.actions = actions + codeActionMenu.x = Math.min(detail.x, window.innerWidth - 430) + codeActionMenu.y = Math.min(detail.y, window.innerHeight - 340) + codeActionMenu.visible = true +} +const pickCodeAction = async (action: any) => { + codeActionMenu.visible = false + if (!editorView.value) { + return + } + try { + const {otherFiles} = await applyCodeAction(editorView.value, action) + if (otherFiles > 0) { + toast.info(`该操作还涉及 ${otherFiles} 个其它文件的修改,暂未自动应用`) + } + } + catch (err) { + toast.error('应用代码操作失败: ' + err) + } +} + // 全局替换后:刷新涉及到的已打开标签(保留有未保存修改的标签) const reloadAffectedFiles = async (paths: string[]) => { const set = new Set(paths) @@ -1734,6 +1784,7 @@ onMounted(async () => { window.addEventListener('keydown', onGlobalKeydown, true) window.addEventListener('lsp:open-location', onLspOpenLocation) + window.addEventListener('lsp:code-actions', onLspCodeActions) window.addEventListener('contextmenu', onEditorContext) // 触发 app-ready 事件,通知主进程 @@ -1744,6 +1795,7 @@ onUnmounted(() => { cleanupEventListeners() window.removeEventListener('keydown', onGlobalKeydown, true) window.removeEventListener('lsp:open-location', onLspOpenLocation) + window.removeEventListener('lsp:code-actions', onLspCodeActions) window.removeEventListener('contextmenu', onEditorContext) }) diff --git a/src/editor/lspExtension.ts b/src/editor/lspExtension.ts index e132229..8ee9dfb 100644 --- a/src/editor/lspExtension.ts +++ b/src/editor/lspExtension.ts @@ -11,6 +11,7 @@ import { } from 'codemirror-languageserver' import {keymap, EditorView} from '@codemirror/view' import {Prec} from '@codemirror/state' +import {forEachDiagnostic} from '@codemirror/lint' import {TauriLspTransport} from './lspTransport' import {setLspState} from './lspStatus' import {lspCustomHover} from './lspHover' @@ -68,6 +69,132 @@ export const runGotoDefinition = (view: EditorView, pos?: number): boolean => { return true } +// LSP 行列 → CodeMirror 偏移量 +const lspPosToOffset = (doc: any, pos: {line: number; character: number}): number => { + const line = doc.line(Math.min(Math.max(pos.line + 1, 1), doc.lines)) + return Math.min(line.from + pos.character, line.to) +} + +// CodeMirror 诊断 severity → LSP severity 数字 +const SEVERITY: Record = {error: 1, warning: 2, info: 3, hint: 4} + +/** + * 向语言服务器请求当前选区/光标处的代码操作(含重叠诊断作为 context)。 + * 返回 (Command | CodeAction)[],无能力或出错时返回 []。 + */ +export const requestCodeActions = async (view: EditorView): Promise => { + const plugin: any = view.plugin(languageServerPlugin as any) + const client = plugin?.client + if (!client?.ready || !client.capabilities?.codeActionProvider) { + return [] + } + const doc = view.state.doc + const sel = view.state.selection.main + // 收集与选区重叠的诊断作为 context(库未保留原始 LSP 诊断,此处由编辑器诊断重建) + const diagnostics: any[] = [] + forEachDiagnostic(view.state, (d, from, to) => { + if (to >= sel.from && from <= sel.to) { + diagnostics.push({ + range: {start: offsetToLspPos(doc, from), end: offsetToLspPos(doc, to)}, + message: d.message, + severity: SEVERITY[d.severity] ?? 1 + }) + } + }) + const params = { + textDocument: {uri: plugin.documentUri}, + range: {start: offsetToLspPos(doc, sel.from), end: offsetToLspPos(doc, sel.to)}, + context: {diagnostics} + } + try { + const res = await client.request('textDocument/codeAction', params, 10000) + return Array.isArray(res) ? res : [] + } + catch { + return [] + } +} + +/** + * 应用一个代码操作:先 resolve 补全 edit,应用当前文件的 WorkspaceEdit, + * 再尽力执行其 command。返回涉及但未自动应用的其它文件数。 + */ +export const applyCodeAction = async (view: EditorView, action: any): Promise<{otherFiles: number}> => { + const plugin: any = view.plugin(languageServerPlugin as any) + const client = plugin?.client + let act = action + // 惰性 edit:无 edit 但有 data 且服务器支持 resolve 时,先解析 + if (act && !act.edit && act.data !== undefined && client?.capabilities?.codeActionProvider?.resolveProvider) { + try { + act = await client.request('codeAction/resolve', act, 10000) + } + catch { /* 解析失败则按原样处理 */ } + } + + let otherFiles = 0 + const edit = act?.edit + if (edit) { + // 汇总每个文件的 TextEdit[] + const perUri: Record = {} + if (edit.changes) { + for (const [uri, edits] of Object.entries(edit.changes)) { + perUri[uri] = (perUri[uri] || []).concat(edits) + } + } + if (Array.isArray(edit.documentChanges)) { + for (const dc of edit.documentChanges) { + if (dc?.textDocument?.uri && Array.isArray(dc.edits)) { + perUri[dc.textDocument.uri] = (perUri[dc.textDocument.uri] || []).concat(dc.edits) + } + } + } + const doc = view.state.doc + const current = perUri[plugin.documentUri] + if (current?.length) { + const changes = current + .map((te: any) => ({ + from: lspPosToOffset(doc, te.range.start), + to: lspPosToOffset(doc, te.range.end), + insert: te.newText ?? '' + })) + .sort((a, b) => a.from - b.from) + view.dispatch({changes}) + } + otherFiles = Object.keys(perUri).filter(u => u !== plugin.documentUri && perUri[u]?.length).length + } + + // command:Command 形如 {title, command:string, arguments};CodeAction.command 为其对象 + const cmd = typeof act?.command === 'string' + ? {command: act.command, arguments: act.arguments} + : act?.command + if (cmd?.command && client) { + try { + await client.request('workspace/executeCommand', {command: cmd.command, arguments: cmd.arguments}, 10000) + } + catch { /* 忽略命令执行失败 */ } + } + return {otherFiles} +} + +/** + * 触发代码操作:请求后派发 lsp:code-actions(携带动作与锚点坐标)由 App 弹菜单。 + * 供 Cmd+. 键位与右键菜单共用。 + */ +export const triggerCodeActions = (view: EditorView): boolean => { + const head = view.state.selection.main.head + const coords = view.coordsAtPos(head) + requestCodeActions(view).then((actions) => { + window.dispatchEvent(new CustomEvent('lsp:code-actions', { + detail: { + actions, + x: coords ? coords.left : 0, + y: coords ? coords.bottom : 0 + } + })) + }) + return true +} + // 代次:每次构建 LSP 扩展自增,过期 client 的回调据此忽略 let stateGen = 0 @@ -203,7 +330,8 @@ export async function createLspExtensions( {key: 'F2', run: renameSymbol}, {key: 'Shift-Alt-f', run: formatDocument}, {key: 'Mod-Shift-i', run: formatDocument}, - {key: 'Mod-k Mod-f', run: formatSelection} + {key: 'Mod-k Mod-f', run: formatSelection}, + {key: 'Mod-.', run: triggerCodeActions} ]) const fmt = formattingOptions.of({ tabSize: fmtOptions?.tabSize ?? 4, From d788079105fff18aef6f5723c642e3575dc4e4c6 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Mon, 15 Jun 2026 08:23:50 +0800 Subject: [PATCH 03/25] =?UTF-8?q?feat(less):=20=E6=96=B0=E5=A2=9E=20Less?= =?UTF-8?q?=20=E8=AF=AD=E8=A8=80=E6=94=AF=E6=8C=81=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 LessPlugin 并注册(lessc 编译为 CSS 输出) - 前端高亮暂复用 CSS 模式,.less 扩展名经语言注册表自动识别 - LSP 复用 vscode-css-language-server(languageId=less) - 新增 less 图标(暂用 CSS 图标占位) --- public/icons/less.svg | 8 ++++ src-tauri/src/lsp.rs | 2 +- src-tauri/src/plugins/less.rs | 55 ++++++++++++++++++++++++++ src-tauri/src/plugins/manager.rs | 2 + src-tauri/src/plugins/mod.rs | 1 + src/composables/useCodeMirrorEditor.ts | 2 + src/editor/lspExtension.ts | 3 +- 7 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 public/icons/less.svg create mode 100644 src-tauri/src/plugins/less.rs diff --git a/public/icons/less.svg b/public/icons/less.svg new file mode 100644 index 0000000..b765c26 --- /dev/null +++ b/public/icons/less.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src-tauri/src/lsp.rs b/src-tauri/src/lsp.rs index af6c8c0..d3a3cd1 100644 --- a/src-tauri/src/lsp.rs +++ b/src-tauri/src/lsp.rs @@ -48,7 +48,7 @@ fn server_cmd(language: &str) -> Option<(&'static str, Vec<&'static str>)> { "php" => Some(("intelephense", vec!["--stdio"])), "ruby" => Some(("solargraph", vec!["stdio"])), "html" => Some(("vscode-html-language-server", vec!["--stdio"])), - "css" => Some(("vscode-css-language-server", vec!["--stdio"])), + "css" | "less" | "scss" => Some(("vscode-css-language-server", vec!["--stdio"])), "json" => Some(("vscode-json-language-server", vec!["--stdio"])), "java" => Some(("jdtls", vec![])), "kotlin" => Some(("kotlin-language-server", vec![])), diff --git a/src-tauri/src/plugins/less.rs b/src-tauri/src/plugins/less.rs new file mode 100644 index 0000000..967c3d5 --- /dev/null +++ b/src-tauri/src/plugins/less.rs @@ -0,0 +1,55 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct LessPlugin; + +impl LanguagePlugin for LessPlugin { + fn get_order(&self) -> i32 { + 22 + } + + fn get_language_name(&self) -> &'static str { + "Less" + } + + fn get_language_key(&self) -> &'static str { + "less" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "less".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--version"] + } + + fn get_path_command(&self) -> String { + "lessc --version".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: String::from("less"), + before_compile: None, + extension: String::from("less"), + execute_home: None, + // 用 lessc 编译为 CSS 输出到控制台 + run_command: Some(String::from("lessc $filename")), + after_compile: None, + template: Some(String::from("// 在这里输入 Less 代码")), + 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(|| "lessc".to_string()) + } +} diff --git a/src-tauri/src/plugins/manager.rs b/src-tauri/src/plugins/manager.rs index 53b9f55..0f928fa 100644 --- a/src-tauri/src/plugins/manager.rs +++ b/src-tauri/src/plugins/manager.rs @@ -17,6 +17,7 @@ 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::less::LessPlugin; use crate::plugins::lua::LuaPlugin; use crate::plugins::markdown::MarkdownPlugin; use crate::plugins::nodejs::NodeJSPlugin; @@ -73,6 +74,7 @@ impl PluginManager { ("groovy".to_string(), Box::new(GroovyPlugin)), ("html".to_string(), Box::new(HtmlPlugin)), ("css".to_string(), Box::new(CssPlugin)), + ("less".to_string(), Box::new(LessPlugin)), ("svg".to_string(), Box::new(SvgPlugin)), ("json".to_string(), Box::new(JsonPlugin)), ("xml".to_string(), Box::new(XmlPlugin)), diff --git a/src-tauri/src/plugins/mod.rs b/src-tauri/src/plugins/mod.rs index 8cdbca7..a5bd9bf 100644 --- a/src-tauri/src/plugins/mod.rs +++ b/src-tauri/src/plugins/mod.rs @@ -416,6 +416,7 @@ pub mod javascript_jquery; pub mod javascript_nodejs; pub mod json; pub mod kotlin; +pub mod less; pub mod lua; pub mod manager; pub mod markdown; diff --git a/src/composables/useCodeMirrorEditor.ts b/src/composables/useCodeMirrorEditor.ts index 2aed75a..db8bf5f 100644 --- a/src/composables/useCodeMirrorEditor.ts +++ b/src/composables/useCodeMirrorEditor.ts @@ -356,6 +356,8 @@ export function useCodeMirrorEditor(props: Props) case 'html': return html() case 'css': + case 'less': + // Less 是 CSS 超集,暂复用 CSS 高亮(未引入 lang-less 依赖) return css() case 'svg': return xml() diff --git a/src/editor/lspExtension.ts b/src/editor/lspExtension.ts index 8ee9dfb..f737149 100644 --- a/src/editor/lspExtension.ts +++ b/src/editor/lspExtension.ts @@ -221,6 +221,7 @@ const LANGUAGE_ID: Record = { ruby: 'ruby', html: 'html', css: 'css', + less: 'less', json: 'json', java: 'java', kotlin: 'kotlin', @@ -235,7 +236,7 @@ const LANGUAGE_ID: Record = { const LANGUAGE_EXT: Record = { python: 'py', typescript: 'ts', javascript: 'js', rust: 'rs', go: 'go', c: 'c', cpp: 'cpp', 'objective-c': 'm', 'objective-cpp': 'mm', - lua: 'lua', php: 'php', ruby: 'rb', html: 'html', css: 'css', json: 'json', + lua: 'lua', php: 'php', ruby: 'rb', html: 'html', css: 'css', less: 'less', json: 'json', java: 'java', kotlin: 'kt', swift: 'swift', scala: 'scala', yaml: 'yaml', shellscript: 'sh', haskell: 'hs' } From 3fc521e83361d5ef5d7b3a1c7cb246c996741e8a Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Mon, 15 Jun 2026 08:26:06 +0800 Subject: [PATCH 04/25] =?UTF-8?q?feat(react):=20=E6=96=B0=E5=A2=9E=20React?= =?UTF-8?q?=20(JSX)=20=E8=AF=AD=E8=A8=80=E6=94=AF=E6=8C=81=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 ReactPlugin 并注册,.jsx 扩展名经语言注册表自动识别 - 运行采用浏览器预览:CDN 引入 React/ReactDOM + Babel, text/babel 在浏览器内转译 JSX 并渲染到 #root - 前端高亮用 javascript({jsx:true}) - LSP 走 typescript-language-server(languageId=javascriptreact) - 新增 React 图标 --- public/icons/react.svg | 8 ++++ src-tauri/src/lsp.rs | 2 +- src-tauri/src/plugins/manager.rs | 2 + src-tauri/src/plugins/mod.rs | 1 + src-tauri/src/plugins/react.rs | 60 ++++++++++++++++++++++++++ src/composables/useCodeMirrorEditor.ts | 2 + src/editor/lspExtension.ts | 2 + 7 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 public/icons/react.svg create mode 100644 src-tauri/src/plugins/react.rs diff --git a/public/icons/react.svg b/public/icons/react.svg new file mode 100644 index 0000000..85fad80 --- /dev/null +++ b/public/icons/react.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src-tauri/src/lsp.rs b/src-tauri/src/lsp.rs index d3a3cd1..9cb1495 100644 --- a/src-tauri/src/lsp.rs +++ b/src-tauri/src/lsp.rs @@ -38,7 +38,7 @@ fn server_cmd(language: &str) -> Option<(&'static str, Vec<&'static str>)> { match language { "python3" | "python2" | "python" => Some(("pyright-langserver", vec!["--stdio"])), "typescript" | "typescript-nodejs" | "typescript-browser" | "javascript-nodejs" - | "javascript-browser" | "javascript-jquery" | "nodejs" => { + | "javascript-browser" | "javascript-jquery" | "nodejs" | "react" => { Some(("typescript-language-server", vec!["--stdio"])) } "rust" => Some(("rust-analyzer", vec![])), diff --git a/src-tauri/src/plugins/manager.rs b/src-tauri/src/plugins/manager.rs index 0f928fa..c079b08 100644 --- a/src-tauri/src/plugins/manager.rs +++ b/src-tauri/src/plugins/manager.rs @@ -27,6 +27,7 @@ use crate::plugins::php::PHPPlugin; use crate::plugins::python2::Python2Plugin; use crate::plugins::python3::Python3Plugin; use crate::plugins::r::RPlugin; +use crate::plugins::react::ReactPlugin; use crate::plugins::ruby::RubyPlugin; use crate::plugins::rust::RustPlugin; use crate::plugins::scala::ScalaPlugin; @@ -70,6 +71,7 @@ impl PluginManager { ("ruby".to_string(), Box::new(RubyPlugin)), ("applescript".to_string(), Box::new(AppleScriptPlugin)), ("typescript".to_string(), Box::new(TypeScriptPlugin)), + ("react".to_string(), Box::new(ReactPlugin)), ("cpp".to_string(), Box::new(CppPlugin)), ("groovy".to_string(), Box::new(GroovyPlugin)), ("html".to_string(), Box::new(HtmlPlugin)), diff --git a/src-tauri/src/plugins/mod.rs b/src-tauri/src/plugins/mod.rs index a5bd9bf..66c0ff6 100644 --- a/src-tauri/src/plugins/mod.rs +++ b/src-tauri/src/plugins/mod.rs @@ -427,6 +427,7 @@ pub mod php; pub mod python2; pub mod python3; pub mod r; +pub mod react; pub mod ruby; pub mod rust; pub mod scala; diff --git a/src-tauri/src/plugins/react.rs b/src-tauri/src/plugins/react.rs new file mode 100644 index 0000000..695733b --- /dev/null +++ b/src-tauri/src/plugins/react.rs @@ -0,0 +1,60 @@ +use super::{LanguagePlugin, PluginConfig}; +use std::vec; + +pub struct ReactPlugin; + +impl LanguagePlugin for ReactPlugin { + fn get_order(&self) -> i32 { + 14 + } + + fn get_language_name(&self) -> &'static str { + "React (JSX)" + } + + fn get_language_key(&self) -> &'static str { + "react" + } + + fn get_file_extension(&self) -> String { + self.get_config() + .map(|config| config.extension.clone()) + .unwrap_or_else(|| "jsx".to_string()) + } + + fn get_version_args(&self) -> Vec<&'static str> { + vec!["--"] + } + + fn get_path_command(&self) -> String { + "which node".to_string() + } + + fn get_default_config(&self) -> PluginConfig { + PluginConfig { + enabled: true, + language: self.get_language_key().to_string(), + before_compile: None, + extension: String::from("jsx"), + execute_home: None, + // 浏览器预览:CDN 引入 React/ReactDOM + Babel,以 text/babel 在浏览器内转译 JSX。 + // 用户代码可向 #root 渲染:ReactDOM.createRoot(document.getElementById('root')).render(...) + run_command: Some(String::from( + "echo
\n\n\n\n", + )), + after_compile: None, + template: Some(String::from( + "function App() {\n return

Hello, React!

;\n}\n\nReactDOM.createRoot(document.getElementById('root')).render();", + )), + timeout: Some(30), + console_type: Some(String::from("web")), + icon_path: None, + } + } + + fn get_default_command(&self) -> String { + self.get_config() + .and_then(|config| config.run_command.clone()) + .unwrap_or_else(|| "node".to_string()) + } +} diff --git a/src/composables/useCodeMirrorEditor.ts b/src/composables/useCodeMirrorEditor.ts index db8bf5f..de5c3eb 100644 --- a/src/composables/useCodeMirrorEditor.ts +++ b/src/composables/useCodeMirrorEditor.ts @@ -324,6 +324,8 @@ export function useCodeMirrorEditor(props: Props) case 'javascript-browser': case 'javascript-nodejs': return javascript() + case 'react': + return javascript({jsx: true}) case 'go': return go() case 'java': diff --git a/src/editor/lspExtension.ts b/src/editor/lspExtension.ts index f737149..d419e8d 100644 --- a/src/editor/lspExtension.ts +++ b/src/editor/lspExtension.ts @@ -210,6 +210,7 @@ const LANGUAGE_ID: Record = { 'javascript-browser': 'javascript', 'javascript-jquery': 'javascript', nodejs: 'javascript', + react: 'javascriptreact', rust: 'rust', go: 'go', c: 'c', @@ -237,6 +238,7 @@ const LANGUAGE_EXT: Record = { python: 'py', typescript: 'ts', javascript: 'js', rust: 'rs', go: 'go', c: 'c', cpp: 'cpp', 'objective-c': 'm', 'objective-cpp': 'mm', lua: 'lua', php: 'php', ruby: 'rb', html: 'html', css: 'css', less: 'less', json: 'json', + javascriptreact: 'jsx', java: 'java', kotlin: 'kt', swift: 'swift', scala: 'scala', yaml: 'yaml', shellscript: 'sh', haskell: 'hs' } From a071ca15180c49d486c4270c4f0d11211922ed33 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Mon, 15 Jun 2026 08:32:09 +0800 Subject: [PATCH 05/25] =?UTF-8?q?feat(db):=20=E6=96=B0=E5=A2=9E=20PostgreS?= =?UTF-8?q?QL=20=E6=95=B0=E6=8D=AE=E6=BA=90=E6=94=AF=E6=8C=81=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 PostgresExecutor(同步 postgres 驱动,simple_query 执行)并注册 - 数据库设置支持新增/编辑 PostgreSQL 连接(默认端口 5432) - SchemaBrowser 增加 PostgreSQL 库表 introspection 分支 - SSL/SSH 隧道为独立议题(#93),当前用 NoTls;DDL 复制暂不支持 --- src-tauri/Cargo.lock | 153 +++++++++++++++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/db/mod.rs | 2 + src-tauri/src/db/postgres.rs | 82 +++++++++++++++ src/components/SchemaBrowser.vue | 10 ++ src/components/setting/Database.vue | 20 ++-- src/composables/useDbConnections.ts | 2 +- 7 files changed, 258 insertions(+), 12 deletions(-) create mode 100644 src-tauri/src/db/postgres.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f4f47d7..48e3ca4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -17,6 +17,7 @@ dependencies = [ "mysql", "notify", "portable-pty", + "postgres", "regex", "reqwest 0.11.27", "rfd 0.15.4", @@ -1347,6 +1348,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1534,6 +1541,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -2730,6 +2738,16 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.5" @@ -3630,6 +3648,49 @@ dependencies = [ "winreg 0.10.1", ] +[[package]] +name = "postgres" +version = "0.19.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c48ece1c6cda0db61b058c1721378da76855140e9214339fa1317decacb176" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "futures-util", + "log", + "tokio", + "tokio-postgres", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee9dd5fe15055d2b6806f4736aa0c9637217074e224bbec46d4041b91bb9491" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "hmac", + "md-5", + "memchr", + "rand 0.9.2", + "sha2", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b858f82211e84682fecd373f68e1ceae642d8d751a1ebd13f33de6257b3e20" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "postgres-protocol", +] + [[package]] name = "potential_utf" version = "0.1.2" @@ -4033,7 +4094,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ "bitflags 2.12.1", - "fallible-iterator", + "fallible-iterator 0.3.0", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", @@ -4707,6 +4768,17 @@ dependencies = [ "quote", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strip-ansi-escapes" version = "0.2.1" @@ -5325,6 +5397,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.47.1" @@ -5352,6 +5439,32 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-postgres" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b40d66d9b2cfe04b628173409368e58247e8eddbbd3b0e6c6ba1d09f20f6c9e" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.9.2", + "socket2 0.6.0", + "tokio", + "tokio-util", + "whoami", +] + [[package]] name = "tokio-util" version = "0.7.16" @@ -5663,12 +5776,33 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -5816,6 +5950,12 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.122" @@ -6087,6 +6227,17 @@ dependencies = [ "windows-core", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2206837..12aaf67 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,6 +36,7 @@ rfd = "0.15" fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" } async-trait = "0.1" mysql = { version = "25", default-features = false, features = ["minimal", "rustls-tls"] } +postgres = "0.19" # 固定 mysql_common 的构建依赖 subprocess 到不依赖 let 链的旧版,兼容当前 Rust 工具链 subprocess = "=0.2.9" portable-pty = "0.8" diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index a4eed95..d9f3931 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -2,6 +2,7 @@ //! 每种数据库类型实现 `DbExecutor` 并在 `executors()` 中注册一行,新增类型互不影响。 mod mysql; +mod postgres; mod sqlite; use serde::{Deserialize, Serialize}; @@ -63,6 +64,7 @@ fn executors() -> Vec> { vec![ Box::new(sqlite::SqliteExecutor), Box::new(mysql::MysqlExecutor), + Box::new(postgres::PostgresExecutor), ] } diff --git a/src-tauri/src/db/postgres.rs b/src-tauri/src/db/postgres.rs new file mode 100644 index 0000000..9229de9 --- /dev/null +++ b/src-tauri/src/db/postgres.rs @@ -0,0 +1,82 @@ +use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult}; +use postgres::{Config, NoTls, SimpleQueryMessage}; +use serde_json::Value as JsonValue; + +pub(crate) struct PostgresExecutor; + +impl DbExecutor for PostgresExecutor { + fn handles(&self, kind: &str) -> bool { + kind == "postgres" || kind == "postgresql" + } + + fn run(&self, sql: &str, source: &DataSource) -> SqlRunResult { + let mut result = SqlRunResult::new(); + + let mut cfg = Config::new(); + cfg.host(source.host.as_deref().unwrap_or("127.0.0.1")) + .port(source.port.unwrap_or(5432)) + .user(source.user.as_deref().unwrap_or("postgres")); + if let Some(pwd) = source.password.as_deref() { + cfg.password(pwd); + } + if let Some(db) = source.database.as_deref() { + cfg.dbname(db); + } + + // SSL/SSH 隧道为独立议题(#93),此处暂用 NoTls + let mut client = match cfg.connect(NoTls) { + Ok(c) => c, + Err(e) => { + result.error = Some(format!("连接 PostgreSQL 失败: {}", e)); + return result; + } + }; + + // simple_query 一次执行整段脚本,按消息流切分结果集与影响行数 + let messages = match client.simple_query(sql) { + Ok(m) => m, + Err(e) => { + result.error = Some(e.to_string()); + return result; + } + }; + + let mut columns: Vec = Vec::new(); + let mut rows: Vec> = Vec::new(); + let flush = |columns: &mut Vec, rows: &mut Vec>, result: &mut SqlRunResult| { + if !columns.is_empty() { + result.result_sets.push(SqlResultSet { + columns: std::mem::take(columns), + rows: std::mem::take(rows), + }); + } + }; + + for msg in messages { + match msg { + SimpleQueryMessage::Row(row) => { + if columns.is_empty() { + columns = row.columns().iter().map(|c| c.name().to_string()).collect(); + } + let vals = (0..row.len()) + .map(|i| match row.get(i) { + Some(s) => JsonValue::from(s.to_string()), + None => JsonValue::Null, + }) + .collect(); + rows.push(vals); + } + SimpleQueryMessage::CommandComplete(n) => { + if columns.is_empty() { + result.messages.push(format!("OK,影响 {} 行", n)); + } else { + flush(&mut columns, &mut rows, &mut result); + } + } + _ => {} + } + } + flush(&mut columns, &mut rows, &mut result); + result + } +} diff --git a/src/components/SchemaBrowser.vue b/src/components/SchemaBrowser.vue index dc977de..37f2f24 100644 --- a/src/components/SchemaBrowser.vue +++ b/src/components/SchemaBrowser.vue @@ -153,6 +153,12 @@ const tablesSql = (kind: string, db?: string): string => { + `FROM information_schema.columns WHERE table_schema = '${esc(db || '')}' ` + 'ORDER BY table_name, ordinal_position' } + if (kind === 'postgres') { + return 'SELECT table_name AS tbl, column_name AS col, data_type AS typ ' + + 'FROM information_schema.columns ' + + "WHERE table_schema NOT IN ('pg_catalog', 'information_schema') " + + 'ORDER BY table_name, ordinal_position' + } return 'SELECT m.name AS tbl, p.name AS col, p.type AS typ ' + 'FROM sqlite_master m JOIN pragma_table_info(m.name) p ' + "WHERE m.type IN ('table','view') AND m.name NOT LIKE 'sqlite\\_%' ESCAPE '\\' " @@ -268,6 +274,10 @@ const exportCsv = async (name: string, db?: string) => { const copyDdl = async (name: string, db?: string) => { try { const source = resolveActiveSource() + if (source.kind === 'postgres') { + toast.info('PostgreSQL 暂不支持一键复制建表语句') + return + } const qualified = db ? `${quote(source.kind, db)}.${quote(source.kind, name)}` : quote(source.kind, name) const sql = source.kind === 'mysql' ? `SHOW CREATE TABLE ${qualified}` diff --git a/src/components/setting/Database.vue b/src/components/setting/Database.vue index f7df408..4abffa2 100644 --- a/src/components/setting/Database.vue +++ b/src/components/setting/Database.vue @@ -8,11 +8,11 @@
{{ c.kind }} + :class="c.kind === 'mysql' ? 'bg-orange-100 dark:bg-orange-900/40 text-orange-600 dark:text-orange-300' : c.kind === 'postgres' ? 'bg-sky-100 dark:bg-sky-900/40 text-sky-600 dark:text-sky-300' : 'bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-300'">{{ c.kind }}
{{ c.name }}
- {{ c.kind === 'mysql' ? `${c.user || ''}@${c.host || ''}:${c.port || 3306}/${c.database || ''}` : c.file }} + {{ c.kind === 'sqlite' ? c.file : `${c.user || ''}@${c.host || ''}:${c.port || (c.kind === 'postgres' ? 5432 : 3306)}/${c.database || ''}` }}
-