use yew::prelude::*;
use crate::api::types::{ExecutionResponse, ExecutionStatus};
#[derive(Properties, PartialEq)]
pub struct TerminalOutputProps {
pub visible: bool,
pub execution: Option<ExecutionResponse>,
pub on_close: Callback<()>,
#[prop_or_default]
pub on_rerun: Option<Callback<()>>,
#[prop_or_default]
pub on_copy: Option<Callback<String>>,
#[prop_or(false)]
pub minimized: bool,
pub on_toggle_minimize: Callback<()>,
}
#[function_component(TerminalOutput)]
pub fn terminal_output(props: &TerminalOutputProps) -> Html {
let terminal_ref = use_node_ref();
use_effect_with((props.execution.clone(), terminal_ref.clone()), |(execution, terminal_ref)| {
if execution.is_some() {
if let Some(terminal) = terminal_ref.cast::<web_sys::HtmlElement>() {
terminal.set_scroll_top(terminal.scroll_height());
}
}
|| ()
});
let on_close_click = {
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
e.prevent_default();
on_close.emit(());
})
};
let on_minimize_click = {
let on_toggle = props.on_toggle_minimize.clone();
Callback::from(move |e: MouseEvent| {
e.prevent_default();
on_toggle.emit(());
})
};
let on_rerun_click = {
let on_rerun = props.on_rerun.clone();
Callback::from(move |e: MouseEvent| {
e.prevent_default();
if let Some(callback) = &on_rerun {
callback.emit(());
}
})
};
let on_copy_click = {
let on_copy = props.on_copy.clone();
let output = props.execution.as_ref().map(|e| e.output.clone());
Callback::from(move |e: MouseEvent| {
e.prevent_default();
if let Some(callback) = &on_copy {
if let Some(output) = &output {
callback.emit(output.clone());
}
}
})
};
let formatted_output = props.execution.as_ref().map(|exec| {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&exec.output) {
serde_json::to_string_pretty(&json).unwrap_or_else(|_| exec.output.clone())
} else {
exec.output.clone()
}
});
let status_class = props.execution.as_ref().map(|exec| {
match exec.status {
ExecutionStatus::Success => "text-success-500",
ExecutionStatus::Failed | ExecutionStatus::Timeout => "text-error-500",
ExecutionStatus::Running => "text-primary-500",
ExecutionStatus::Pending => "text-gray-500 dark:text-gray-400",
ExecutionStatus::Cancelled => "text-warning-500",
}
});
if !props.visible {
return html! {};
}
html! {
<div class={classes!(
"fixed", "bottom-0", "left-0", "right-0",
"bg-white", "dark:bg-gray-900",
"border-t", "border-gray-200", "dark:border-gray-700",
"shadow-lg",
"z-50",
"transition-all", "duration-200",
props.visible.then(|| "translate-y-0").unwrap_or("translate-y-full"),
props.minimized.then(|| "h-14").unwrap_or("h-[60vh]")
)}>
<div class="flex items-center justify-between px-6 py-3 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-4">
if let Some(exec) = &props.execution {
<div class="flex items-center gap-2">
<span class={classes!("text-sm", "font-semibold", status_class.clone())}>
{ format!("{:?}", exec.status) }
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{ format!("({}ms)", exec.duration_ms) }
</span>
</div>
} else {
<span class="text-sm text-gray-500 dark:text-gray-400">
{ "Waiting for execution..." }
</span>
}
</div>
<div class="flex items-center gap-2">
if props.on_copy.is_some() && props.execution.is_some() {
<button
onclick={on_copy_click}
class="p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 transition-colors"
title="Copy output"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
}
if props.on_rerun.is_some() {
<button
onclick={on_rerun_click}
class="p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 transition-colors"
title="Re-run command"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
}
<button
onclick={on_minimize_click}
class="p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 transition-colors"
title={if props.minimized { "Maximize" } else { "Minimize" }}
>
if props.minimized {
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
} else {
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
}
</button>
<button
onclick={on_close_click}
class="p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-error-500 transition-colors"
title="Close"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
if !props.minimized {
<div
ref={terminal_ref}
class="p-6 overflow-y-auto h-[calc(100%-56px)] bg-gray-50 dark:bg-gray-900"
>
if let Some(exec) = &props.execution {
<div class="text-gray-600 dark:text-gray-400 mb-4 font-mono text-sm">
<span>{ "$ " }</span>
<span class="text-primary-500">{ &exec.id }</span>
</div>
<pre class={classes!(
"whitespace-pre-wrap",
"break-words",
"font-mono",
"text-sm",
"text-gray-900",
"dark:text-gray-100",
status_class
)}>
{ formatted_output.as_ref().unwrap_or(&exec.output) }
</pre>
if let Some(error) = &exec.error {
<div class="mt-4 p-4 bg-error-50 dark:bg-error-900/20 border-l-4 border-error-500 rounded">
<div class="text-error-500 font-semibold mb-2">
{ "Error:" }
</div>
<pre class="text-error-500 whitespace-pre-wrap font-mono text-sm">
{ error }
</pre>
</div>
}
if exec.status == ExecutionStatus::Success {
<div class="mt-4 text-success-500 font-mono text-sm">
{ format!("✓ Success ({}ms)", exec.duration_ms) }
</div>
}
} else {
<div class="flex items-center gap-2 text-gray-600 dark:text-gray-400 font-mono text-sm">
<span>{ "Executing" }</span>
<span class="animate-pulse">{ "..." }</span>
</div>
}
</div>
}
</div>
}
}