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, 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#[cfg(not(target_arch = "wasm32"))]
26const POLL_INTERVAL_MS: u32 = 500;
27#[cfg(not(target_arch = "wasm32"))]
29const MAX_POLLS: u32 = 240; #[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 let mut show_output = use_signal(|| false);
60 let mut has_output = use_signal(|| false);
62
63 let mut source_signal = use_signal(|| source.clone());
70
71 let container_id = format!("code-runner-{instance_id}");
75
76 let output_container_id = format!("code-runner-output-{instance_id}");
78
79 #[cfg(target_arch = "wasm32")]
82 let mut term_handle: Signal<Option<crate::xterm_bridge::TerminalHandle>> = use_signal(|| None);
83
84 let mut editor_ready = use_signal(|| false);
88
89 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 #[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 let resolved_theme = use_resolved_theme();
131
132 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; }
140
141 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 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 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 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 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 use_drop(move || {
233 editor_handle.set(None);
234 editor_ready.set(false);
235 });
236 }
237
238 #[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 let resolved_theme = use_resolved_theme();
249
250 let mount_container_id = output_container_id.clone();
254 use_effect(move || {
255 if term_handle.read().is_some() {
256 return; }
258 if !show_output() {
259 return; }
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 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 use_drop(move || {
306 term_handle.set(None);
307 });
308 }
309
310 let run_language = language.clone();
312 let run_overrides = overrides.clone();
313
314 #[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 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 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 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 #[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 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 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 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 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 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 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
594fn 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#[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 #[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 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 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 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 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 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 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 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}