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; #[component]
50#[allow(unused_mut)]
51pub fn CodeRunner(
52 source: String,
53 language: String,
54 overrides: Option<ResourceLimits>,
55 instance_id: usize,
56) -> Element {
57 let mut running = use_signal(|| false);
58 let mut stage = use_signal(String::new);
59 let mut exit_info = use_signal(String::new);
60 let mut error_msg = use_signal(String::new);
61 let mut show_output = use_signal(|| false);
63 let mut has_output = use_signal(|| false);
65 let editor_library = use_browser_library("codemirror", || true);
66 let terminal_library = use_browser_library("xterm", move || *show_output.read());
67 #[cfg(target_arch = "wasm32")]
69 let mut pending_exec: Signal<Option<ExecRequest>> = use_signal(|| None);
70
71 let mut source_signal = use_signal(|| source.clone());
78
79 let container_id = format!("code-runner-{instance_id}");
83
84 let output_container_id = format!("code-runner-output-{instance_id}");
86
87 #[cfg(target_arch = "wasm32")]
90 let mut term_handle: Signal<Option<crate::bridges::xterm::TerminalHandle>> =
91 use_signal(|| None);
92
93 let mut editor_ready = use_signal(|| false);
97
98 let mut vim_enabled = use_signal(|| {
100 #[cfg(target_arch = "wasm32")]
101 {
102 if let Some(window) = web_sys::window() {
103 if let Ok(Some(storage)) = window.local_storage() {
104 if let Ok(Some(val)) = storage.get_item("yggdrasil-code-runner-vim") {
105 return val == "true";
106 }
107 }
108 }
109 }
110 true
111 });
112
113 let toggle_vim = move |_| {
114 let next = !vim_enabled();
115 vim_enabled.set(next);
116 #[cfg(target_arch = "wasm32")]
117 {
118 if let Some(window) = web_sys::window() {
119 if let Ok(Some(storage)) = window.local_storage() {
120 let _ = storage.set_item("yggdrasil-code-runner-vim", &next.to_string());
121 }
122 }
123 }
124 };
125
126 #[cfg(target_arch = "wasm32")]
129 {
130 use crate::bridges::codemirror;
131 use crate::theme::{use_resolved_theme, ResolvedTheme};
132 use wasm_bindgen::closure::Closure;
133
134 let mut editor_handle: Signal<Option<codemirror::EditorHandle>> = use_signal(|| None);
135 let resolved_theme = use_resolved_theme();
139
140 let mount_language = language.clone();
143 let mount_container_id = container_id.clone();
144 use_effect(move || {
145 if editor_handle.read().is_some() {
146 return; }
148 if !library_ready(editor_library) {
149 return;
150 }
151
152 let on_change = Closure::new({
154 let mut sig = source_signal;
155 move |v: String| sig.set(v)
156 });
157 let on_ready = Closure::new(|| {});
158 let on_run_shortcut = Closure::new(|| {});
161
162 let theme_name = if resolved_theme() == ResolvedTheme::Dark {
163 "dark"
164 } else {
165 "light"
166 };
167
168 let opts = codemirror::EditorOptions::new();
169 opts.set_language(&mount_language);
170 opts.set_theme(theme_name);
171 opts.set_vim(*vim_enabled.read());
172 opts.set_value(&source_signal.read());
173 opts.set_on_change(&on_change);
174 opts.set_on_ready(&on_ready);
175 opts.set_on_run_shortcut(&on_run_shortcut);
176
177 if let Ok(Some(inst)) = codemirror::get_module().create(&mount_container_id, &opts) {
178 let handle =
179 codemirror::EditorHandle::new(inst, on_change, on_ready, on_run_shortcut);
180 editor_handle.set(Some(handle));
181 editor_ready.set(true);
182 }
183 });
184
185 use_effect(move || {
195 #[cfg(target_arch = "wasm32")]
196 {
197 let transitioning = web_sys::window()
198 .and_then(|w| w.document())
199 .and_then(|d| d.document_element())
200 .map(|el| el.class_list().contains("is-theme-transitioning"))
201 .unwrap_or(false);
202 if transitioning {
203 return;
204 }
205 }
206 if let Some(h) = editor_handle.read().as_ref() {
207 h.instance()
208 .set_theme(if resolved_theme() == ResolvedTheme::Dark {
209 "dark"
210 } else {
211 "light"
212 });
213 }
214 });
215
216 use_effect(move || {
218 let enabled = vim_enabled();
219 if let Some(h) = editor_handle.read().as_ref() {
220 h.instance().set_vim(enabled);
221 }
222 });
223
224 use_drop(move || {
226 editor_handle.set(None);
227 editor_ready.set(false);
228 });
229 }
230
231 #[cfg(target_arch = "wasm32")]
234 {
235 use crate::bridges::xterm;
236 use crate::theme::{use_resolved_theme, ResolvedTheme};
237 use wasm_bindgen::closure::Closure;
238
239 let resolved_theme = use_resolved_theme();
242
243 let mount_container_id = output_container_id.clone();
247 use_effect(move || {
248 if term_handle.read().is_some() {
249 return; }
251 if !show_output() {
252 return; }
254 if !library_ready(terminal_library) {
255 return;
256 }
257
258 let on_ready = Closure::new(|| {});
259 let theme_name = if resolved_theme() == ResolvedTheme::Dark {
260 "dark"
261 } else {
262 "light"
263 };
264
265 let opts = xterm::XtermOptions::new();
266 opts.set_theme(theme_name);
267 opts.set_font_size(13);
268 opts.set_on_ready(&on_ready);
269
270 if let Ok(Some(inst)) = xterm::get_module().create(&mount_container_id, &opts) {
271 let handle = xterm::TerminalHandle::new(inst, on_ready);
272 term_handle.set(Some(handle));
273 }
274 });
275
276 use_effect(move || {
279 #[cfg(target_arch = "wasm32")]
280 {
281 let transitioning = web_sys::window()
282 .and_then(|w| w.document())
283 .and_then(|d| d.document_element())
284 .map(|el| el.class_list().contains("is-theme-transitioning"))
285 .unwrap_or(false);
286 if transitioning {
287 return;
288 }
289 }
290 if let Some(h) = term_handle.read().as_ref() {
291 h.instance()
292 .set_theme(if resolved_theme() == ResolvedTheme::Dark {
293 "dark"
294 } else {
295 "light"
296 });
297 }
298 });
299
300 use_drop(move || {
302 term_handle.set(None);
303 });
304 }
305
306 let run_language = language.clone();
308 let run_overrides = overrides.clone();
309
310 #[cfg(target_arch = "wasm32")]
313 let run_code = {
314 let mut running = running;
315 let mut stage = stage;
316 let mut error_msg = error_msg;
317 let mut source_signal = source_signal;
318 let mut term_handle = term_handle;
319 let mut show_output = show_output;
320 let mut has_output = has_output;
321 let run_language = run_language.clone();
322 let run_overrides = run_overrides.clone();
323 move |_| {
324 if running() || !editor_ready() {
325 return;
326 }
327 running.set(true);
328 show_output.set(true);
329 has_output.set(false);
330 stage.set("准备中...".to_string());
331 error_msg.set(String::new());
332
333 if let Some(h) = term_handle.read().as_ref() {
335 h.instance().clear();
336 }
337
338 let run_source = source_signal.read().clone();
339 let req = ExecRequest {
340 language: run_language.clone(),
341 source: run_source,
342 overrides: run_overrides.clone(),
343 };
344
345 pending_exec.set(Some(req));
346 }
347 };
348
349 #[cfg(target_arch = "wasm32")]
350 use_effect(move || {
351 if term_handle.read().is_none() || pending_exec.read().is_none() {
352 return;
353 }
354 let Some(req) = pending_exec.write().take() else {
355 return;
356 };
357 stage.set("提交中...".to_string());
358 spawn(async move {
359 let task_id = match start_exec_stream(req).await {
360 Ok(id) => id,
361 Err(e) => {
362 running.set(false);
363 let msg = e.to_string();
364 stage.set(msg.clone());
365 error_msg.set(msg);
366 return;
367 }
368 };
369 stage.set("运行中".to_string());
370
371 if sse_consumer::start_sse(
374 &task_id,
375 &term_handle,
376 &mut running,
377 &mut exit_info,
378 &mut error_msg,
379 &mut has_output,
380 )
381 .is_err()
382 {
383 sse_consumer::poll_result(
385 &task_id,
386 &mut running,
387 &mut stage,
388 &mut exit_info,
389 &mut error_msg,
390 &term_handle,
391 &mut has_output,
392 )
393 .await;
394 }
395 });
396 });
397
398 #[cfg(not(target_arch = "wasm32"))]
400 let run_code = {
401 let mut running = running;
402 let mut stage = stage;
403 let mut exit_info = exit_info;
404 let mut error_msg = error_msg;
405 let mut source_signal = source_signal;
406 let mut show_output = show_output;
407 let run_language = run_language.clone();
408 let run_overrides = run_overrides.clone();
409 move |_| {
410 if running() {
411 return;
412 }
413 running.set(true);
414 show_output.set(true);
415 stage.set("提交中...".to_string());
416 exit_info.set(String::new());
417 error_msg.set(String::new());
418
419 let run_source = source_signal.read().clone();
420 let req = ExecRequest {
421 language: run_language.clone(),
422 source: run_source,
423 overrides: run_overrides.clone(),
424 };
425
426 spawn(async move {
427 match start_exec(req).await {
428 Ok(task_id) => {
429 let mut polls = 0u32;
430 loop {
431 polls += 1;
432 sleep_ms(POLL_INTERVAL_MS).await;
433 match get_exec_result(task_id.clone()).await {
434 Ok(task) => {
435 stage.set(task.stage.clone());
436 let terminal = task.status != ExecStatus::Queued
437 && task.status != ExecStatus::Running;
438 if terminal {
439 running.set(false);
440 if let Some(res) = task.result {
441 apply_exec_outcome(
442 &mut exit_info,
443 &mut error_msg,
444 &res,
445 );
446 }
447 break;
448 }
449 if polls >= MAX_POLLS {
450 running.set(false);
451 stage.set("查询超时".to_string());
452 error_msg.set("轮询超时,请重试".to_string());
453 break;
454 }
455 }
456 Err(_) => {
457 running.set(false);
458 stage.set("结果获取异常".to_string());
459 error_msg.set("结果获取异常".to_string());
460 break;
461 }
462 }
463 }
464 }
465 Err(e) => {
466 running.set(false);
467 let msg = e.to_string();
468 stage.set(msg.clone());
469 error_msg.set(msg);
470 }
471 }
472 });
473 }
474 };
475
476 rsx! {
477 div { class: "rounded-2xl overflow-hidden border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] my-[var(--content-gap-paper)]",
478 div { class: "flex justify-between items-center px-4 py-2.5 border-b border-[var(--color-paper-border)] bg-[var(--color-paper-theme)]",
480 div { class: "flex items-center gap-3",
481 span { class: "w-2 h-2 rounded-full bg-[var(--color-paper-accent)]" }
482 span { class: "font-mono text-sm font-semibold text-[var(--color-paper-primary)]",
483 "{language}"
484 }
485 button {
486 class: format!(
487 "text-[10px] px-1.5 py-0.5 rounded border transition cursor-pointer {}",
488 if vim_enabled() {
489 "bg-[var(--color-paper-accent)]/15 text-[var(--color-paper-accent)] border-[var(--color-paper-accent)]/30 font-semibold"
490 } else {
491 "bg-transparent text-[var(--color-paper-tertiary)] border-[var(--color-paper-border)] hover:text-[var(--color-paper-primary)]"
492 },
493 ),
494 onclick: toggle_vim,
495 "Vim"
496 }
497 }
498 button {
499 class: format!("{BTN_PRIMARY_SM} gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed"),
500 disabled: running() || !editor_ready(),
501 onclick: run_code,
502 if running() {
503 span {
504 class: "inline-block w-3.5 h-3.5 text-[var(--color-paper-theme)]",
505 dangerous_inner_html: SPINNER_SVG,
506 }
507 "{stage()}"
508 } else {
509 "运行"
510 }
511 }
512 }
513 LibraryLoadError { library: editor_library }
514 div {
515 id: "{container_id}",
516 class: "code-runner-editor font-mono text-sm relative",
517
518 if !editor_ready() {
522 div { class: "absolute inset-0 flex flex-col justify-center gap-2.5 px-4 py-4 bg-[var(--color-paper-code-block)]",
523 SkeletonBox { class: "h-3 rounded", style: Some("width: 90%") }
525 SkeletonBox { class: "h-3 rounded", style: Some("width: 70%") }
526 SkeletonBox { class: "h-3 rounded", style: Some("width: 55%") }
527 SkeletonBox { class: "h-3 rounded", style: Some("width: 85%") }
528 SkeletonBox { class: "h-3 rounded", style: Some("width: 40%") }
529 }
530 }
531 }
532 if show_output() {
535 div { class: "border-t border-[var(--color-paper-border)]",
536 LibraryLoadError { library: terminal_library }
537 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)]",
538 span { class: "font-medium uppercase tracking-wide", "输出" }
539 span { "{exit_info()}" }
540 }
541 div {
542 id: "{output_container_id}",
543 class: "px-2 py-2 bg-[var(--color-paper-code-block)] max-h-80 overflow-hidden text-xs relative min-h-24",
544 if running() && !has_output() {
546 div { class: "absolute inset-0 flex flex-col justify-center gap-2.5 px-4 py-4 bg-[var(--color-paper-code-block)]",
547 SkeletonBox { class: "h-3 rounded", style: Some("width: 70%") }
548 SkeletonBox { class: "h-3 rounded", style: Some("width: 55%") }
549 SkeletonBox { class: "h-3 rounded", style: Some("width: 85%") }
550 }
551 }
552 }
553 }
554 }
555 if !error_msg().is_empty() {
557 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",
558 {error_msg()}
559 }
560 }
561 }
562 }
563}
564
565fn status_label(status: &ExecStatus) -> String {
567 match status {
568 ExecStatus::Queued => "排队中".to_string(),
569 ExecStatus::Running => "运行中".to_string(),
570 ExecStatus::Success => "成功".to_string(),
571 ExecStatus::Timeout => "超时".to_string(),
572 ExecStatus::OomKilled => "内存超限".to_string(),
573 ExecStatus::Error => "运行错误".to_string(),
574 ExecStatus::Failed => "系统失败".to_string(),
575 ExecStatus::RateLimited => "请求过频".to_string(),
576 }
577}
578
579fn apply_exec_outcome(
583 exit_info: &mut Signal<String>,
584 error_msg: &mut Signal<String>,
585 res: &ExecResult,
586) {
587 exit_info.set(format!(
588 "耗时: {}ms · 状态: {}",
589 res.duration_ms,
590 status_label(&res.status)
591 ));
592 if res.status == ExecStatus::Success {
593 error_msg.set(String::new());
594 } else {
595 error_msg.set(status_label(&res.status));
596 }
597}
598
599#[cfg(target_arch = "wasm32")]
603mod sse_consumer {
604 use dioxus::prelude::*;
605 use wasm_bindgen::closure::Closure;
606 use wasm_bindgen::prelude::*;
607 use wasm_bindgen::JsCast;
608 use web_sys::{EventSource, MessageEvent};
609
610 use crate::api::code_runner::execute::get_exec_result;
611 use crate::api::code_runner::ExecStatus;
612 use crate::bridges::xterm::TerminalHandle;
613 use crate::utils::time::sleep_ms;
614
615 #[derive(serde::Deserialize)]
617 struct DonePayload {
618 exit_code: Option<i64>,
619 oom_killed: bool,
620 timed_out: bool,
621 duration_ms: u64,
622 #[serde(default)]
624 error: Option<String>,
625 }
626
627 pub fn start_sse(
634 task_id: &str,
635 term_handle: &Signal<Option<TerminalHandle>>,
636 running: &mut Signal<bool>,
637 exit_info: &mut Signal<String>,
638 error_msg: &mut Signal<String>,
639 has_output: &mut Signal<bool>,
640 ) -> Result<(), JsValue> {
641 let url = format!("/api/exec/stream?task_id={task_id}");
642 let es = EventSource::new(&url)?;
643
644 let term_for_stdout = *term_handle;
646 let mut has_output_for_stdout = *has_output;
647 let on_stdout = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
648 if let Some(s) = e.data().as_string() {
649 if let Some(h) = term_for_stdout.read().as_ref() {
650 h.instance().write_stdout(&s);
651 }
652 has_output_for_stdout.set(true);
653 }
654 });
655 es.add_event_listener_with_callback("stdout", on_stdout.as_ref().unchecked_ref())?;
656 on_stdout.forget();
657
658 let term_for_stderr = *term_handle;
660 let mut has_output_for_stderr = *has_output;
661 let on_stderr = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
662 if let Some(s) = e.data().as_string() {
663 if let Some(h) = term_for_stderr.read().as_ref() {
664 h.instance().write_stderr(&s);
665 }
666 has_output_for_stderr.set(true);
667 }
668 });
669 es.add_event_listener_with_callback("stderr", on_stderr.as_ref().unchecked_ref())?;
670 on_stderr.forget();
671
672 let mut running_clone = *running;
674 let mut exit_info_clone = *exit_info;
675 let mut error_msg_clone = *error_msg;
676 let es_for_done = es.clone();
677 let on_done = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
678 let payload: DonePayload = e
679 .data()
680 .as_string()
681 .and_then(|s| serde_json::from_str(&s).ok())
682 .unwrap_or(DonePayload {
683 exit_code: None,
684 oom_killed: false,
685 timed_out: false,
686 duration_ms: 0,
687 error: None,
688 });
689
690 let (info, err) = if let Some(msg) = payload.error {
693 (
694 format!("耗时: {}ms · 状态: 系统错误", payload.duration_ms),
695 msg,
696 )
697 } else if payload.timed_out {
698 (
699 format!("耗时: {}ms · 状态: 超时", payload.duration_ms),
700 "超时".to_string(),
701 )
702 } else if payload.oom_killed {
703 (
704 format!("耗时: {}ms · 状态: 内存超限", payload.duration_ms),
705 "内存超限".to_string(),
706 )
707 } else if payload.exit_code == Some(0) {
708 (
709 format!("耗时: {}ms · 状态: 成功", payload.duration_ms),
710 String::new(),
711 )
712 } else {
713 (
714 format!(
715 "耗时: {}ms · 退出码: {} · 运行错误",
716 payload.duration_ms,
717 payload.exit_code.unwrap_or(-1)
718 ),
719 "运行错误".to_string(),
720 )
721 };
722 exit_info_clone.set(info);
723 error_msg_clone.set(err);
724 running_clone.set(false);
725 es_for_done.close();
726 });
727 es.add_event_listener_with_callback("done", on_done.as_ref().unchecked_ref())?;
728 on_done.forget();
729
730 let mut running_clone = *running;
732 let mut error_msg_clone = *error_msg;
733 let es_for_error = es.clone();
734 let on_error = Closure::<dyn FnMut(web_sys::Event)>::new(move |_e: web_sys::Event| {
735 error_msg_clone.set("连接异常,请重试".to_string());
736 running_clone.set(false);
737 es_for_error.close();
738 });
739 es.add_event_listener_with_callback("error", on_error.as_ref().unchecked_ref())?;
740 on_error.forget();
741
742 Ok(())
743 }
744
745 pub async fn poll_result(
748 task_id: &str,
749 running: &mut Signal<bool>,
750 stage: &mut Signal<String>,
751 exit_info: &mut Signal<String>,
752 error_msg: &mut Signal<String>,
753 term_handle: &Signal<Option<TerminalHandle>>,
754 has_output: &mut Signal<bool>,
755 ) {
756 let mut polls = 0u32;
757 loop {
758 polls += 1;
759 sleep_ms(500).await;
760 match get_exec_result(task_id.to_string()).await {
761 Ok(task) => {
762 stage.set(task.stage.clone());
763 let terminal =
764 task.status != ExecStatus::Queued && task.status != ExecStatus::Running;
765 if terminal {
766 running.set(false);
767 if let Some(res) = task.result {
768 super::apply_exec_outcome(exit_info, error_msg, &res);
769 if let Some(h) = term_handle.read().as_ref() {
771 h.instance().write_all(&res.stdout, &res.stderr);
772 }
773 has_output.set(true);
774 }
775 break;
776 }
777 if polls >= 240 {
778 running.set(false);
779 stage.set("查询超时".to_string());
780 error_msg.set("轮询超时,请重试".to_string());
781 break;
782 }
783 }
784 Err(_) => {
785 running.set(false);
786 stage.set("结果获取异常".to_string());
787 error_msg.set("结果获取异常".to_string());
788 break;
789 }
790 }
791 }
792 }
793}