yggdrasil/components/code_runner/
runner.rs1use 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#[cfg(not(target_arch = "wasm32"))]
30const POLL_INTERVAL_MS: u32 = 500;
31#[cfg(not(target_arch = "wasm32"))]
33const MAX_POLLS: u32 = 240; #[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#[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 let mut show_output = use_signal(|| example_output.is_some());
83 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 #[cfg(target_arch = "wasm32")]
89 let mut pending_exec: Signal<Option<ExecRequest>> = use_signal(|| None);
90
91 let mut source_signal = use_signal(|| source.clone());
98
99 let container_id = format!("code-runner-{instance_id}");
103
104 let output_container_id = format!("code-runner-output-{instance_id}");
106
107 #[cfg(target_arch = "wasm32")]
110 let mut term_handle: Signal<Option<crate::bridges::xterm::TerminalHandle>> =
111 use_signal(|| None);
112
113 let mut editor_ready = use_signal(|| false);
117
118 #[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 #[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 let resolved_theme = use_resolved_theme();
165
166 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; }
174 if !library_ready(editor_library) {
175 return;
176 }
177
178 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 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 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 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 use_drop(move || {
252 editor_handle.set(None);
253 editor_ready.set(false);
254 });
255 }
256
257 #[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 let resolved_theme = use_resolved_theme();
268
269 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; }
278 if !show_output() {
279 return; }
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 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 use_drop(move || {
332 term_handle.set(None);
333 });
334 }
335
336 let run_language = language.clone();
338 let run_overrides = overrides.clone();
339
340 #[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 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 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 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 #[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 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 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 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 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 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 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
629fn 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
643fn 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#[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 #[derive(serde::Deserialize)]
681 struct DonePayload {
682 exit_code: Option<i64>,
683 oom_killed: bool,
684 timed_out: bool,
685 duration_ms: u64,
686 #[serde(default)]
688 error: Option<String>,
689 }
690
691 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 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 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 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 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 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 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 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}