yggdrasil/components/code_runner/
runner.rs1mod body;
25#[cfg(target_arch = "wasm32")]
26mod editor;
27mod execution;
28mod state;
29#[cfg(target_arch = "wasm32")]
30mod stream;
31#[cfg(target_arch = "wasm32")]
32mod terminal;
33
34use crate::api::code_runner::ExecRequest;
35use crate::bridges::library::{use_browser_library, BrowserLibrary, LibraryLoadError};
36use crate::components::skeletons::atoms::SkeletonBox;
37use crate::components::ui::{
38 Button, ButtonSize, ButtonVariant, Icon, IconName, Menu, MenuItem, Tooltip,
39};
40use crate::infra::runner_config::ResourceLimits;
41use leptos::prelude::*;
42
43use body::{code_runner_body, RunnerBody};
44use execution::Execution;
45
46#[cfg(target_arch = "wasm32")]
48const VIM_STORAGE_KEY: &str = "yggdrasil-code-runner-vim";
49
50#[derive(Clone, PartialEq)]
52pub struct RunnerExample {
53 pub stdout: String,
54 pub stderr: String,
55 pub status: String,
56 pub error: String,
57}
58
59#[component]
74pub fn CodeRunner(
75 #[prop(into)] source: String,
76 #[prop(into)] language: String,
77 #[prop(optional, into)] overrides: MaybeProp<ResourceLimits>,
78 instance_id: usize,
79 #[prop(optional, into)] highlighted: MaybeProp<String>,
80 #[prop(optional, into)] example_output: MaybeProp<RunnerExample>,
81) -> impl IntoView {
82 let overrides = overrides.get_untracked();
83 let highlighted = highlighted.get_untracked();
84 let example_output = example_output.get_untracked();
85
86 let execution = Execution::new(example_output.as_ref());
87 let show_output = execution.show_output;
88 let has_output = execution.has_output;
89 let state = execution.state;
90 let running = Memo::new(move |_| state.with(|state| state.is_running()));
91 let terminal_library = use_browser_library(BrowserLibrary::Xterm, move || show_output.get());
92 let source_signal = RwSignal::new(source.clone());
94 let dirty = {
95 let original = source.clone();
96 Memo::new(move |_| source_signal.with(|current| *current != original))
97 };
98 let ran_modified = RwSignal::new(false);
100 let copied = RwSignal::new(None::<bool>);
102
103 let container_id = format!("code-runner-{instance_id}");
106 let output_container_id = format!("code-runner-output-{instance_id}");
107 let editor_mount = NodeRef::<leptos::html::Div>::new();
108 let output_mount = NodeRef::<leptos::html::Div>::new();
109
110 let near_viewport = crate::hooks::use_near_viewport(container_id.clone(), "0px 0px 100% 0px");
115 let activated = RwSignal::new(false);
116 Effect::new(move |_| {
117 if near_viewport.get() {
118 activated.set(true);
119 }
120 });
121
122 let editor_library = use_browser_library(BrowserLibrary::CodeMirror, move || activated.get());
124
125 let pending_run = RwSignal::new(false);
127
128 let editor_ready = RwSignal::new(false);
130 let editor_failed = RwSignal::new(false);
132 let terminal_failed = RwSignal::new(false);
133 let mount_retry = RwSignal::new(0_u32);
135 let vim_mode: RwSignal<Option<String>> = RwSignal::new(None);
136 let editor_command: RwSignal<Option<String>> = RwSignal::new(None);
138
139 let vim_enabled = RwSignal::new(true);
142 #[cfg(target_arch = "wasm32")]
143 let example_mode = example_output.is_some();
144 #[cfg(target_arch = "wasm32")]
145 Effect::new(move |_| {
146 let preferred = web_sys::window().and_then(|window| {
147 let stored = (!example_mode)
148 .then(|| window.local_storage().ok().flatten())
149 .flatten()
150 .and_then(|storage| storage.get_item(VIM_STORAGE_KEY).ok().flatten());
151 match stored {
152 Some(val) => Some(val == "true"),
153 None => window
154 .match_media("(pointer: coarse)")
155 .ok()
156 .flatten()
157 .map(|query| !query.matches()),
158 }
159 });
160 if let Some(enabled) = preferred.filter(|enabled| !enabled) {
161 vim_enabled.set(enabled);
162 }
163 });
164
165 let toggle_vim = Callback::new(move |()| {
166 let next = !vim_enabled.get_untracked();
167 vim_enabled.set(next);
168 #[cfg(target_arch = "wasm32")]
169 if !example_mode {
170 if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten())
171 {
172 let _ = storage.set_item(VIM_STORAGE_KEY, &next.to_string());
173 }
174 }
175 });
176
177 let run_example = example_output.clone();
178 let run_language = language.clone();
179 let run = Callback::new(move |()| {
180 if running.get_untracked() {
181 return;
182 }
183 if !editor_ready.get_untracked() {
184 pending_run.set(true);
188 activated.set(true);
189 return;
190 }
191 if let Some(example) = &run_example {
192 execution.show_example(example);
193 } else {
194 ran_modified.set(dirty.get_untracked());
195 execution.start(ExecRequest {
196 language: run_language.clone(),
197 source: source_signal.get_untracked(),
198 overrides: overrides.clone(),
199 });
200 }
201 });
202
203 Effect::new(move |_| {
206 if editor_ready.get() && pending_run.get() && !running.get() {
207 pending_run.set(false);
208 run.run(());
209 }
210 });
211
212 let awaiting_editor = move || {
215 let library_failed = matches!(editor_library.read_state(), Some(Err(_)));
216 pending_run.get() && !editor_ready.get() && !library_failed && !editor_failed.get()
217 };
218
219 let revert_source = source.clone();
220 let revert = Callback::new(move |()| {
221 editor_command.set(Some(revert_source.clone()));
222 source_signal.set(revert_source.clone());
223 });
224 let copy = Callback::new(move |()| {
225 #[cfg(target_arch = "wasm32")]
226 {
227 let text = source_signal.get_untracked();
228 leptos::task::spawn_local(async move {
229 let ok = crate::utils::js::copy_text(&text).await;
230 let _ = copied.try_set(Some(ok));
231 crate::utils::time::sleep_ms(2000).await;
232 let _ = copied.try_set(None);
233 });
234 }
235 });
236
237 let example_mode_label = example_output.is_some();
238 let copy_label = move || match copied.get() {
239 Some(true) => "已复制",
240 Some(false) => "复制失败",
241 None => "复制代码",
242 };
243 let run_label = move || {
244 if example_mode_label {
245 "播放示例"
246 } else if state.with(|state| state.is_finished()) {
247 "再次运行"
248 } else {
249 "运行"
250 }
251 };
252 let output_label = move || {
253 if example_mode_label {
254 "输出示例"
255 } else if ran_modified.get() {
256 "输出 · 修改后的代码"
257 } else {
258 "输出"
259 }
260 };
261 let menu_items = Signal::derive(move || {
262 let mut items = vec![MenuItem::new("copy", copy_label()).icon(IconName::Copy)];
263 if dirty.get() {
264 items.push(MenuItem::new("revert", "还原").icon(IconName::RotateCcw));
265 }
266 items.push(
267 MenuItem::new(
268 "vim",
269 if vim_enabled.get() {
270 "关闭 Vim 键位"
271 } else {
272 "开启 Vim 键位"
273 },
274 )
275 .icon(IconName::Keyboard)
276 .separated(),
277 );
278 items
279 });
280 let placeholder_html = highlighted.unwrap_or_else(|| crate::utils::html::escape_html(&source));
281 let line_count = source.split('\n').count().max(1);
283 let gutter_digits = line_count.to_string().len();
284 let gutter_text: String = (1..=line_count).map(|n| format!("{n}\n")).collect();
285 let placeholder_style =
286 format!("--runner-lines: {line_count}; --runner-digits: {gutter_digits};");
287 let language_label = language.clone();
288
289 let body_args = RunnerBody {
290 language,
291 editor_mount,
292 output_mount,
293 example_output: example_output.clone(),
294 source_signal,
295 vim_enabled,
296 vim_mode,
297 run,
298 activated,
299 editor_ready,
300 editor_failed,
301 terminal_failed,
302 mount_retry,
303 editor_command,
304 editor_library,
305 terminal_library,
306 execution,
307 resolved_theme: crate::theme::use_resolved_theme(),
308 };
309
310 view! {
311 <figure class="runner" data-tone=move || state.with(|state| state.tone().as_str())>
312 <div class="runner-head">
313 <div class="runner-head-start">
314 <span class="runner-lang">{language_label}</span>
315 <span class="runner-badge">"可运行"</span>
316 {move || {
317 dirty
318 .get()
319 .then(|| {
320 view! {
321 <span class="runner-dirty">"已修改"</span>
322 <button
323 class="runner-revert runner-wide"
324 type="button"
325 on:click=move |_| revert.run(())
326 >
327 "还原"
328 </button>
329 }
330 })
331 }}
332 </div>
333 <div class="runner-head-end">
334 <span
335 class=move || {
336 if running.get() { "runner-status is-live" } else { "runner-status" }
337 }
338 role="status"
339 aria-live="polite"
340 >
341 {move || state.with(|state| state.label())}
342 </span>
343 <button
344 class="runner-vim runner-wide"
345 type="button"
346 aria-pressed=move || vim_enabled.get().to_string()
347 title="Vim 键位,偏好保存在本机"
348 on:click=move |_| toggle_vim.run(())
349 >
350 "Vim"
351 </button>
352 <span class="runner-wide">
353 <Tooltip tip=Signal::derive(move || copy_label().to_string())>
354 <button
355 class="btn btn-sm btn-icon btn-ghost"
356 type="button"
357 aria-label=copy_label
358 on:click=move |_| copy.run(())
359 >
360 {move || {
361 view! {
362 <Icon
363 name=if copied.get() == Some(true) {
364 IconName::Check
365 } else {
366 IconName::Copy
367 }
368 size=15
369 />
370 }
371 }}
372 </button>
373 </Tooltip>
374 </span>
375 <span class="runner-narrow">
376 <Menu
377 label="更多操作"
378 items=menu_items
379 on_select=Callback::new(move |id: &'static str| match id {
380 "copy" => copy.run(()),
381 "revert" => revert.run(()),
382 "vim" => toggle_vim.run(()),
383 _ => {}
384 })
385 />
386 </span>
387 <Button
388 variant=ButtonVariant::Primary
389 size=ButtonSize::Sm
390 icon=IconName::Play
391 loading=Signal::derive(move || running.get() || awaiting_editor())
392 onclick=Callback::new(move |_| run.run(()))
393 >
394 <span class="runner-run-label">
395 <span>{run_label}</span>
396 <span class="runner-run-reserve" aria-hidden="true">
397 "再次运行"
398 </span>
399 </span>
400 </Button>
401 </div>
402 </div>
403 <LibraryLoadError library=editor_library/>
404 {move || {
405 editor_failed
406 .get()
407 .then(|| {
408 view! {
409 <div
410 role="alert"
411 class="flex items-center gap-3 p-3 text-sm text-red-500 dark:text-red-400"
412 >
413 <span>"编辑器初始化失败。"</span>
414 <button
415 type="button"
416 class="underline underline-offset-4"
417 on:click=move |_| {
418 editor_failed.set(false);
419 mount_retry.update(|n| *n += 1);
420 }
421 >
422 "重试"
423 </button>
424 </div>
425 }
426 })
427 }}
428 <div id=container_id node_ref=editor_mount class="runner-editor">
429 {move || {
433 (!editor_ready.get())
434 .then(|| {
435 view! {
436 <div
437 class="runner-placeholder code-runner-static"
438 aria-hidden="true"
439 title="点击启用编辑"
440 on:click=move |_| activated.set(true)
441 style=placeholder_style.clone()
442 >
443 <pre class="runner-placeholder-gutter">{gutter_text.clone()}</pre>
444 <pre class="runner-placeholder-code">
445 <code inner_html=placeholder_html.clone()></code>
446 </pre>
447 </div>
448 }
449 })
450 }}
451 </div>
452 {move || {
454 vim_enabled
455 .get()
456 .then(|| {
457 let mode = if editor_ready.get() {
458 vim_mode.get().unwrap_or_default()
459 } else {
460 String::new()
461 };
462 view! { <VimStatus mode/> }
463 })
464 }}
465 {move || {
467 let output_container_id = output_container_id.clone();
468 show_output
469 .get()
470 .then(|| {
471 view! {
472 <div class="runner-output">
473 <LibraryLoadError library=terminal_library/>
474 {move || {
475 terminal_failed
476 .get()
477 .then(|| {
478 view! {
479 <div
480 role="alert"
481 class="flex items-center gap-3 p-3 text-sm text-red-500 dark:text-red-400"
482 >
483 <span>"终端初始化失败。"</span>
484 <button
485 type="button"
486 class="underline underline-offset-4"
487 on:click=move |_| {
488 terminal_failed.set(false);
489 mount_retry.update(|n| *n += 1);
490 }
491 >
492 "重试"
493 </button>
494 </div>
495 }
496 })
497 }}
498 <div class="runner-output-head">
499 <span>{output_label}</span>
500 <div class="runner-output-meta">
501 <span class="tabular-nums">
502 {move || state.with(|state| state.meta())}
503 </span>
504 {example_mode_label
505 .then(|| {
506 view! {
507 <button
508 type="button"
509 on:click=move |_| execution.clear()
510 >
511 "清空"
512 </button>
513 }
514 })}
515 </div>
516 </div>
517 <div id=output_container_id node_ref=output_mount class="runner-terminal">
518 {move || {
519 (running.get() && !has_output.get())
520 .then(|| {
521 view! {
522 <div class="runner-terminal-skeleton">
523 <SkeletonBox class="h-2.5" style="width: 40%"/>
524 <SkeletonBox class="h-2.5" style="width: 64%"/>
525 {move || {
526 state
527 .with(|state| state.busy_hint())
528 .map(|hint| {
529 view! { <span class="runner-terminal-hint">{hint}</span> }
530 })
531 }}
532 </div>
533 }
534 })
535 }}
536 </div>
537 {move || {
538 state
539 .with(|state| state.note().map(|note| (note, state.retryable())))
540 .map(|(note, retryable)| {
541 view! {
542 <div class="runner-note">
543 <span>{note}</span>
544 {retryable
545 .then(|| {
546 view! {
547 <button
548 class="runner-retry"
549 type="button"
550 on:click=move |_| run.run(())
551 >
552 "重试"
553 </button>
554 }
555 })}
556 </div>
557 }
558 })
559 }}
560 </div>
561 }
562 })
563 }}
564 <Suspense fallback=|| ()>
566 {move || {
567 let args = body_args.clone();
568 activated
569 .get()
570 .then(|| Suspend::new(async move { code_runner_body(args).await }))
571 }}
572 </Suspense>
573 </figure>
574 }
575}
576
577#[component]
579fn VimStatus(mode: String) -> impl IntoView {
580 let (label, hint) = match mode.as_str() {
581 "insert" => ("INSERT", "Esc 回到普通模式 · ⌘↵ 运行"),
582 "visual" => ("VISUAL", "y 复制选区 · Esc 取消"),
583 "replace" => ("REPLACE", "Esc 回到普通模式"),
584 _ => ("NORMAL", "i 插入 · v 选择 · ⌘↵ 运行"),
585 };
586 view! {
587 <div class="runner-vim-status" data-mode=label>
588 <span class="runner-vim-mode">{format!("-- {label} --")}</span>
589 <span class="runner-vim-hint">{hint}</span>
590 </div>
591 }
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597 use leptos::reactive::owner::Owner;
598
599 fn render(view: impl FnOnce() -> AnyView) -> String {
600 Owner::new().with(|| {
601 crate::theme::provide_theme();
602 view().to_html()
603 })
604 }
605
606 #[test]
607 fn inactive_runner_renders_static_code_without_output_or_editor() {
608 let html = render(|| {
609 view! {
610 <CodeRunner
611 source="print('hi')\nprint(2)"
612 language="python"
613 instance_id=1usize
614 highlighted="<span>hi</span>".to_string()
615 />
616 }
617 .into_any()
618 });
619 assert!(html.contains("code-runner-static"), "{html}");
621 assert!(html.contains(r#"id="code-runner-1""#), "{html}");
622 assert!(html.contains("<span>hi</span>"), "{html}");
623 assert!(html.contains("可运行"), "{html}");
624 assert!(html.contains("NORMAL"), "{html}");
625 assert!(!html.contains("runner-output"), "{html}");
626 assert!(!html.contains("cm-editor"), "{html}");
627 }
628
629 #[test]
630 fn example_runner_shows_saved_output_without_running() {
631 let html = render(|| {
632 view! {
633 <CodeRunner
634 source="print(1)"
635 language="python"
636 instance_id=2usize
637 example_output=RunnerExample {
638 stdout: "1\n".into(),
639 stderr: String::new(),
640 status: "退出码 0".into(),
641 error: String::new(),
642 }
643 />
644 }
645 .into_any()
646 });
647 assert!(html.contains("播放示例"), "{html}");
648 assert!(html.contains("输出示例"), "{html}");
649 assert!(html.contains(r#"id="code-runner-output-2""#), "{html}");
650 assert!(html.contains("清空"), "{html}");
651 }
652
653 #[test]
654 fn placeholder_gutter_counts_lines() {
655 let html = render(|| {
656 view! { <CodeRunner source="a\nb\nc" language="python" instance_id=3usize/> }.into_any()
657 });
658 assert!(
659 html.contains("--runner-lines: 3; --runner-digits: 1;"),
660 "{html}"
661 );
662 }
663}