Skip to main content

yggdrasil/components/code_runner/
runner.rs

1//! CodeRunner 组件实现:源码 + 运行按钮 + SSE 流式输出 + xterm.js 终端。
2//!
3//! 编辑器挂载:组件在 WASM 端按自身 `container_id` 调用
4//! `codemirror::get_module().create(...)` 挂载 CodeMirror,`onChange`
5//! 回写到内部 `source_signal`,`use_drop` 时销毁实例。范式镜像 SQL 控制台
6//!(`src/pages/admin/system.rs`)与 Tiptap 编辑器(`src/pages/admin/write.rs`)。
7//!
8//! 输出渲染:WASM 端按 `output_container_id` 调用 `xterm::get_module().create(...)`
9//! 挂载 xterm.js 终端(输出专用,无 stdin),SSE stdout/stderr 事件实时写入。
10//! SSE 不可用时降级到轮询 get_exec_result,整段写入终端(writeAll)。
11
12use dioxus::prelude::*;
13
14#[cfg(target_arch = "wasm32")]
15use crate::api::code_runner::execute::start_exec_stream;
16#[cfg(not(target_arch = "wasm32"))]
17use crate::api::code_runner::execute::{get_exec_result, start_exec};
18use crate::api::code_runner::{ExecRequest, ExecResult, ExecStatus};
19#[cfg(target_arch = "wasm32")]
20use crate::bridges::library::library_ready;
21use crate::bridges::library::{use_browser_library, LibraryLoadError};
22use crate::components::skeletons::atoms::SkeletonBox;
23use crate::components::ui::{BTN_PRIMARY_SM, SPINNER_SVG};
24use crate::infra::runner_config::ResourceLimits;
25#[cfg(not(target_arch = "wasm32"))]
26use crate::utils::time::sleep_ms;
27
28/// 轮询间隔(毫秒)。仅 server 端 run_code 占位路径使用。
29#[cfg(not(target_arch = "wasm32"))]
30const POLL_INTERVAL_MS: u32 = 500;
31/// 轮询最大次数兜底,避免任务卡在 Running 状态时无限轮询。
32#[cfg(not(target_arch = "wasm32"))]
33const MAX_POLLS: u32 = 240; // 500ms * 240 = 120s 上限
34
35/// 代码运行器组件。
36///
37/// Props:
38/// - `source`:初始源码(首次挂载用于初始化编辑器;之后编辑器内容是唯一真源)。
39/// - `language`:语言标识(python / node 等)。
40/// - `overrides`:可选资源限制覆盖。
41/// - `instance_id`:实例在父级片段序列中的索引,用作 CodeMirror 容器 id 后缀。
42///   必须是 **SSR/hydration 确定性**的值(如父组件 `for (i, ..)` 的索引 `i`)——
43///   Dioxus hydration 不传递 use_hook 状态,任何在 use_hook 内基于运行时状态
44///   (时间戳 / 随机 / ScopeId)生成的 id,在 SSR 与 hydration 两端会不一致,
45///   导致 CodeMirror `create()` 在 hydration 时找不到 SSR 渲染的容器元素。
46///
47/// `mut` 信号在 WASM 与 server 两套 run_code(#[cfg] 分支)中的 .set() 调用点不同,
48/// 导致任一目标构建都有一组 mut binding 被判为 unused。两端都 allow 放行。
49#[component]
50#[allow(unused_mut)]
51pub fn CodeRunner(
52    source: String,
53    language: String,
54    overrides: Option<ResourceLimits>,
55    instance_id: usize,
56) -> Element {
57    let mut running = use_signal(|| false);
58    let mut stage = use_signal(String::new);
59    let mut exit_info = use_signal(String::new);
60    let mut error_msg = use_signal(String::new);
61    // 输出区可见性:点运行后置 true,控制输出区按需出现(而非页面加载就显示空区)。
62    let mut show_output = use_signal(|| false);
63    // 是否已收到首个输出 chunk:收到后骨架屏消失,露出终端实时渲染。
64    let mut has_output = use_signal(|| false);
65    let editor_library = use_browser_library("codemirror", || true);
66    let terminal_library = use_browser_library("xterm", move || *show_output.read());
67    // 终端首次加载期间先保存运行请求,挂载后才开始执行,避免丢掉前几个输出块。
68    #[cfg(target_arch = "wasm32")]
69    let mut pending_exec: Signal<Option<ExecRequest>> = use_signal(|| None);
70
71    // 编辑器内容的唯一真源;初始化为 prop 值。
72    // C4 修复:删去 source_prop_signal。旧代码在 render body 里对它做镜像 .set()
73    // (if source != *source_prop_signal.read() { .set(...) }),这是项目
74    // dioxus-render-purity skill 明令禁止的反模式,且会触发一次额外渲染。
75    // Dioxus 0.7 的 prop 本身是响应式的——use_effect 闭包内捕获 prop 副本即可在
76    // prop 变化时(如 admin 切换语言重置示例代码)自动重跑,无需中间 signal。
77    let mut source_signal = use_signal(|| source.clone());
78
79    // CodeMirror 容器 id:直接由确定性 prop 派生(不进 use_hook)。
80    // instance_id 由父组件从纯函数片段解析的索引传入,SSR 与 hydration 同一 content_html
81    // → 同一片段序列 → 同一索引 → 同一 id,故 hydration 时 create() 能找到 SSR 渲染的容器。
82    let container_id = format!("code-runner-{instance_id}");
83
84    // xterm.js 输出终端容器 id:同样的确定性派生逻辑,SSR 与 hydration 一致。
85    let output_container_id = format!("code-runner-output-{instance_id}");
86
87    // xterm.js 终端实例句柄(仅 WASM):声明在 cfg block 外的组件作用域,
88    // 使 run_code 的 WASM 版闭包能捕获它。server 构建整行不存在。
89    #[cfg(target_arch = "wasm32")]
90    let mut term_handle: Signal<Option<crate::bridges::xterm::TerminalHandle>> =
91        use_signal(|| None);
92
93    // 编辑器是否已挂载就绪。声明在 cfg 块外,使 SSR 端也能读取:
94    // SSR 与 hydration 完成前为 false → 容器内渲染骨架屏;CodeMirror 挂载后置 true
95    // → 骨架屏从 DOM 移除,露出真实编辑器。
96    let mut editor_ready = use_signal(|| false);
97
98    // Vim 模式状态(通过 localStorage 持久化偏好,默认开启)
99    let mut vim_enabled = use_signal(|| {
100        #[cfg(target_arch = "wasm32")]
101        {
102            if let Some(window) = web_sys::window() {
103                if let Ok(Some(storage)) = window.local_storage() {
104                    if let Ok(Some(val)) = storage.get_item("yggdrasil-code-runner-vim") {
105                        return val == "true";
106                    }
107                }
108            }
109        }
110        true
111    });
112
113    let toggle_vim = move |_| {
114        let next = !vim_enabled();
115        vim_enabled.set(next);
116        #[cfg(target_arch = "wasm32")]
117        {
118            if let Some(window) = web_sys::window() {
119                if let Ok(Some(storage)) = window.local_storage() {
120                    let _ = storage.set_item("yggdrasil-code-runner-vim", &next.to_string());
121                }
122            }
123        }
124    };
125
126    // —— CodeMirror 挂载(仅 WASM)——
127    // 范式镜像 src/pages/admin/system.rs 的 SQL 控制台与 src/pages/admin/write.rs 的 Tiptap。
128    #[cfg(target_arch = "wasm32")]
129    {
130        use crate::bridges::codemirror;
131        use crate::theme::{use_resolved_theme, ResolvedTheme};
132        use wasm_bindgen::closure::Closure;
133
134        let mut editor_handle: Signal<Option<codemirror::EditorHandle>> = use_signal(|| None);
135        // 在 cfg 块顶层(渲染路径)取主题 memo,move 进下方 effect 闭包;闭包内只
136        // 调用 memo(resolved())读值——调用 memo 本身不是 hook,use_resolved_theme
137        // 这个 hook 必须在组件体顶层调用,不能放进 use_effect 闭包(dx check 会报)。
138        let resolved_theme = use_resolved_theme();
139
140        // 首次挂载:构造 closure + options,create 后存进 editor_handle。
141        // 用 resolved() 读取主题作为初始值(同时订阅,但主题切换由下方独立 effect 处理)。
142        let mount_language = language.clone();
143        let mount_container_id = container_id.clone();
144        use_effect(move || {
145            if editor_handle.read().is_some() {
146                return; // 防重复 init
147            }
148            if !library_ready(editor_library) {
149                return;
150            }
151
152            // onChange 回写到 source_signal(编辑器内容 = 唯一真源)。
153            let on_change = Closure::new({
154                let mut sig = source_signal;
155                move |v: String| sig.set(v)
156            });
157            let on_ready = Closure::new(|| {});
158            // CodeRunner 不使用 Ctrl+Enter 运行快捷键(它有自己的运行按钮),
159            // 但 EditorHandle 签名要求该闭包,传 no-op 满足生命周期。
160            let on_run_shortcut = Closure::new(|| {});
161
162            let theme_name = if resolved_theme() == ResolvedTheme::Dark {
163                "dark"
164            } else {
165                "light"
166            };
167
168            let opts = codemirror::EditorOptions::new();
169            opts.set_language(&mount_language);
170            opts.set_theme(theme_name);
171            opts.set_vim(*vim_enabled.read());
172            opts.set_value(&source_signal.read());
173            opts.set_on_change(&on_change);
174            opts.set_on_ready(&on_ready);
175            opts.set_on_run_shortcut(&on_run_shortcut);
176
177            if let Ok(Some(inst)) = codemirror::get_module().create(&mount_container_id, &opts) {
178                let handle =
179                    codemirror::EditorHandle::new(inst, on_change, on_ready, on_run_shortcut);
180                editor_handle.set(Some(handle));
181                editor_ready.set(true);
182            }
183        });
184
185        // 主题切换(含 System 模式下系统偏好变化)时同步编辑器主题。
186        //
187        // VT 动画期间跳过:手动点击主题按钮时,__startThemeTransition 已在 VT 回调内
188        // 通过 'yggdrasil:theme-change' 事件同步调了 setTheme(出现在 NEW 快照里)。
189        // 但 use_effect 在 theme.set(next) 后立即触发——早于 VT 回调(异步),会直接改
190        // 实时 DOM 的编辑器背景。VT 动画播的是伪元素快照,实时 DOM 改动会穿透伪元素,
191        // 表现为「圆形还没展开到代码块,代码块就整体瞬切」。is-theme-transitioning
192        // 期间跳过,让 VT 事件负责换肤;动画结束后此 effect 会因 resolved 信号变化重跑
193        // (此时 is-theme-transitioning 已移除),做一次幂等的兜底同步。
194        use_effect(move || {
195            #[cfg(target_arch = "wasm32")]
196            {
197                let transitioning = web_sys::window()
198                    .and_then(|w| w.document())
199                    .and_then(|d| d.document_element())
200                    .map(|el| el.class_list().contains("is-theme-transitioning"))
201                    .unwrap_or(false);
202                if transitioning {
203                    return;
204                }
205            }
206            if let Some(h) = editor_handle.read().as_ref() {
207                h.instance()
208                    .set_theme(if resolved_theme() == ResolvedTheme::Dark {
209                        "dark"
210                    } else {
211                        "light"
212                    });
213            }
214        });
215
216        // 监听 vim 模式切换并同步。
217        use_effect(move || {
218            let enabled = vim_enabled();
219            if let Some(h) = editor_handle.read().as_ref() {
220                h.instance().set_vim(enabled);
221            }
222        });
223
224        // 组件卸载时销毁 CodeMirror 实例(EditorHandle::drop → instance.destroy)。
225        use_drop(move || {
226            editor_handle.set(None);
227            editor_ready.set(false);
228        });
229    }
230
231    // —— xterm.js 终端挂载(仅 WASM,输出专用)——
232    // 范式镜像 CodeMirror 挂载:get_module().create() → TerminalHandle,use_drop 销毁。
233    #[cfg(target_arch = "wasm32")]
234    {
235        use crate::bridges::xterm;
236        use crate::theme::{use_resolved_theme, ResolvedTheme};
237        use wasm_bindgen::closure::Closure;
238
239        // 在 cfg 块顶层(渲染路径)取主题 memo,move 进下方 effect 闭包;闭包内只
240        // 调用 memo(resolved_theme())读值——调用 memo 本身不是 hook。
241        let resolved_theme = use_resolved_theme();
242
243        // 首次挂载:构造 onReady 闭包 + XtermOptions,create 后存进 term_handle。
244        // 订阅 show_output:输出区在 show_output 变 true(用户点运行)后才渲染进 DOM,
245        // 容器此前不存在;读 show_output 建立订阅,使其变 true 时重跑本 effect 完成挂载。
246        let mount_container_id = output_container_id.clone();
247        use_effect(move || {
248            if term_handle.read().is_some() {
249                return; // 防重复 init
250            }
251            if !show_output() {
252                return; // 输出区未显示,容器不在 DOM,等 show_output 变 true 再挂载
253            }
254            if !library_ready(terminal_library) {
255                return;
256            }
257
258            let on_ready = Closure::new(|| {});
259            let theme_name = if resolved_theme() == ResolvedTheme::Dark {
260                "dark"
261            } else {
262                "light"
263            };
264
265            let opts = xterm::XtermOptions::new();
266            opts.set_theme(theme_name);
267            opts.set_font_size(13);
268            opts.set_on_ready(&on_ready);
269
270            if let Ok(Some(inst)) = xterm::get_module().create(&mount_container_id, &opts) {
271                let handle = xterm::TerminalHandle::new(inst, on_ready);
272                term_handle.set(Some(handle));
273            }
274        });
275
276        // 主题切换时同步终端主题。
277        // VT 动画期间跳过(同 CodeMirror 的 use_effect,见上方注释)。
278        use_effect(move || {
279            #[cfg(target_arch = "wasm32")]
280            {
281                let transitioning = web_sys::window()
282                    .and_then(|w| w.document())
283                    .and_then(|d| d.document_element())
284                    .map(|el| el.class_list().contains("is-theme-transitioning"))
285                    .unwrap_or(false);
286                if transitioning {
287                    return;
288                }
289            }
290            if let Some(h) = term_handle.read().as_ref() {
291                h.instance()
292                    .set_theme(if resolved_theme() == ResolvedTheme::Dark {
293                        "dark"
294                    } else {
295                        "light"
296                    });
297            }
298        });
299
300        // 组件卸载时销毁终端(TerminalHandle::drop → instance.destroy)。
301        use_drop(move || {
302            term_handle.set(None);
303        });
304    }
305
306    // —— run_code:同步段取 signal 当前值,move 进 spawn ——
307    let run_language = language.clone();
308    let run_overrides = overrides.clone();
309
310    // WASM 版:start_exec_stream → EventSource SSE 实时写入 xterm.js 终端;
311    // SSE 不可用时降级到轮询 get_exec_result(writeAll 整段写入)。
312    #[cfg(target_arch = "wasm32")]
313    let run_code = {
314        let mut running = running;
315        let mut stage = stage;
316        let mut error_msg = error_msg;
317        let mut source_signal = source_signal;
318        let mut term_handle = term_handle;
319        let mut show_output = show_output;
320        let mut has_output = has_output;
321        let run_language = run_language.clone();
322        let run_overrides = run_overrides.clone();
323        move |_| {
324            if running() || !editor_ready() {
325                return;
326            }
327            running.set(true);
328            show_output.set(true);
329            has_output.set(false);
330            stage.set("准备中...".to_string());
331            error_msg.set(String::new());
332
333            // 清空终端,准备新一轮输出。
334            if let Some(h) = term_handle.read().as_ref() {
335                h.instance().clear();
336            }
337
338            let run_source = source_signal.read().clone();
339            let req = ExecRequest {
340                language: run_language.clone(),
341                source: run_source,
342                overrides: run_overrides.clone(),
343            };
344
345            pending_exec.set(Some(req));
346        }
347    };
348
349    #[cfg(target_arch = "wasm32")]
350    use_effect(move || {
351        if term_handle.read().is_none() || pending_exec.read().is_none() {
352            return;
353        }
354        let Some(req) = pending_exec.write().take() else {
355            return;
356        };
357        stage.set("提交中...".to_string());
358        spawn(async move {
359            let task_id = match start_exec_stream(req).await {
360                Ok(id) => id,
361                Err(e) => {
362                    running.set(false);
363                    let msg = e.to_string();
364                    stage.set(msg.clone());
365                    error_msg.set(msg);
366                    return;
367                }
368            };
369            stage.set("运行中".to_string());
370
371            // 启动 SSE:用原生 EventSource 消费流,回调写入终端与 signal。
372            // 若 EventSource 创建失败,降级到轮询。
373            if sse_consumer::start_sse(
374                &task_id,
375                &term_handle,
376                &mut running,
377                &mut exit_info,
378                &mut error_msg,
379                &mut has_output,
380            )
381            .is_err()
382            {
383                // 降级轮询
384                sse_consumer::poll_result(
385                    &task_id,
386                    &mut running,
387                    &mut stage,
388                    &mut exit_info,
389                    &mut error_msg,
390                    &term_handle,
391                    &mut has_output,
392                )
393                .await;
394            }
395        });
396    });
397
398    // Server 版(占位,组件在前端运行):保留轮询逻辑使双目标都能编译。
399    #[cfg(not(target_arch = "wasm32"))]
400    let run_code = {
401        let mut running = running;
402        let mut stage = stage;
403        let mut exit_info = exit_info;
404        let mut error_msg = error_msg;
405        let mut source_signal = source_signal;
406        let mut show_output = show_output;
407        let run_language = run_language.clone();
408        let run_overrides = run_overrides.clone();
409        move |_| {
410            if running() {
411                return;
412            }
413            running.set(true);
414            show_output.set(true);
415            stage.set("提交中...".to_string());
416            exit_info.set(String::new());
417            error_msg.set(String::new());
418
419            let run_source = source_signal.read().clone();
420            let req = ExecRequest {
421                language: run_language.clone(),
422                source: run_source,
423                overrides: run_overrides.clone(),
424            };
425
426            spawn(async move {
427                match start_exec(req).await {
428                    Ok(task_id) => {
429                        let mut polls = 0u32;
430                        loop {
431                            polls += 1;
432                            sleep_ms(POLL_INTERVAL_MS).await;
433                            match get_exec_result(task_id.clone()).await {
434                                Ok(task) => {
435                                    stage.set(task.stage.clone());
436                                    let terminal = task.status != ExecStatus::Queued
437                                        && task.status != ExecStatus::Running;
438                                    if terminal {
439                                        running.set(false);
440                                        if let Some(res) = task.result {
441                                            apply_exec_outcome(
442                                                &mut exit_info,
443                                                &mut error_msg,
444                                                &res,
445                                            );
446                                        }
447                                        break;
448                                    }
449                                    if polls >= MAX_POLLS {
450                                        running.set(false);
451                                        stage.set("查询超时".to_string());
452                                        error_msg.set("轮询超时,请重试".to_string());
453                                        break;
454                                    }
455                                }
456                                Err(_) => {
457                                    running.set(false);
458                                    stage.set("结果获取异常".to_string());
459                                    error_msg.set("结果获取异常".to_string());
460                                    break;
461                                }
462                            }
463                        }
464                    }
465                    Err(e) => {
466                        running.set(false);
467                        let msg = e.to_string();
468                        stage.set(msg.clone());
469                        error_msg.set(msg);
470                    }
471                }
472            });
473        }
474    };
475
476    rsx! {
477        div { class: "rounded-2xl overflow-hidden border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] my-[var(--content-gap-paper)]",
478            // 顶栏:语言标签 + 运行按钮
479            div { class: "flex justify-between items-center px-4 py-2.5 border-b border-[var(--color-paper-border)] bg-[var(--color-paper-theme)]",
480                div { class: "flex items-center gap-3",
481                    span { class: "w-2 h-2 rounded-full bg-[var(--color-paper-accent)]" }
482                    span { class: "font-mono text-sm font-semibold text-[var(--color-paper-primary)]",
483                        "{language}"
484                    }
485                    button {
486                        class: format!(
487                            "text-[10px] px-1.5 py-0.5 rounded border transition cursor-pointer {}",
488                            if vim_enabled() {
489                                "bg-[var(--color-paper-accent)]/15 text-[var(--color-paper-accent)] border-[var(--color-paper-accent)]/30 font-semibold"
490                            } else {
491                                "bg-transparent text-[var(--color-paper-tertiary)] border-[var(--color-paper-border)] hover:text-[var(--color-paper-primary)]"
492                            },
493                        ),
494                        onclick: toggle_vim,
495                        "Vim"
496                    }
497                }
498                button {
499                    class: format!("{BTN_PRIMARY_SM} gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed"),
500                    disabled: running() || !editor_ready(),
501                    onclick: run_code,
502                    if running() {
503                        span {
504                            class: "inline-block w-3.5 h-3.5 text-[var(--color-paper-theme)]",
505                            dangerous_inner_html: SPINNER_SVG,
506                        }
507                        "{stage()}"
508                    } else {
509                        "运行"
510                    }
511                }
512            }
513            LibraryLoadError { library: editor_library }
514            div {
515                id: "{container_id}",
516                class: "code-runner-editor font-mono text-sm relative",
517
518                // 骨架屏:CodeMirror 尚未挂载就绪时(SSR + hydration 完成前)显示。
519                // editor_ready 由挂载 effect 置 true 后,此处 if 分支消失,骨架屏从 DOM 移除。
520                // 用绝对定位覆盖在(始终存在的)容器上方,不影响 CodeMirror 的 getElementById 挂载。
521                if !editor_ready() {
522                    div { class: "absolute inset-0 flex flex-col justify-center gap-2.5 px-4 py-4 bg-[var(--color-paper-code-block)]",
523                        // 代码行占位条:递减宽度模拟代码缩进,贴合等宽字体语境。
524                        SkeletonBox { class: "h-3 rounded", style: Some("width: 90%") }
525                        SkeletonBox { class: "h-3 rounded", style: Some("width: 70%") }
526                        SkeletonBox { class: "h-3 rounded", style: Some("width: 55%") }
527                        SkeletonBox { class: "h-3 rounded", style: Some("width: 85%") }
528                        SkeletonBox { class: "h-3 rounded", style: Some("width: 40%") }
529                    }
530                }
531            }
532            // 输出区:用户点运行后才显示(show_output)。
533            // running 时显示骨架屏占位(等首个 chunk 到达);有内容后 xterm 终端渲染。
534            if show_output() {
535                div { class: "border-t border-[var(--color-paper-border)]",
536                    LibraryLoadError { library: terminal_library }
537                    div { class: "flex justify-between items-center px-4 py-2 text-xs text-[var(--color-paper-tertiary)] border-b border-[var(--color-paper-border)] bg-[var(--color-paper-code-block)]",
538                        span { class: "font-medium uppercase tracking-wide", "输出" }
539                        span { "{exit_info()}" }
540                    }
541                    div {
542                        id: "{output_container_id}",
543                        class: "px-2 py-2 bg-[var(--color-paper-code-block)] max-h-80 overflow-hidden text-xs relative min-h-24",
544                        // running 且尚未收到首个 chunk 时,显示骨架屏占位。
545                        if running() && !has_output() {
546                            div { class: "absolute inset-0 flex flex-col justify-center gap-2.5 px-4 py-4 bg-[var(--color-paper-code-block)]",
547                                SkeletonBox { class: "h-3 rounded", style: Some("width: 70%") }
548                                SkeletonBox { class: "h-3 rounded", style: Some("width: 55%") }
549                                SkeletonBox { class: "h-3 rounded", style: Some("width: 85%") }
550                            }
551                        }
552                    }
553                }
554            }
555            // 错误提示
556            if !error_msg().is_empty() {
557                div { class: "px-4 py-2.5 border-t border-[var(--color-paper-border)] text-xs text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-900/10",
558                    {error_msg()}
559                }
560            }
561        }
562    }
563}
564
565/// 把 ExecStatus 映射成中文标签。
566fn status_label(status: &ExecStatus) -> String {
567    match status {
568        ExecStatus::Queued => "排队中".to_string(),
569        ExecStatus::Running => "运行中".to_string(),
570        ExecStatus::Success => "成功".to_string(),
571        ExecStatus::Timeout => "超时".to_string(),
572        ExecStatus::OomKilled => "内存超限".to_string(),
573        ExecStatus::Error => "运行错误".to_string(),
574        ExecStatus::Failed => "系统失败".to_string(),
575        ExecStatus::RateLimited => "请求过频".to_string(),
576    }
577}
578
579/// 把执行结果(耗时/状态)写入 exit_info / error_msg:成功清空错误提示,
580/// 非成功态回填状态中文标签。非 WASM 占位轮询路径与 WASM 端 SSE 降级轮询
581/// (sse_consumer::poll_result)共用同一套结果展示逻辑,避免重复维护。
582fn apply_exec_outcome(
583    exit_info: &mut Signal<String>,
584    error_msg: &mut Signal<String>,
585    res: &ExecResult,
586) {
587    exit_info.set(format!(
588        "耗时: {}ms · 状态: {}",
589        res.duration_ms,
590        status_label(&res.status)
591    ));
592    if res.status == ExecStatus::Success {
593        error_msg.set(String::new());
594    } else {
595        error_msg.set(status_label(&res.status));
596    }
597}
598
599// —— WASM-only 辅助函数:SSE 消费 + 轮询兜底 ——
600// start_sse 用原生 EventSource 消费 SSE 流,回调写入 xterm.js 终端与 signal。
601// poll_result 是降级路径:轮询 get_exec_result,拿到完整结果后 writeAll 写入终端。
602#[cfg(target_arch = "wasm32")]
603mod sse_consumer {
604    use dioxus::prelude::*;
605    use wasm_bindgen::closure::Closure;
606    use wasm_bindgen::prelude::*;
607    use wasm_bindgen::JsCast;
608    use web_sys::{EventSource, MessageEvent};
609
610    use crate::api::code_runner::execute::get_exec_result;
611    use crate::api::code_runner::ExecStatus;
612    use crate::bridges::xterm::TerminalHandle;
613    use crate::utils::time::sleep_ms;
614
615    /// SSE done 事件的 JSON payload。
616    #[derive(serde::Deserialize)]
617    struct DonePayload {
618        exit_code: Option<i64>,
619        oom_killed: bool,
620        timed_out: bool,
621        duration_ms: u64,
622        /// 系统级错误消息(镜像缺失 / daemon 不可达)。有值时优先于退出码展示。
623        #[serde(default)]
624        error: Option<String>,
625    }
626
627    /// 启动 EventSource 消费 SSE 流。
628    ///
629    /// 回调写入 xterm.js 终端(stdout/stderr)与 signal(exit_info/error_msg)。
630    /// 返回后 spawn block 的 future 即结束——EventSource 自行维持连接直到 done/error。
631    ///
632    /// 返回 Err 表示 EventSource 创建失败(如 URL 非法),调用方应降级到轮询。
633    pub fn start_sse(
634        task_id: &str,
635        term_handle: &Signal<Option<TerminalHandle>>,
636        running: &mut Signal<bool>,
637        exit_info: &mut Signal<String>,
638        error_msg: &mut Signal<String>,
639        has_output: &mut Signal<bool>,
640    ) -> Result<(), JsValue> {
641        let url = format!("/api/exec/stream?task_id={task_id}");
642        let es = EventSource::new(&url)?;
643
644        // stdout 事件 → 终端 writeStdout + 标记已有输出(骨架屏消失)
645        let term_for_stdout = *term_handle;
646        let mut has_output_for_stdout = *has_output;
647        let on_stdout = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
648            if let Some(s) = e.data().as_string() {
649                if let Some(h) = term_for_stdout.read().as_ref() {
650                    h.instance().write_stdout(&s);
651                }
652                has_output_for_stdout.set(true);
653            }
654        });
655        es.add_event_listener_with_callback("stdout", on_stdout.as_ref().unchecked_ref())?;
656        on_stdout.forget();
657
658        // stderr 事件 → 终端 writeStderr(红色)+ 标记已有输出
659        let term_for_stderr = *term_handle;
660        let mut has_output_for_stderr = *has_output;
661        let on_stderr = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
662            if let Some(s) = e.data().as_string() {
663                if let Some(h) = term_for_stderr.read().as_ref() {
664                    h.instance().write_stderr(&s);
665                }
666                has_output_for_stderr.set(true);
667            }
668        });
669        es.add_event_listener_with_callback("stderr", on_stderr.as_ref().unchecked_ref())?;
670        on_stderr.forget();
671
672        // done 事件 → 解析终态,设 signal,关闭连接
673        let mut running_clone = *running;
674        let mut exit_info_clone = *exit_info;
675        let mut error_msg_clone = *error_msg;
676        let es_for_done = es.clone();
677        let on_done = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
678            let payload: DonePayload = e
679                .data()
680                .as_string()
681                .and_then(|s| serde_json::from_str(&s).ok())
682                .unwrap_or(DonePayload {
683                    exit_code: None,
684                    oom_killed: false,
685                    timed_out: false,
686                    duration_ms: 0,
687                    error: None,
688                });
689
690            // 系统级错误(镜像缺失 / daemon 不可达)优先于退出码展示——
691            // 此类错误下 exit_code 无意义,直接显示可操作消息。
692            let (info, err) = if let Some(msg) = payload.error {
693                (
694                    format!("耗时: {}ms · 状态: 系统错误", payload.duration_ms),
695                    msg,
696                )
697            } else if payload.timed_out {
698                (
699                    format!("耗时: {}ms · 状态: 超时", payload.duration_ms),
700                    "超时".to_string(),
701                )
702            } else if payload.oom_killed {
703                (
704                    format!("耗时: {}ms · 状态: 内存超限", payload.duration_ms),
705                    "内存超限".to_string(),
706                )
707            } else if payload.exit_code == Some(0) {
708                (
709                    format!("耗时: {}ms · 状态: 成功", payload.duration_ms),
710                    String::new(),
711                )
712            } else {
713                (
714                    format!(
715                        "耗时: {}ms · 退出码: {} · 运行错误",
716                        payload.duration_ms,
717                        payload.exit_code.unwrap_or(-1)
718                    ),
719                    "运行错误".to_string(),
720                )
721            };
722            exit_info_clone.set(info);
723            error_msg_clone.set(err);
724            running_clone.set(false);
725            es_for_done.close();
726        });
727        es.add_event_listener_with_callback("done", on_done.as_ref().unchecked_ref())?;
728        on_done.forget();
729
730        // error 事件 → EventSource 连接异常,关闭并标记错误
731        let mut running_clone = *running;
732        let mut error_msg_clone = *error_msg;
733        let es_for_error = es.clone();
734        let on_error = Closure::<dyn FnMut(web_sys::Event)>::new(move |_e: web_sys::Event| {
735            error_msg_clone.set("连接异常,请重试".to_string());
736            running_clone.set(false);
737            es_for_error.close();
738        });
739        es.add_event_listener_with_callback("error", on_error.as_ref().unchecked_ref())?;
740        on_error.forget();
741
742        Ok(())
743    }
744
745    /// 轮询兜底路径:SSE 不可用时用 get_exec_result 轮询,
746    /// 拿到完整结果后 writeAll 整段写入终端。
747    pub async fn poll_result(
748        task_id: &str,
749        running: &mut Signal<bool>,
750        stage: &mut Signal<String>,
751        exit_info: &mut Signal<String>,
752        error_msg: &mut Signal<String>,
753        term_handle: &Signal<Option<TerminalHandle>>,
754        has_output: &mut Signal<bool>,
755    ) {
756        let mut polls = 0u32;
757        loop {
758            polls += 1;
759            sleep_ms(500).await;
760            match get_exec_result(task_id.to_string()).await {
761                Ok(task) => {
762                    stage.set(task.stage.clone());
763                    let terminal =
764                        task.status != ExecStatus::Queued && task.status != ExecStatus::Running;
765                    if terminal {
766                        running.set(false);
767                        if let Some(res) = task.result {
768                            super::apply_exec_outcome(exit_info, error_msg, &res);
769                            // 整段写入终端
770                            if let Some(h) = term_handle.read().as_ref() {
771                                h.instance().write_all(&res.stdout, &res.stderr);
772                            }
773                            has_output.set(true);
774                        }
775                        break;
776                    }
777                    if polls >= 240 {
778                        running.set(false);
779                        stage.set("查询超时".to_string());
780                        error_msg.set("轮询超时,请重试".to_string());
781                        break;
782                    }
783                }
784                Err(_) => {
785                    running.set(false);
786                    stage.set("结果获取异常".to_string());
787                    error_msg.set("结果获取异常".to_string());
788                    break;
789                }
790            }
791        }
792    }
793}