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