fix(ui): hide output area until first run; show skeleton while waiting

The output area was always visible (empty terminal container shown on
page load). Now it only appears after the user clicks Run, with three
states:

- Not run: output area hidden entirely (show_output = false)
- Running, no output yet: skeleton screen placeholder
  (running && !has_output)
- Output received: skeleton gone, xterm renders streamed content

Two new signals drive this:
- show_output: set true on Run click, controls output area visibility
- has_output: set true on first stdout/stderr chunk (SSE) or writeAll
  (polling fallback), dismisses the skeleton

xterm mount effect now reads show_output, so it retries after the
container div enters the DOM (when show_output flips true).
This commit is contained in:
xfy 2026-07-13 09:49:57 +08:00
parent e347776dcd
commit 9191d07200

View File

@ -55,6 +55,10 @@ pub fn CodeRunner(
let mut output = use_signal(String::new); let mut output = use_signal(String::new);
let mut exit_info = use_signal(String::new); let mut exit_info = use_signal(String::new);
let mut error_msg = use_signal(String::new); let mut error_msg = use_signal(String::new);
// 输出区可见性:点运行后置 true控制输出区按需出现而非页面加载就显示空区
let mut show_output = use_signal(|| false);
// 是否已收到首个输出 chunk收到后骨架屏消失露出终端实时渲染。
let mut has_output = use_signal(|| false);
// 编辑器内容的唯一真源;初始化为 prop 值。 // 编辑器内容的唯一真源;初始化为 prop 值。
let mut source_signal = use_signal(|| source.clone()); let mut source_signal = use_signal(|| source.clone());
@ -220,11 +224,16 @@ pub fn CodeRunner(
use wasm_bindgen::closure::Closure; use wasm_bindgen::closure::Closure;
// 首次挂载:构造 onReady 闭包 + XtermOptionscreate 后存进 term_handle。 // 首次挂载:构造 onReady 闭包 + XtermOptionscreate 后存进 term_handle。
// 订阅 show_output输出区在 show_output 变 true用户点运行后才渲染进 DOM
// 容器此前不存在;读 show_output 建立订阅,使其变 true 时重跑本 effect 完成挂载。
let mount_container_id = output_container_id.clone(); let mount_container_id = output_container_id.clone();
use_effect(move || { use_effect(move || {
if term_handle.read().is_some() { if term_handle.read().is_some() {
return; // 防重复 init return; // 防重复 init
} }
if !show_output() {
return; // 输出区未显示,容器不在 DOM等 show_output 变 true 再挂载
}
let on_ready = Closure::new(|| {}); let on_ready = Closure::new(|| {});
let resolved = use_resolved_theme(); let resolved = use_resolved_theme();
@ -281,6 +290,8 @@ pub fn CodeRunner(
let mut error_msg = error_msg; let mut error_msg = error_msg;
let mut source_signal = source_signal; let mut source_signal = source_signal;
let mut term_handle = term_handle; let mut term_handle = term_handle;
let mut show_output = show_output;
let mut has_output = has_output;
let run_language = run_language.clone(); let run_language = run_language.clone();
let run_overrides = run_overrides.clone(); let run_overrides = run_overrides.clone();
move |_| { move |_| {
@ -288,6 +299,8 @@ pub fn CodeRunner(
return; return;
} }
running.set(true); running.set(true);
show_output.set(true);
has_output.set(false);
stage.set("提交中...".to_string()); stage.set("提交中...".to_string());
error_msg.set(String::new()); error_msg.set(String::new());
@ -324,6 +337,7 @@ pub fn CodeRunner(
&mut running, &mut running,
&mut exit_info, &mut exit_info,
&mut error_msg, &mut error_msg,
&mut has_output,
) )
.is_err() .is_err()
{ {
@ -336,6 +350,7 @@ pub fn CodeRunner(
&mut exit_info, &mut exit_info,
&mut error_msg, &mut error_msg,
&term_handle, &term_handle,
&mut has_output,
) )
.await; .await;
} }
@ -352,6 +367,7 @@ pub fn CodeRunner(
let mut exit_info = exit_info; let mut exit_info = exit_info;
let mut error_msg = error_msg; let mut error_msg = error_msg;
let mut source_signal = source_signal; let mut source_signal = source_signal;
let mut show_output = show_output;
let run_language = run_language.clone(); let run_language = run_language.clone();
let run_overrides = run_overrides.clone(); let run_overrides = run_overrides.clone();
move |_| { move |_| {
@ -359,6 +375,7 @@ pub fn CodeRunner(
return; return;
} }
running.set(true); running.set(true);
show_output.set(true);
stage.set("提交中...".to_string()); stage.set("提交中...".to_string());
output.set(String::new()); output.set(String::new());
exit_info.set(String::new()); exit_info.set(String::new());
@ -484,18 +501,26 @@ pub fn CodeRunner(
} }
} }
} }
// 输出区xterm.js 终端容器WASM 实时流式渲染)。 // 输出区用户点运行后才显示show_output
// 终端在 WASM 端按 output_container_id 挂载;未挂载时显示占位。 // running 时显示骨架屏占位(等首个 chunk 到达);有内容后 xterm 终端渲染。
// 始终渲染容器,保证 xterm create() 能找到挂载点。 if show_output() {
div { class: "border-t border-[var(--color-paper-border)]", div { class: "border-t border-[var(--color-paper-border)]",
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)]", 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)]",
span { class: "font-medium uppercase tracking-wide", "输出" } span { class: "font-medium uppercase tracking-wide", "输出" }
span { "{exit_info()}" } span { "{exit_info()}" }
} }
div { div {
id: "{output_container_id}", id: "{output_container_id}",
class: "px-2 py-2 bg-[var(--color-paper-code-block)] max-h-80 overflow-hidden text-xs", class: "px-2 py-2 bg-[var(--color-paper-code-block)] max-h-80 overflow-hidden text-xs relative min-h-24",
// xterm.js 在 WASM 端挂载到此 divSSR / hydration 前为空。 // running 且尚未收到首个 chunk 时,显示骨架屏占位。
if running() && !has_output() {
div { class: "absolute inset-0 flex flex-col justify-center gap-2.5 px-4 py-4 bg-[var(--color-paper-code-block)]",
div { class: "h-3 rounded bg-[var(--color-paper-tertiary)]/25 dark:bg-gray-600/50 animate-pulse", style: "width: 70%" }
div { class: "h-3 rounded bg-[var(--color-paper-tertiary)]/25 dark:bg-gray-600/50 animate-pulse", style: "width: 55%" }
div { class: "h-3 rounded bg-[var(--color-paper-tertiary)]/25 dark:bg-gray-600/50 animate-pulse", style: "width: 85%" }
}
}
}
} }
} }
// 错误提示 // 错误提示
@ -558,29 +583,34 @@ mod sse_consumer {
running: &mut Signal<bool>, running: &mut Signal<bool>,
exit_info: &mut Signal<String>, exit_info: &mut Signal<String>,
error_msg: &mut Signal<String>, error_msg: &mut Signal<String>,
has_output: &mut Signal<bool>,
) -> Result<(), JsValue> { ) -> Result<(), JsValue> {
let url = format!("/api/exec/stream?task_id={task_id}"); let url = format!("/api/exec/stream?task_id={task_id}");
let es = EventSource::new(&url)?; let es = EventSource::new(&url)?;
// stdout 事件 → 终端 writeStdout // stdout 事件 → 终端 writeStdout + 标记已有输出(骨架屏消失)
let term_for_stdout = *term_handle; let term_for_stdout = *term_handle;
let mut has_output_for_stdout = *has_output;
let on_stdout = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| { let on_stdout = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
if let Some(s) = e.data().as_string() { if let Some(s) = e.data().as_string() {
if let Some(h) = term_for_stdout.read().as_ref() { if let Some(h) = term_for_stdout.read().as_ref() {
h.instance().write_stdout(&s); h.instance().write_stdout(&s);
} }
has_output_for_stdout.set(true);
} }
}); });
es.add_event_listener_with_callback("stdout", on_stdout.as_ref().unchecked_ref())?; es.add_event_listener_with_callback("stdout", on_stdout.as_ref().unchecked_ref())?;
on_stdout.forget(); on_stdout.forget();
// stderr 事件 → 终端 writeStderr红色 // stderr 事件 → 终端 writeStderr红色+ 标记已有输出
let term_for_stderr = *term_handle; let term_for_stderr = *term_handle;
let mut has_output_for_stderr = *has_output;
let on_stderr = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| { let on_stderr = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
if let Some(s) = e.data().as_string() { if let Some(s) = e.data().as_string() {
if let Some(h) = term_for_stderr.read().as_ref() { if let Some(h) = term_for_stderr.read().as_ref() {
h.instance().write_stderr(&s); h.instance().write_stderr(&s);
} }
has_output_for_stderr.set(true);
} }
}); });
es.add_event_listener_with_callback("stderr", on_stderr.as_ref().unchecked_ref())?; es.add_event_listener_with_callback("stderr", on_stderr.as_ref().unchecked_ref())?;
@ -647,6 +677,7 @@ mod sse_consumer {
exit_info: &mut Signal<String>, exit_info: &mut Signal<String>,
error_msg: &mut Signal<String>, error_msg: &mut Signal<String>,
term_handle: &Signal<Option<TerminalHandle>>, term_handle: &Signal<Option<TerminalHandle>>,
has_output: &mut Signal<bool>,
) { ) {
let mut polls = 0u32; let mut polls = 0u32;
loop { loop {
@ -677,6 +708,7 @@ mod sse_consumer {
if let Some(h) = term_handle.read().as_ref() { if let Some(h) = term_handle.read().as_ref() {
h.instance().write_all(&res.stdout, &res.stderr); h.instance().write_all(&res.stdout, &res.stderr);
} }
has_output.set(true);
} }
break; break;
} }