Skip to main content

yggdrasil/components/code_runner/
runner.rs

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