1#![allow(clippy::disallowed_methods, reason = "tooling is exempt")]
2
3use std::io::Write;
4use std::path::Path;
5use std::process::Command;
6
7use anyhow::{Context as _, Result, bail};
8use clap::Parser;
9
10#[derive(Parser)]
11pub struct WebExamplesArgs {
12 #[arg(long)]
13 pub release: bool,
14 #[arg(long, default_value = "8080")]
15 pub port: u16,
16 #[arg(long)]
17 pub no_serve: bool,
18}
19
20fn check_program(binary: &str, install_hint: &str) -> Result<()> {
21 match Command::new(binary).arg("--version").output() {
22 Ok(output) if output.status.success() => Ok(()),
23 _ => bail!("`{binary}` not found. Install with: {install_hint}"),
24 }
25}
26
27fn discover_examples() -> Result<Vec<String>> {
28 let examples_dir = Path::new("crates/gpui/examples");
29 let mut names = Vec::new();
30
31 for entry in std::fs::read_dir(examples_dir).context("failed to read crates/gpui/examples")? {
32 let path = entry?.path();
33 if path.extension().and_then(|e| e.to_str()) == Some("rs") {
34 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
35 names.push(stem.to_string());
36 }
37 }
38 }
39
40 if names.is_empty() {
41 bail!("no examples found in crates/gpui/examples");
42 }
43
44 names.sort();
45 Ok(names)
46}
47
48pub fn run_web_examples(args: WebExamplesArgs) -> Result<()> {
49 let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
50 let profile = if args.release { "release" } else { "debug" };
51 let out_dir = "target/web-examples";
52
53 check_program("wasm-bindgen", "cargo install wasm-bindgen-cli")?;
54
55 let examples = discover_examples()?;
56 eprintln!(
57 "Building {} example(s) for wasm32-unknown-unknown ({profile})...\n",
58 examples.len()
59 );
60
61 std::fs::create_dir_all(out_dir).context("failed to create output directory")?;
62
63 eprintln!("Building all examples...");
64
65 let mut cmd = Command::new(&cargo);
66 cmd.args([
67 "build",
68 "--target",
69 "wasm32-unknown-unknown",
70 "-p",
71 "gpui",
72 "--keep-going",
73 ]);
74 for name in &examples {
75 cmd.args(["--example", name]);
76 }
77 if args.release {
78 cmd.arg("--release");
79 }
80
81 let _ = cmd.status().context("failed to run cargo build")?;
82
83 // Run wasm-bindgen on each .wasm that was produced.
84 let mut succeeded: Vec<String> = Vec::new();
85 let mut failed: Vec<String> = Vec::new();
86
87 for name in &examples {
88 let wasm_path = format!("target/wasm32-unknown-unknown/{profile}/examples/{name}.wasm");
89 if !Path::new(&wasm_path).exists() {
90 eprintln!("[{name}] SKIPPED (build failed)");
91 failed.push(name.clone());
92 continue;
93 }
94
95 eprintln!("[{name}] Running wasm-bindgen...");
96
97 let example_dir = format!("{out_dir}/{name}");
98 std::fs::create_dir_all(&example_dir)
99 .with_context(|| format!("failed to create {example_dir}"))?;
100
101 let status = Command::new("wasm-bindgen")
102 .args([
103 &wasm_path,
104 "--target",
105 "web",
106 "--no-typescript",
107 "--out-dir",
108 &example_dir,
109 "--out-name",
110 name,
111 ])
112 .status()
113 .context("failed to run wasm-bindgen")?;
114 if !status.success() {
115 eprintln!("[{name}] SKIPPED (wasm-bindgen failed)");
116 failed.push(name.clone());
117 continue;
118 }
119
120 // Write per-example index.html.
121 let html_path = format!("{example_dir}/index.html");
122 std::fs::File::create(&html_path)
123 .and_then(|mut file| file.write_all(make_example_html(name).as_bytes()))
124 .with_context(|| format!("failed to write {html_path}"))?;
125
126 eprintln!("[{name}] OK");
127 succeeded.push(name.clone());
128 }
129
130 if succeeded.is_empty() {
131 bail!("all {} examples failed to build", examples.len());
132 }
133
134 let example_names: Vec<&str> = succeeded.iter().map(|s| s.as_str()).collect();
135 let index_path = format!("{out_dir}/index.html");
136 std::fs::File::create(&index_path)
137 .and_then(|mut file| file.write_all(make_gallery_html(&example_names).as_bytes()))
138 .context("failed to write index.html")?;
139
140 if args.no_serve {
141 return Ok(());
142 }
143
144 // Serve with COEP/COOP headers required for WebGPU / SharedArrayBuffer.
145 eprintln!("Serving on http://127.0.0.1:{}...", args.port);
146
147 let server_script = format!(
148 r#"
149import http.server
150class Handler(http.server.SimpleHTTPRequestHandler):
151 def __init__(self, *args, **kwargs):
152 super().__init__(*args, directory="{out_dir}", **kwargs)
153 def end_headers(self):
154 self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
155 self.send_header("Cross-Origin-Opener-Policy", "same-origin")
156 super().end_headers()
157http.server.HTTPServer(("127.0.0.1", {port}), Handler).serve_forever()
158"#,
159 port = args.port,
160 );
161
162 let status = Command::new("python3")
163 .args(["-c", &server_script])
164 .status()
165 .context("failed to run python3 http server (is python3 installed?)")?;
166 if !status.success() {
167 bail!("python3 http server exited with: {status}");
168 }
169
170 Ok(())
171}
172
173fn make_example_html(name: &str) -> String {
174 format!(
175 r#"<!DOCTYPE html>
176<html lang="en">
177<head>
178 <meta charset="utf-8">
179 <meta name="viewport" content="width=device-width, initial-scale=1.0">
180 <title>GPUI Web: {name}</title>
181 <style>
182 * {{ margin: 0; padding: 0; box-sizing: border-box; }}
183 html, body {{
184 width: 100%; height: 100%; overflow: hidden;
185 background: #1e1e2e; color: #cdd6f4;
186 font-family: system-ui, -apple-system, sans-serif;
187 }}
188 canvas {{ display: block; width: 100%; height: 100%; }}
189 #loading {{
190 position: fixed; inset: 0;
191 display: flex; align-items: center; justify-content: center;
192 font-size: 1.25rem; opacity: 0.6;
193 }}
194 #loading.hidden {{ display: none; }}
195 </style>
196</head>
197<body>
198 <div id="loading">Loading {name}…</div>
199 <script type="module">
200 import init from './{name}.js';
201 await init();
202 document.getElementById('loading').classList.add('hidden');
203 </script>
204</body>
205</html>
206"#
207 )
208}
209
210fn make_gallery_html(examples: &[&str]) -> String {
211 let mut buttons = String::new();
212 for name in examples {
213 buttons.push_str(&format!(
214 " <button class=\"example-btn\" data-name=\"{name}\">{name}</button>\n"
215 ));
216 }
217
218 let first = examples.first().copied().unwrap_or("hello_web");
219
220 format!(
221 r##"<!DOCTYPE html>
222<html lang="en">
223<head>
224 <meta charset="utf-8">
225 <meta name="viewport" content="width=device-width, initial-scale=1.0">
226 <title>GPUI Web Examples</title>
227 <style>
228 * {{ margin: 0; padding: 0; box-sizing: border-box; }}
229 html, body {{
230 width: 100%; height: 100%; overflow: hidden;
231 background: #1e1e2e; color: #cdd6f4;
232 font-family: system-ui, -apple-system, sans-serif;
233 }}
234 #app {{ display: flex; width: 100%; height: 100%; }}
235
236 #sidebar {{
237 width: 240px; min-width: 240px;
238 background: #181825;
239 border-right: 1px solid #313244;
240 display: flex; flex-direction: column;
241 }}
242 #sidebar-header {{
243 padding: 16px 14px 12px;
244 font-size: 0.8rem; font-weight: 700;
245 text-transform: uppercase; letter-spacing: 0.08em;
246 color: #a6adc8; border-bottom: 1px solid #313244;
247 }}
248 #sidebar-header span {{
249 font-size: 1rem; text-transform: none; letter-spacing: normal;
250 color: #cdd6f4; display: block; margin-top: 2px;
251 }}
252 #example-list {{
253 flex: 1; overflow-y: auto; padding: 8px 0;
254 }}
255 .example-btn {{
256 display: block; width: 100%;
257 padding: 8px 14px; border: none;
258 background: transparent; color: #bac2de;
259 font-size: 0.85rem; text-align: left;
260 cursor: pointer;
261 font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
262 }}
263 .example-btn:hover {{ background: #313244; color: #cdd6f4; }}
264 .example-btn.active {{ background: #45475a; color: #f5e0dc; font-weight: 600; }}
265
266 #main {{ flex: 1; display: flex; flex-direction: column; min-width: 0; }}
267 #toolbar {{
268 height: 40px; display: flex; align-items: center;
269 padding: 0 16px; gap: 12px;
270 background: #1e1e2e; border-bottom: 1px solid #313244;
271 font-size: 0.8rem; color: #a6adc8;
272 }}
273 #current-name {{
274 font-weight: 600; color: #cdd6f4;
275 font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
276 }}
277 #open-tab {{
278 margin-left: auto; padding: 4px 10px;
279 border: 1px solid #585b70; border-radius: 4px;
280 background: transparent; color: #a6adc8;
281 font-size: 0.75rem; cursor: pointer;
282 text-decoration: none;
283 }}
284 #open-tab:hover {{ background: #313244; color: #cdd6f4; }}
285 #viewer {{ flex: 1; border: none; width: 100%; background: #11111b; }}
286 </style>
287</head>
288<body>
289 <div id="app">
290 <div id="sidebar">
291 <div id="sidebar-header">
292 GPUI Examples
293 <span>{count} available</span>
294 </div>
295 <div id="example-list">
296{buttons} </div>
297 </div>
298 <div id="main">
299 <div id="toolbar">
300 <span id="current-name">{first}</span>
301 <a id="open-tab" href="./{first}/" target="_blank">Open in new tab ↗</a>
302 </div>
303 <iframe id="viewer" src="./{first}/"></iframe>
304 </div>
305 </div>
306 <script>
307 const buttons = document.querySelectorAll('.example-btn');
308 const viewer = document.getElementById('viewer');
309 const nameEl = document.getElementById('current-name');
310 const openEl = document.getElementById('open-tab');
311
312 function select(name) {{
313 buttons.forEach(b => b.classList.toggle('active', b.dataset.name === name));
314 viewer.src = './' + name + '/';
315 nameEl.textContent = name;
316 openEl.href = './' + name + '/';
317 history.replaceState(null, '', '#' + name);
318 }}
319
320 buttons.forEach(b => b.addEventListener('click', () => select(b.dataset.name)));
321
322 const hash = location.hash.slice(1);
323 if (hash && [...buttons].some(b => b.dataset.name === hash)) {{
324 select(hash);
325 }} else {{
326 select('{first}');
327 }}
328 </script>
329</body>
330</html>
331"##,
332 count = examples.len(),
333 )
334}