1use std::collections::HashMap;
2use std::convert::Infallible;
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6
7use axum::{
8 extract::{Path, State},
9 response::{
10 sse::{Event, KeepAlive, Sse},
11 Html, IntoResponse,
12 },
13 routing::{get, post},
14 Router,
15};
16use tokio::sync::Mutex;
17
18use triforge_core::prelude::*;
19use triforge_core::schema::{ArgKind, CommandSchema};
20
21pub struct GuiRenderer {
24 port: u16,
25}
26
27pub type RunnerFn = Arc<
28 dyn Fn(HashMap<String, serde_json::Value>) -> Pin<Box<dyn Future<Output = Result<serde_json::Value, AppError>> + Send>>
29 + Send
30 + Sync,
31>;
32
33impl GuiRenderer {
34 pub fn new(port: u16) -> Self { Self { port } }
35
36 pub async fn serve(&self, schema: CommandSchema, runner: RunnerFn) {
37 let state = Arc::new(AppState {
38 schema: Arc::new(schema),
39 sessions: Mutex::new(HashMap::new()),
40 runner,
41 });
42
43 let app = Router::new()
44 .route("/", get(index))
45 .route("/run", post(run_handler))
46 .route("/progress/{id}", get(progress_handler))
47 .with_state(state);
48
49 let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", self.port))
50 .await.unwrap();
51 eprintln!("Triforge GUI ready: http://localhost:{}", self.port);
52 axum::serve(listener, app).await.unwrap();
53 }
54}
55
56struct AppState {
59 schema: Arc<CommandSchema>,
60 sessions: Mutex<HashMap<String, tokio::sync::mpsc::Receiver<serde_json::Value>>>,
61 runner: RunnerFn,
62}
63
64async fn index(State(state): State<Arc<AppState>>) -> Html<String> {
67 let schema = &state.schema;
68 let mut fields_html = String::new();
69 let mut field_js_meta = String::new();
70
71 for (i, arg) in schema.args.iter().enumerate() {
72 if i > 0 { field_js_meta.push(','); }
73 field_js_meta.push_str(&format!("{{name:\"{}\",kind:\"{}\"}}", arg.name, kind_name(&arg.kind)));
74
75 let req = if arg.required { " required" } else { "" };
76 let req_label = if arg.required { " class=\"req\"" } else { "" };
77
78 let widget = match &arg.kind {
79 ArgKind::Flag => {
80 let ck = matches!(&arg.default, Some(serde_json::Value::Bool(true))).then_some(" checked").unwrap_or("");
81 format!("<input type=\"checkbox\" id=\"field-{}\"{}>", arg.name, ck)
82 }
83 ArgKind::Text | ArgKind::Path { .. } => {
84 let dv = arg.default.as_ref().and_then(|d| d.as_str()).unwrap_or("");
85 format!("<input type=\"text\" id=\"field-{}\" placeholder=\"{}\"{} value=\"{}\">", arg.name, arg.about, req, dv)
86 }
87 ArgKind::Number { min, max } => {
88 let dv = arg.default.as_ref().and_then(|d| d.as_f64())
89 .map(|n| n.to_string())
90 .unwrap_or_else(|| min.map(|m| m.to_string()).unwrap_or_default());
91 let min_a = min.map(|m| format!(" min=\"{m}\"")).unwrap_or_default();
92 let max_a = max.map(|m| format!(" max=\"{m}\"")).unwrap_or_default();
93 format!("<input type=\"number\" id=\"field-{}\"{} value=\"{}\"{}{}>", arg.name, req, dv, min_a, max_a)
94 }
95 ArgKind::Enum { values } => {
96 let mut opts = String::new();
97 for v in values {
98 let sel = if arg.default.as_ref().and_then(|d| d.as_str()) == Some(v.as_str()) { " selected" } else { "" };
99 opts.push_str(&format!("<option value=\"{v}\"{sel}>{v}</option>"));
100 }
101 format!("<select id=\"field-{}\"{}>{}</select>", arg.name, req, opts)
102 }
103 ArgKind::List { .. } => {
104 let mut inputs = String::new();
105 for j in 0..3 {
106 inputs.push_str(&format!(
107 "<input type=\"text\" id=\"field-{}-{}\" placeholder=\"{} #{}\" style=\"margin-bottom:4px\">",
108 arg.name, j, arg.about, j + 1
109 ));
110 }
111 format!("<div style=\"display:flex;flex-direction:column;gap:4px;flex:1\">{inputs}</div>")
112 }
113 };
114
115 fields_html.push_str(&format!(
116 "<div class=\"field\"><label for=\"field-{}\"{}>{}</label>{}</div>\n",
117 arg.name, req_label, arg.about, widget
118 ));
119 }
120
121 Html(HTML_TEMPLATE
122 .replace("{title}", &format!("{} — {}", schema.name, schema.about))
123 .replace("{about}", &schema.about)
124 .replace("{cmd_name}", &schema.name)
125 .replace("{fields_html}", &fields_html)
126 .replace("{field_js_meta}", &field_js_meta))
127}
128
129fn kind_name(kind: &ArgKind) -> &'static str {
130 match kind {
131 ArgKind::Flag => "Flag", ArgKind::Text => "Text",
132 ArgKind::Number { .. } => "Number", ArgKind::Enum { .. } => "Enum",
133 ArgKind::Path { .. } => "Path", ArgKind::List { .. } => "List",
134 }
135}
136
137const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
138<html lang="en"><head>
139<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
140<title>{title}</title>
141<style>
142*{box-sizing:border-box;margin:0;padding:0}
143body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0f1117;color:#e1e4e8;max-width:760px;margin:48px auto;padding:0 24px}
144h1{font-size:20px;margin-bottom:4px}
145.about{color:#8b949e;margin-bottom:28px;font-size:14px}
146.field{display:flex;align-items:center;margin-bottom:14px;gap:12px}
147.field label{width:150px;text-align:right;font-size:14px;flex-shrink:0}
148.field label.req::after{content:" *";color:#f85149}
149.field input,.field select{flex:1;padding:8px 12px;background:#21262d;border:1px solid #30363d;border-radius:6px;color:#e1e4e8;font-size:14px}
150.field input:focus,.field select:focus{outline:none;border-color:#58a6ff;box-shadow:0 0 0 1px #58a6ff44}
151.actions{margin-top:24px;display:flex;gap:12px}
152.actions button{padding:10px 24px;border-radius:6px;font-size:14px;cursor:pointer;border:none}
153.btn-run{background:#238636;color:#fff}.btn-run:hover{background:#2ea043}
154.btn-copy{background:#21262d;color:#c9d1d9;border:1px solid #30363d}
155.cli-preview{margin-top:14px;padding:10px 14px;background:#161b22;border-radius:6px;font-family:monospace;font-size:13px;color:#7ee787;word-break:break-all}
156.output{margin-top:28px}
157.output h3{font-size:16px;margin-bottom:10px}
158.progress-bar{width:100%;height:8px;background:#21262d;border-radius:4px;overflow:hidden;margin-bottom:10px}
159.progress-bar .fill{height:100%;background:#238636;transition:width .3s;border-radius:4px;width:0%}
160.log{max-height:220px;overflow-y:auto;font-size:12px;color:#8b949e;background:#161b22;padding:10px;border-radius:6px;white-space:pre-wrap}
161.log .err{color:#f85149}
162</style></head><body>
163<h1>{title}</h1>
164<p class="about">{about}</p>
165<form id="form">
166{fields_html}
167<div class="actions">
168<button type="submit" class="btn-run">▶ Run</button>
169<button type="button" class="btn-copy" onclick="copyCmd()">📋 Copy CLI</button>
170</div>
171<div class="cli-preview" id="preview">$ {cmd_name}</div>
172</form>
173<div class="output" id="out" style="display:none">
174<h3>Output</h3>
175<div class="progress-bar"><div class="fill" id="pbar"></div></div>
176<div class="log" id="log"></div>
177<pre id="result" style="color:#e1e4e8;font-size:13px;margin-top:10px;overflow-x:auto"></pre>
178</div>
179<script>
180const SCHEMA={name:"{cmd_name}",args:[{field_js_meta}]};
181const form=document.getElementById("form");
182const preview=document.getElementById("preview");
183const out=document.getElementById("out");
184const pbar=document.getElementById("pbar");
185const logEl=document.getElementById("log");
186const resultEl=document.getElementById("result");
187function updatePreview(){var parts=[SCHEMA.name];
188for(var a of SCHEMA.args){var el=document.getElementById("field-"+a.name);if(!el)continue;
189if(a.kind==="Flag"){if(el.checked)parts.push("--"+a.name)}
190else if(a.kind==="List"){document.querySelectorAll("[id^=field-"+a.name+"-]").forEach(function(inp){if(inp.value)parts.push("--"+a.name+" "+inp.value)})}
191else{if(el.value){var v=a.kind==="Path"&&el.value.includes(" ")?'"'+el.value+'"':el.value;parts.push("--"+a.name+" "+v)}}}
192preview.textContent="$ "+parts.join(" ")}
193document.querySelectorAll("input,select").forEach(function(el){el.addEventListener("input",updatePreview)});
194form.addEventListener("submit",async function(e){e.preventDefault();out.style.display="block";pbar.style.width="0%";logEl.innerHTML="";resultEl.textContent="";
195var data={};
196for(var a of SCHEMA.args){if(a.kind==="List"){data[a.name]=[];document.querySelectorAll("[id^=field-"+a.name+"-]").forEach(function(inp){if(inp.value)data[a.name].push(inp.value)})}
197else{var el=document.getElementById("field-"+a.name);if(a.kind==="Flag")data[a.name]=el.checked;else data[a.name]=el.value}}
198var sid=Math.random().toString(36).slice(2);
199var es=new EventSource("/progress/"+sid);
200es.onmessage=function(ev){var p=JSON.parse(ev.data);
201if(p.type==="tick"){pbar.style.width=(p.percent*100)+"%";logEl.innerHTML+=(p.message||"")+"\n"}
202else if(p.type==="log"){logEl.innerHTML+="["+(p.level||"info")+"] "+(p.message||"")+"\n"}
203else if(p.type==="done"){resultEl.textContent=JSON.stringify(p.result,null,2);es.close()}
204else if(p.type==="error"){logEl.innerHTML+="<span class=err>ERROR: "+(p.message||"")+"</span>\n";es.close()}
205logEl.scrollTop=logEl.scrollHeight};
206es.onerror=function(){es.close()};
207try{await fetch("/run",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({session_id:sid,args:data})})}catch(err){logEl.innerHTML+="<span class=err>Failed: "+err+"</span>\n"}
208});
209function copyCmd(){navigator.clipboard.writeText(preview.textContent.slice(2))}
210</script></body></html>"#;
211
212#[derive(serde::Deserialize)]
215struct RunRequest {
216 session_id: String,
217 args: HashMap<String, serde_json::Value>,
218}
219
220async fn run_handler(
221 State(state): State<Arc<AppState>>,
222 axum::Json(req): axum::Json<RunRequest>,
223) -> impl IntoResponse {
224 let (tx, rx) = tokio::sync::mpsc::channel::<serde_json::Value>(128);
225 {
226 let mut sessions = state.sessions.lock().await;
227 sessions.insert(req.session_id, rx);
228 }
229
230 let runner = state.runner.clone();
231
232 tokio::spawn(async move {
233 let result = runner(req.args).await;
234 let msg = match result {
235 Ok(val) => serde_json::json!({"type":"done","result":val,"duration_ms":0}),
236 Err(e) => serde_json::json!({"type":"error","code":1,"message":e.to_string()}),
237 };
238 let _ = tx.send(msg).await;
239 });
240
241 "OK"
242}
243
244async fn progress_handler(
245 State(state): State<Arc<AppState>>,
246 Path(id): Path<String>,
247) -> impl IntoResponse {
248 let maybe_rx = { state.sessions.lock().await.remove(&id) };
249 let found = maybe_rx.is_some();
250
251 let stream = async_stream::stream! {
252 if !found {
253 yield Result::<Event, Infallible>::Ok(Event::default().data(
254 serde_json::json!({"type":"error","message":"session not found"}).to_string()
255 ));
256 } else {
257 let mut rx = maybe_rx.unwrap();
258 yield Ok(Event::default().data(
259 serde_json::json!({"type":"started","message":"Running..."}).to_string()
260 ));
261 while let Some(msg) = rx.recv().await {
262 yield Ok(Event::default().data(msg.to_string()));
263 }
264 }
265 };
266
267 Sse::new(stream).keep_alive(KeepAlive::default())
268}