Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "codeforge",
"private": true,
"version": "26.1.0",
"version": "26.2.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "CodeForge"
version = "26.1.0"
version = "26.2.0"
description = "CodeForge 是一款轻量级、高性能的桌面代码执行器,专为开发者、学生和编程爱好者设计。"
authors = ["devlive-community"]
edition = "2024"
Expand Down
121 changes: 103 additions & 18 deletions src-tauri/src/lsp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ use serde::Serialize;
use std::collections::HashMap;
use std::io::{BufReader, Read, Write};
use std::path::PathBuf;
use std::process::{Child, ChildStdin, Command, Stdio};
use std::process::{Child, Command, Stdio};
use std::sync::Mutex as StdMutex;
use tauri::{AppHandle, Emitter, State};

struct Server {
child: Child,
stdin: ChildStdin,
// 写入通过独立线程,避免 stdin 写阻塞主线程(语言服务器索引时可能来不及读)
tx: std::sync::mpsc::Sender<Vec<u8>>,
}

pub struct LspState {
Expand All @@ -27,9 +28,9 @@ impl LspState {
}

#[derive(Clone, Serialize)]
struct LspEvent {
struct LspBatch {
language: String,
message: String,
messages: Vec<String>,
}

/// 语言 -> (可执行名, 参数)。新增语言在此加一行。
Expand All @@ -49,6 +50,13 @@ fn server_cmd(language: &str) -> Option<(&'static str, Vec<&'static str>)> {
"html" => Some(("vscode-html-language-server", vec!["--stdio"])),
"css" => 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![])),
"swift" => Some(("sourcekit-lsp", vec![])),
"scala" => Some(("metals", vec![])),
"yaml" => Some(("yaml-language-server", vec!["--stdio"])),
"shell" => Some(("bash-language-server", vec!["start"])),
"haskell" => Some(("haskell-language-server-wrapper", vec!["--lsp"])),
_ => None,
}
}
Expand Down Expand Up @@ -106,6 +114,43 @@ fn server_defs() -> Vec<(&'static str, &'static str, &'static str, &'static str)
"vscode-html-language-server",
"npm i -g vscode-langservers-extracted",
),
("java", "Java (jdtls)", "jdtls", "brew install jdtls"),
(
"kotlin",
"Kotlin",
"kotlin-language-server",
"brew install kotlin-language-server",
),
(
"swift",
"Swift (sourcekit-lsp)",
"sourcekit-lsp",
"xcode-select --install",
),
(
"scala",
"Scala (metals)",
"metals",
"coursier install metals",
),
(
"yaml",
"YAML",
"yaml-language-server",
"npm i -g yaml-language-server",
),
(
"shell",
"Shell / Bash",
"bash-language-server",
"npm i -g bash-language-server",
),
(
"haskell",
"Haskell (HLS)",
"haskell-language-server-wrapper",
"ghcup install hls",
),
]
}

Expand Down Expand Up @@ -308,21 +353,45 @@ pub fn lsp_start(
let stdout = child.stdout.take().ok_or("无法获取 stdout")?;
let stderr = child.stderr.take();

// 读取线程:解析 Content-Length 帧,原样转发 JSON 给前端
let app_reader = app.clone();
// 读取线程:解析 Content-Length 帧,把消息体丢进 channel
let (msg_tx, msg_rx) = std::sync::mpsc::channel::<String>();
let lang_reader = language.clone();
std::thread::spawn(move || {
let mut reader = BufReader::new(stdout);
while let Some(body) = read_message(&mut reader) {
if msg_tx.send(body).is_err() {
break;
}
}
// 发送端 drop → 下面的 emitter 收到 Err 后发 lsp:exit
let _ = &lang_reader;
});

// 发射线程:合批转发。索引时语言服务器会突发成千上万条通知,
// 逐条 emit 会让 webview 主线程被 IPC 反序列化压垮(编辑器/环境检查随之卡死)。
// 这里把"此刻已排队"的消息一次性打包成一个事件,空闲时则单条即时下发。
let app_reader = app.clone();
let lang_emit = language.clone();
std::thread::spawn(move || {
// 阻塞等待第一条;channel 关闭(语言服务器退出)时 recv 返回 Err,循环结束
while let Ok(first) = msg_rx.recv() {
let mut batch = vec![first];
// 排空当前已到达的消息,凑成一批(上限防止单批过大)
while let Ok(m) = msg_rx.try_recv() {
batch.push(m);
if batch.len() >= 512 {
break;
}
}
let _ = app_reader.emit(
"lsp:message",
LspEvent {
language: lang_reader.clone(),
message: body,
"lsp:messages",
LspBatch {
language: lang_emit.clone(),
messages: batch,
},
);
}
let _ = app_reader.emit("lsp:exit", lang_reader.clone());
let _ = app_reader.emit("lsp:exit", lang_emit.clone());
});

// 排空 stderr,避免阻塞
Expand All @@ -337,11 +406,27 @@ pub fn lsp_start(
});
}

// 写入线程:独占 stdin,从 channel 取帧写入。
// 这样 lsp_send 只需把帧塞进 channel(瞬时返回),即便语言服务器索引时
// 不读 stdin 导致管道写阻塞,也只阻塞这个后台线程,不会卡住主线程的命令循环。
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
std::thread::spawn(move || {
let mut stdin = stdin;
while let Ok(frame) = rx.recv() {
if stdin.write_all(&frame).is_err() {
break;
}
if stdin.flush().is_err() {
break;
}
}
});

state
.servers
.lock()
.map_err(|e| e.to_string())?
.insert(language, Server { child, stdin });
.insert(language, Server { child, tx });
Ok(true)
}

Expand All @@ -352,16 +437,16 @@ pub fn lsp_send(
language: String,
message: String,
) -> Result<(), String> {
let mut servers = state.servers.lock().map_err(|e| e.to_string())?;
let servers = state.servers.lock().map_err(|e| e.to_string())?;
let server = servers
.get_mut(&language)
.get(&language)
.ok_or_else(|| "语言服务器未启动".to_string())?;
let frame = format!("Content-Length: {}\r\n\r\n{}", message.len(), message);
// 仅入队,不在持锁/主线程上做阻塞写
server
.stdin
.write_all(frame.as_bytes())
.map_err(|e| e.to_string())?;
server.stdin.flush().map_err(|e| e.to_string())?;
.tx
.send(frame.into_bytes())
.map_err(|_| "语言服务器写入通道已关闭".to_string())?;
Ok(())
}

Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CodeForge",
"version": "26.1.0",
"version": "26.2.0",
"identifier": "org.devlive.codeforge",
"build": {
"beforeDevCommand": "pnpm dev",
Expand Down
94 changes: 89 additions & 5 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@
@close="showTerminal = false; terminalMounted = false"/>

<!-- 状态栏 -->
<StatusBar class="flex-shrink-0" :env-info="envInfo" :is-loading="isLoadingEnvInfo" :execution-time="lastExecutionTime" :code-length="(code || '').length" @check-environment="refreshEnvInfo" @toggle-terminal="toggleTerminal"/>
<StatusBar class="flex-shrink-0" :env-info="envInfo" :is-loading="isLoadingEnvInfo" :execution-time="lastExecutionTime" :code-length="(code || '').length" @check-environment="refreshEnvInfo" @toggle-terminal="toggleTerminal" @toggle-problems="showDiagnostics = !showDiagnostics"/>

<!-- 关于组件 -->
<About v-if="showAbout" @close="closeAbout"/>
Expand Down Expand Up @@ -360,18 +360,47 @@
@refresh="refreshGitStatus"
@close="showGit = false"/>

<!-- LSP 问题面板 -->
<DiagnosticsPanel v-if="showDiagnostics"
@go="(line, col) => gotoLine(line, col)"
@close="showDiagnostics = false"/>

<!-- 编辑器 LSP 右键菜单 -->
<div v-if="editorCtx.visible" class="fixed inset-0 z-50" @click="closeEditorCtx" @contextmenu.prevent="closeEditorCtx">
<div class="absolute bg-white dark:bg-gray-800 dark:text-gray-100 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 py-1 text-sm min-w-[170px]"
:style="{ top: `${editorCtx.y}px`, left: `${editorCtx.x}px` }"
@click.stop>
<button class="flex w-full items-center justify-between px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer" @click="runEditorCommand(runGotoDefinition)">
<span>跳转到定义</span><span class="text-gray-400 text-xs ml-6">F12</span>
</button>
<button class="flex w-full items-center justify-between px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer" @click="runEditorCommand(renameSymbol)">
<span>重命名符号</span><span class="text-gray-400 text-xs ml-6">F2</span>
</button>
<div class="border-t border-gray-100 dark:border-gray-700 my-1"></div>
<button class="flex w-full items-center justify-between px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer" @click="runEditorCommand(formatDocument)">
<span>格式化文档</span><span class="text-gray-400 text-xs ml-6">⇧⌥F</span>
</button>
<button class="w-full text-left px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer" @click="runEditorCommand(formatSelection)">
格式化选中
</button>
</div>
</div>

<!-- Toast 组件 -->
<Toast/>
</div>
</template>

<script setup lang="ts">
import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue'
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 {ChevronRight, Code2, CornerDownRight, Eye, FolderOpen, GitBranch, GitCompare, History, ListTree, Maximize2, Monitor, Moon, PanelBottom, PanelLeft, PanelRight, Play, Plus, Save, Search, Settings as SettingsIcon, Sparkles, Sun, Terminal as TerminalIcon, X} from 'lucide-vue-next'
import {ExecutionResult, LayoutMode, SplitDirection} from './types/app.ts'
import AppHeader from './components/AppHeader.vue'
import CodeEditor from './components/CodeEditor.vue'
import DiagnosticsPanel from './components/DiagnosticsPanel.vue'
import ConsoleOutput from './components/ConsoleOutput.vue'
import WebOutput from "./components/WebOutput.vue";
import JsonView from "./components/JsonView.vue";
Expand Down Expand Up @@ -942,7 +971,11 @@ const confirmApplyAi = () => {
}

// 当前 CodeMirror view(用于在光标处插入生成的代码)
const editorView = ref<any>(null)
// shallowRef:EditorView 是含 LSP client/plugin 等大量可变内部状态的对象,
// 绝不能被 Vue 深度响应式代理。否则 watch(editorView) 会在 view 内部每次 mutation 时触发,
// 与 applyDiffMarkers 的 dispatch 形成 mutation→watch→dispatch→mutation 无限循环导致整页卡死
// (LSP 接入后 client 持续 mutate 会立刻触发该循环)。
const editorView = shallowRef<any>(null)

// AI 自然语言生成 / 选区改写
const showGenerate = ref(false)
Expand Down Expand Up @@ -981,14 +1014,16 @@ const openSearch = () => {
showSearch.value = true
}

const gotoLine = (line: number) => {
const gotoLine = (line: number, character?: number) => {
const view = editorView.value
if (!view) {
return
}
const target = Math.max(1, Math.min(line, view.state.doc.lines))
const l = view.state.doc.line(target)
view.dispatch({selection: {anchor: l.from}, scrollIntoView: true})
// 带列号时精确定位到列(夹在行内),否则定位到行首
const anchor = character != null ? Math.min(l.from + character, l.to) : l.from
view.dispatch({selection: {anchor}, scrollIntoView: true})
view.focus()
}

Expand Down Expand Up @@ -1040,6 +1075,51 @@ const openSearchResult = async (path: string, line: number) => {
gotoLine(line)
}

// LSP 跨文件跳转定义:编辑器扩展派发 lsp:open-location,这里打开目标文件并定位
const onLspOpenLocation = async (e: Event) => {
const detail = (e as CustomEvent).detail as {path: string; line: number; character?: number}
if (!detail?.path) {
return
}
await smartOpen(detail.path)
await nextTick()
gotoLine(detail.line, detail.character)
}

// LSP 问题面板显隐
const showDiagnostics = ref(false)

// ===== 编辑器 LSP 右键菜单(跳转定义 / 重命名 / 格式化)=====
const editorCtx = reactive({visible: false, x: 0, y: 0})
const closeEditorCtx = () => {
editorCtx.visible = false
}
const onEditorContext = (e: MouseEvent) => {
const target = e.target as HTMLElement | null
// 仅在编辑器内容区、且当前语言支持 LSP 时弹出
if (!target?.closest('.cm-content') || !lspSupportsLanguage(currentLanguage.value) || !editorView.value) {
return
}
e.preventDefault()
// 将光标移到右键处,使命令作用于点击位置
const view = editorView.value
const pos = view.posAtCoords({x: e.clientX, y: e.clientY})
if (pos != null) {
view.dispatch({selection: {anchor: pos}})
}
// 夹取到视口内,避免贴边裁切(菜单约 180×180)
editorCtx.x = Math.min(e.clientX, window.innerWidth - 190)
editorCtx.y = Math.min(e.clientY, window.innerHeight - 190)
editorCtx.visible = true
}
const runEditorCommand = (cmd: (v: any) => boolean) => {
closeEditorCtx()
// 不在此处 focus 编辑器:重命名会弹出需要焦点的内联输入框
if (editorView.value) {
cmd(editorView.value)
}
}

// 全局替换后:刷新涉及到的已打开标签(保留有未保存修改的标签)
const reloadAffectedFiles = async (paths: string[]) => {
const set = new Set(paths)
Expand Down Expand Up @@ -1653,6 +1733,8 @@ onMounted(async () => {
await restoreSession()

window.addEventListener('keydown', onGlobalKeydown, true)
window.addEventListener('lsp:open-location', onLspOpenLocation)
window.addEventListener('contextmenu', onEditorContext)

// 触发 app-ready 事件,通知主进程
window.dispatchEvent(new CustomEvent('app-ready'))
Expand All @@ -1661,5 +1743,7 @@ onMounted(async () => {
onUnmounted(() => {
cleanupEventListeners()
window.removeEventListener('keydown', onGlobalKeydown, true)
window.removeEventListener('lsp:open-location', onLspOpenLocation)
window.removeEventListener('contextmenu', onEditorContext)
})
</script>
Loading
Loading