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