1use std::{
5 collections::{HashMap, VecDeque},
6 fs,
7 net::{IpAddr, SocketAddr},
8 path::{Path, PathBuf},
9 process::Stdio,
10 sync::Arc,
11 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
12};
13
14use anyhow::{Context, Result};
15use askama::Template;
16use axum::{
17 body::Body,
18 extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
19 http::{header, HeaderValue, Request, StatusCode},
20 middleware::{self, Next},
21 response::{Html, IntoResponse, Response},
22 routing::{get, post},
23 Json, Router,
24};
25use serde::{Deserialize, Serialize};
26use tokio::sync::Mutex;
27use tower_http::cors::CorsLayer;
28
29use sloc_config::{AppConfig, BinaryFileBehavior, MixedLinePolicy};
30
31static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
32
33use sloc_core::{
34 analyze, compute_delta, read_json, AnalysisRun, FileChangeStatus, RegistryEntry, ScanRegistry,
35 ScanSummarySnapshot, SummaryTotals,
36};
37use sloc_report::{render_html, render_sub_report_html, write_pdf_from_html};
38const MAX_CONCURRENT_ANALYSES: usize = 4;
39
40struct IpRateLimiter {
43 window: Duration,
44 max_requests: usize,
45 state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
46}
47
48impl IpRateLimiter {
49 fn new(window: Duration, max_requests: usize) -> Self {
50 Self {
51 window,
52 max_requests,
53 state: std::sync::Mutex::new(HashMap::new()),
54 }
55 }
56
57 fn is_allowed(&self, ip: IpAddr) -> bool {
58 let now = Instant::now();
59 let cutoff = now.checked_sub(self.window).unwrap_or(now);
60 let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
61 let bucket = state.entry(ip).or_default();
62 while bucket.front().map(|t| *t <= cutoff).unwrap_or(false) {
63 bucket.pop_front();
64 }
65 if bucket.len() >= self.max_requests {
66 return false;
67 }
68 bucket.push_back(now);
69 true
70 }
71}
72
73#[derive(Clone)]
74struct AppState {
75 base_config: AppConfig,
76 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
77 registry: Arc<Mutex<ScanRegistry>>,
78 registry_path: PathBuf,
79 analyze_semaphore: Arc<tokio::sync::Semaphore>,
80 server_mode: bool,
81 tls_enabled: bool,
82 api_key: Option<String>,
83 rate_limiter: Arc<IpRateLimiter>,
84}
85
86type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
87
88#[derive(Clone, Debug)]
89struct RunArtifacts {
90 output_dir: PathBuf,
91 html_path: Option<PathBuf>,
92 pdf_path: Option<PathBuf>,
93 json_path: Option<PathBuf>,
94 report_title: String,
95}
96
97pub async fn serve(config: AppConfig) -> Result<()> {
98 let bind_address = config.web.bind_address.clone();
99 let server_mode = config.web.server_mode;
100 let output_root = resolve_output_root(None).unwrap_or_else(|_| PathBuf::from("out/web"));
101 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
103 .map(PathBuf::from)
104 .unwrap_or_else(|_| output_root.join("registry.json"));
105 let mut registry = ScanRegistry::load(®istry_path);
106 registry.prune_stale();
107 let _ = registry.save(®istry_path);
108
109 let api_key = std::env::var("SLOC_API_KEY").ok().filter(|k| !k.is_empty());
110 if server_mode && api_key.is_none() {
111 println!(
112 "WARNING: SLOC_API_KEY is not set. All web endpoints are unauthenticated. \
113 Set SLOC_API_KEY to enable bearer-token authentication."
114 );
115 }
116
117 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
119 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
120 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
121 if server_mode && !tls_enabled {
122 println!(
123 "WARNING: TLS is not configured. Traffic is cleartext. \
124 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
125 or terminate TLS at a reverse proxy (nginx, caddy)."
126 );
127 }
128
129 let rate_limiter = Arc::new(IpRateLimiter::new(Duration::from_secs(60), 60));
131
132 let state = AppState {
133 base_config: config,
134 artifacts: Arc::new(Mutex::new(HashMap::new())),
135 registry: Arc::new(Mutex::new(registry)),
136 registry_path,
137 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
138 server_mode,
139 tls_enabled,
140 api_key,
141 rate_limiter,
142 };
143
144 let protected = Router::new()
145 .route("/", get(splash))
146 .route("/scan-setup", get(scan_setup_handler))
147 .route("/scan", get(index))
148 .route("/analyze", post(analyze_handler))
149 .route("/preview", get(preview_handler))
150 .route("/pick-directory", get(pick_directory_handler))
151 .route("/open-path", get(open_path_handler))
152 .route("/pick-file", get(pick_file_handler))
153 .route("/locate-report", post(locate_report_handler))
154 .route("/view-reports", get(history_handler))
155 .route("/compare-scans", get(compare_select_handler))
156 .route("/compare", get(compare_handler))
157 .route("/images/:folder/:file", get(image_handler))
158 .route("/runs/:run_id/:artifact", get(artifact_handler))
159 .route("/api/metrics/latest", get(api_metrics_latest_handler))
160 .route("/api/metrics/:run_id", get(api_metrics_run_handler))
161 .route("/api/project-history", get(project_history_handler))
162 .route("/embed/summary", get(embed_handler))
163 .route_layer(middleware::from_fn_with_state(
164 state.clone(),
165 require_api_key,
166 ));
167
168 let app = protected
169 .route("/healthz", get(healthz))
170 .route("/badge/:metric", get(badge_handler))
171 .route("/static/chart.js", get(chart_js_handler))
172 .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
173 .layer(middleware::from_fn_with_state(
174 state.clone(),
175 add_security_headers,
176 ))
177 .layer(CorsLayer::new())
178 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
179 .with_state(state.clone());
180
181 let listener = tokio::net::TcpListener::bind(&bind_address)
182 .await
183 .with_context(|| format!("failed to bind local web UI on {bind_address}"))?;
184
185 let addr: SocketAddr = bind_address
186 .parse()
187 .unwrap_or_else(|_| listener.local_addr().expect("listener has a local address"));
188
189 if tls_enabled {
190 let cert_path = tls_cert.expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
191 let key_path = tls_key.expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
192 let tls_config = build_tls_config(&cert_path, &key_path)
193 .context("failed to load TLS certificate/key")?;
194 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
195
196 let url = format!("https://{addr}/");
197 println!("OxideSLOC server running at {url} (TLS)");
198 println!("Use Ctrl+C to stop.");
199
200 return serve_tls(listener, app, acceptor, server_mode).await;
201 }
202
203 let scheme = "http";
204 let url = format!("{scheme}://{addr}/");
205 if server_mode {
206 println!("OxideSLOC server running at {url}");
207 println!("Use Ctrl+C to stop.");
208 } else {
209 println!("OxideSLOC local web UI running at {url}");
210 println!("Press Ctrl+C to stop the server.");
211 let open_url = url.clone();
212 tokio::task::spawn_blocking(move || {
213 #[cfg(target_os = "windows")]
214 let _ = std::process::Command::new("cmd")
215 .args(["/c", "start", "", &open_url])
216 .stdout(Stdio::null())
217 .stderr(Stdio::null())
218 .spawn();
219 #[cfg(target_os = "macos")]
220 let _ = std::process::Command::new("open")
221 .arg(&open_url)
222 .stdout(Stdio::null())
223 .stderr(Stdio::null())
224 .spawn();
225 #[cfg(target_os = "linux")]
226 let _ = std::process::Command::new("xdg-open")
227 .arg(&open_url)
228 .stdout(Stdio::null())
229 .stderr(Stdio::null())
230 .spawn();
231 });
232 }
233
234 axum::serve(
235 listener,
236 app.into_make_service_with_connect_info::<SocketAddr>(),
237 )
238 .with_graceful_shutdown(async move {
239 if tokio::signal::ctrl_c().await.is_ok() {
240 println!();
241 if server_mode {
242 println!("Shutting down OxideSLOC server...");
243 } else {
244 println!("Shutting down OxideSLOC local web UI...");
245 }
246 println!("Server stopped cleanly.");
247 }
248 })
249 .await
250 .context("web server terminated unexpectedly")
251}
252
253fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
255 use rustls_pemfile::{certs, private_key};
256 use std::io::BufReader;
257
258 let cert_bytes =
259 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
260 let key_bytes =
261 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
262
263 let cert_chain: Vec<_> = certs(&mut BufReader::new(cert_bytes.as_slice()))
264 .collect::<std::result::Result<_, _>>()
265 .context("failed to parse TLS certificates")?;
266
267 let key = private_key(&mut BufReader::new(key_bytes.as_slice()))
268 .context("failed to parse TLS private key")?
269 .ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
270
271 rustls::ServerConfig::builder()
272 .with_no_client_auth()
273 .with_single_cert(cert_chain, key)
274 .context("failed to build TLS server config")
275}
276
277async fn serve_tls(
279 listener: tokio::net::TcpListener,
280 app: Router,
281 acceptor: tokio_rustls::TlsAcceptor,
282 server_mode: bool,
283) -> Result<()> {
284 use hyper_util::rt::{TokioExecutor, TokioIo};
285 use hyper_util::server::conn::auto::Builder as ConnBuilder;
286 use hyper_util::service::TowerToHyperService;
287 use tower::{Service, ServiceExt};
288
289 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
290
291 loop {
292 tokio::select! {
293 biased;
294 _ = tokio::signal::ctrl_c() => {
295 println!();
296 if server_mode {
297 println!("Shutting down OxideSLOC server...");
298 } else {
299 println!("Shutting down OxideSLOC local web UI...");
300 }
301 println!("Server stopped cleanly.");
302 return Ok(());
303 }
304 result = listener.accept() => {
305 let (tcp, peer_addr) = result.context("TLS accept failed")?;
306 let acceptor = acceptor.clone();
307 let mut factory = make_svc.clone();
308
309 tokio::spawn(async move {
310 let tls = match acceptor.accept(tcp).await {
311 Ok(s) => s,
312 Err(e) => {
313 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
314 return;
315 }
316 };
317 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
318 Ok(f) => match Service::call(f, peer_addr).await {
319 Ok(s) => s,
320 Err(_) => return,
321 },
322 Err(_) => return,
323 };
324 let io = TokioIo::new(tls);
325 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
326 .serve_connection(io, TowerToHyperService::new(svc))
327 .await
328 {
329 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
330 }
331 });
332 }
333 }
334 }
335}
336
337async fn require_api_key(
338 State(state): State<AppState>,
339 req: Request<Body>,
340 next: Next,
341) -> Response {
342 if let Some(ref expected) = state.api_key {
343 let provided = req
344 .headers()
345 .get(header::AUTHORIZATION)
346 .and_then(|v| v.to_str().ok())
347 .and_then(|v| v.strip_prefix("Bearer "))
348 .or_else(|| req.headers().get("X-API-Key").and_then(|v| v.to_str().ok()));
349 if provided.map(|k| ct_eq(k, expected)).unwrap_or(false) {
350 return next.run(req).await;
351 }
352 return (
353 StatusCode::UNAUTHORIZED,
354 [(header::WWW_AUTHENTICATE, "Bearer realm=\"oxide-sloc\"")],
355 "401 Unauthorized\n",
356 )
357 .into_response();
358 }
359 next.run(req).await
360}
361
362fn ct_eq(a: &str, b: &str) -> bool {
363 if a.len() != b.len() {
364 return false;
365 }
366 a.bytes()
367 .zip(b.bytes())
368 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
369 == 0
370}
371
372async fn add_security_headers(
373 State(state): State<AppState>,
374 req: Request<Body>,
375 next: Next,
376) -> Response {
377 let mut resp = next.run(req).await;
378 let h = resp.headers_mut();
379 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
380 h.insert(
381 "X-Content-Type-Options",
382 HeaderValue::from_static("nosniff"),
383 );
384 h.insert(
385 "Referrer-Policy",
386 HeaderValue::from_static("strict-origin-when-cross-origin"),
387 );
388 h.insert(
389 "Content-Security-Policy",
390 HeaderValue::from_static(
391 "default-src 'self'; \
392 style-src 'self' 'unsafe-inline'; \
393 img-src 'self' data: blob:; \
394 script-src 'self' 'unsafe-inline'; \
395 font-src 'self' data:; \
396 object-src 'none'; \
397 frame-ancestors 'none'",
398 ),
399 );
400 h.insert(
401 "X-Permitted-Cross-Domain-Policies",
402 HeaderValue::from_static("none"),
403 );
404 h.insert(
405 "Permissions-Policy",
406 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
407 );
408 h.insert(
409 "Cross-Origin-Opener-Policy",
410 HeaderValue::from_static("same-origin"),
411 );
412 h.insert(
413 "Cross-Origin-Resource-Policy",
414 HeaderValue::from_static("same-origin"),
415 );
416 if state.tls_enabled {
417 h.insert(
418 "Strict-Transport-Security",
419 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
420 );
421 }
422 resp
423}
424
425async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
426 let ip = req
427 .extensions()
428 .get::<axum::extract::ConnectInfo<SocketAddr>>()
429 .map(|c| c.0.ip())
430 .or_else(|| {
431 req.headers()
432 .get("X-Forwarded-For")
433 .and_then(|v| v.to_str().ok())
434 .and_then(|s| s.split(',').next())
435 .and_then(|s| s.trim().parse::<IpAddr>().ok())
436 })
437 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
438
439 if !state.rate_limiter.is_allowed(ip) {
440 return (
441 StatusCode::TOO_MANY_REQUESTS,
442 [(header::RETRY_AFTER, "60")],
443 "429 Too Many Requests\n",
444 )
445 .into_response();
446 }
447 next.run(req).await
448}
449
450async fn splash() -> impl IntoResponse {
451 let template = SplashTemplate {};
452 Html(
453 template
454 .render()
455 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
456 )
457}
458
459async fn index(Query(query): Query<IndexQuery>) -> impl IntoResponse {
460 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
461 let policy = query
462 .mixed_line_policy
463 .unwrap_or_else(|| "code_only".to_string());
464 let behavior = query
465 .binary_file_behavior
466 .unwrap_or_else(|| "skip".to_string());
467 let cfg = ScanConfig {
468 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
469 path: query.path.unwrap_or_default(),
470 include_globs: query.include_globs.unwrap_or_default(),
471 exclude_globs: query.exclude_globs.unwrap_or_default(),
472 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
473 mixed_line_policy: policy,
474 python_docstrings_as_comments: query
475 .python_docstrings_as_comments
476 .as_deref()
477 .map(|v| v != "off")
478 .unwrap_or(true),
479 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
480 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
481 vendor_directory_detection: query.vendor_directory_detection.as_deref()
482 != Some("disabled"),
483 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
484 binary_file_behavior: behavior,
485 output_dir: query.output_dir.unwrap_or_default(),
486 report_title: query.report_title.unwrap_or_default(),
487 generate_html: query
488 .generate_html
489 .as_deref()
490 .map(|v| v != "off")
491 .unwrap_or(true),
492 generate_pdf: query.generate_pdf.as_deref() == Some("on"),
493 };
494 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
495 } else {
496 "{}".to_string()
497 };
498
499 let template = IndexTemplate {
500 version: env!("CARGO_PKG_VERSION"),
501 prefill_json,
502 };
503
504 Html(
505 template
506 .render()
507 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
508 )
509}
510
511async fn scan_setup_handler(State(state): State<AppState>) -> impl IntoResponse {
512 let recent_scans_json = {
513 let reg = state.registry.lock().await;
514 let arr: Vec<serde_json::Value> = reg
515 .entries
516 .iter()
517 .rev()
518 .take(6)
519 .map(|e| {
520 let run_dir = e
521 .html_path
522 .as_ref()
523 .or(e.json_path.as_ref())
524 .and_then(|p| p.parent().map(PathBuf::from));
525 let config_val: Option<serde_json::Value> = run_dir
526 .map(|d| d.join("scan-config.json"))
527 .filter(|p| p.exists())
528 .and_then(|p| fs::read_to_string(&p).ok())
529 .and_then(|s| serde_json::from_str(&s).ok());
530 serde_json::json!({
531 "project_label": e.project_label,
532 "timestamp": fmt_pst(e.timestamp_utc),
533 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
534 "config": config_val,
535 })
536 })
537 .collect();
538 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
539 };
540
541 let template = ScanSetupTemplate { recent_scans_json };
542 Html(
543 template
544 .render()
545 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
546 )
547}
548
549async fn healthz() -> &'static str {
550 "ok"
551}
552
553async fn chart_js_handler() -> impl IntoResponse {
554 (
555 [(
556 header::CONTENT_TYPE,
557 "application/javascript; charset=utf-8",
558 )],
559 CHART_JS,
560 )
561}
562
563#[derive(Debug, Deserialize)]
564struct AnalyzeForm {
565 path: String,
566 mixed_line_policy: Option<MixedLinePolicy>,
567 python_docstrings_as_comments: Option<String>,
568 generated_file_detection: Option<String>,
569 minified_file_detection: Option<String>,
570 vendor_directory_detection: Option<String>,
571 include_lockfiles: Option<String>,
572 binary_file_behavior: Option<BinaryFileBehavior>,
573 output_dir: Option<String>,
574 report_title: Option<String>,
575 generate_html: Option<String>,
576 generate_pdf: Option<String>,
577 include_globs: Option<String>,
578 exclude_globs: Option<String>,
579 submodule_breakdown: Option<String>,
580}
581
582#[derive(Debug, Serialize, Deserialize, Clone)]
583struct ScanConfig {
584 oxide_sloc_version: String,
585 path: String,
586 include_globs: String,
587 exclude_globs: String,
588 submodule_breakdown: bool,
589 mixed_line_policy: String,
590 python_docstrings_as_comments: bool,
591 generated_file_detection: bool,
592 minified_file_detection: bool,
593 vendor_directory_detection: bool,
594 include_lockfiles: bool,
595 binary_file_behavior: String,
596 output_dir: String,
597 report_title: String,
598 generate_html: bool,
599 generate_pdf: bool,
600}
601
602#[derive(Debug, Deserialize, Default)]
603struct IndexQuery {
604 path: Option<String>,
605 include_globs: Option<String>,
606 exclude_globs: Option<String>,
607 submodule_breakdown: Option<String>,
608 mixed_line_policy: Option<String>,
609 python_docstrings_as_comments: Option<String>,
610 generated_file_detection: Option<String>,
611 minified_file_detection: Option<String>,
612 vendor_directory_detection: Option<String>,
613 include_lockfiles: Option<String>,
614 binary_file_behavior: Option<String>,
615 output_dir: Option<String>,
616 report_title: Option<String>,
617 generate_html: Option<String>,
618 generate_pdf: Option<String>,
619 prefilled: Option<String>,
620}
621
622#[derive(Debug, Deserialize)]
623struct PreviewQuery {
624 path: Option<String>,
625 include_globs: Option<String>,
626 exclude_globs: Option<String>,
627}
628
629#[derive(Debug, Deserialize)]
630struct PickDirectoryQuery {
631 kind: Option<String>,
632 current: Option<String>,
633}
634
635#[derive(Debug, Deserialize, Default)]
636struct ArtifactQuery {
637 download: Option<String>,
638}
639
640#[derive(Debug, Serialize)]
641struct PickDirectoryResponse {
642 selected_path: Option<String>,
643 cancelled: bool,
644}
645
646async fn pick_directory_handler(
647 State(state): State<AppState>,
648 Query(query): Query<PickDirectoryQuery>,
649) -> Response {
650 if state.server_mode {
651 return StatusCode::NOT_FOUND.into_response();
652 }
653
654 let title = match query.kind.as_deref() {
655 Some("output") => "Select output directory",
656 _ => "Select project directory",
657 };
658
659 let mut dialog = rfd::FileDialog::new().set_title(title);
660 if let Some(current) = query.current.as_deref() {
661 let resolved = resolve_input_path(current);
662 let seed = if resolved.is_dir() {
663 Some(resolved)
664 } else {
665 resolved.parent().map(Path::to_path_buf)
666 };
667 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
668 dialog = dialog.set_directory(seed_dir);
669 }
670 }
671
672 let picked = dialog.pick_folder();
673
674 Json(PickDirectoryResponse {
675 selected_path: picked.as_ref().map(|p| display_path(p)),
676 cancelled: picked.is_none(),
677 })
678 .into_response()
679}
680
681async fn pick_file_handler(State(state): State<AppState>) -> Response {
682 if state.server_mode {
683 return StatusCode::NOT_FOUND.into_response();
684 }
685 let picked = rfd::FileDialog::new()
686 .set_title("Select HTML report")
687 .add_filter("HTML report", &["html"])
688 .pick_file();
689 Json(PickDirectoryResponse {
690 selected_path: picked.as_ref().map(|p| display_path(p)),
691 cancelled: picked.is_none(),
692 })
693 .into_response()
694}
695
696#[derive(Deserialize)]
697struct LocateReportForm {
698 file_path: String,
699}
700
701async fn locate_report_handler(
702 State(state): State<AppState>,
703 Form(form): Form<LocateReportForm>,
704) -> impl IntoResponse {
705 let file_ext = Path::new(&form.file_path)
706 .extension()
707 .and_then(|e| e.to_str())
708 .unwrap_or("")
709 .to_ascii_lowercase();
710 if file_ext != "html" {
711 let html = ErrorTemplate {
712 message: "Only .html report files can be located via this form.".to_string(),
713 last_report_url: Some("/view-reports".to_string()),
714 last_report_label: Some("View Reports".to_string()),
715 }
716 .render()
717 .unwrap_or_else(|_| "<pre>Invalid file type.</pre>".to_string());
718 return Html(html).into_response();
719 }
720 let html_path = match fs::canonicalize(PathBuf::from(&form.file_path)) {
721 Ok(p) => strip_unc_prefix(p),
722 Err(_) => {
723 let html = ErrorTemplate {
724 message: "Report file not found or path is invalid.".to_string(),
725 last_report_url: Some("/view-reports".to_string()),
726 last_report_label: Some("View Reports".to_string()),
727 }
728 .render()
729 .unwrap_or_else(|_| "<pre>Invalid path.</pre>".to_string());
730 return Html(html).into_response();
731 }
732 };
733 if state.server_mode {
735 let output_root = resolve_output_root(None).unwrap_or_else(|_| PathBuf::from("out/web"));
736 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
737 if !html_path.starts_with(&canonical_root) {
738 let html = ErrorTemplate {
739 message: "Report file must be within the configured output directory.".to_string(),
740 last_report_url: Some("/view-reports".to_string()),
741 last_report_label: Some("View Reports".to_string()),
742 }
743 .render()
744 .unwrap_or_else(|_| "<pre>Invalid path.</pre>".to_string());
745 return Html(html).into_response();
746 }
747 }
748 let parent = match html_path.parent() {
749 Some(p) => p.to_path_buf(),
750 None => {
751 let html = ErrorTemplate {
752 message: "Report file has no parent directory.".to_string(),
753 last_report_url: Some("/view-reports".to_string()),
754 last_report_label: Some("View Reports".to_string()),
755 }
756 .render()
757 .unwrap_or_else(|_| "<pre>Invalid path.</pre>".to_string());
758 return Html(html).into_response();
759 }
760 };
761 let json_candidate = parent.join("result.json");
762 let mut reg = state.registry.lock().await;
763 let entry_idx = reg.entries.iter().position(|e| {
765 let json_match = e
766 .json_path
767 .as_ref()
768 .and_then(|p| p.parent())
769 .map(|p| p == parent)
770 .unwrap_or(false);
771 let html_match = e
772 .html_path
773 .as_ref()
774 .and_then(|p| p.parent())
775 .map(|p| p == parent)
776 .unwrap_or(false);
777 json_match || html_match
778 });
779 if let Some(idx) = entry_idx {
780 reg.entries[idx].html_path = Some(html_path);
781 let _ = reg.save(&state.registry_path);
782 return axum::response::Redirect::to("/view-reports?linked=1").into_response();
783 }
784 if json_candidate.exists() {
786 match read_json(&json_candidate) {
787 Ok(run) => {
788 let project_label = run
789 .input_roots
790 .first()
791 .map(|r| sanitize_project_label(r))
792 .unwrap_or_else(|| "Unknown Project".to_string());
793 let entry = RegistryEntry {
794 run_id: run.tool.run_id.clone(),
795 timestamp_utc: run.tool.timestamp_utc,
796 project_label,
797 input_roots: run.input_roots.clone(),
798 json_path: Some(json_candidate),
799 html_path: Some(html_path),
800 pdf_path: None,
801 summary: ScanSummarySnapshot {
802 files_analyzed: run.summary_totals.files_analyzed,
803 files_skipped: run.summary_totals.files_skipped,
804 total_physical_lines: run.summary_totals.total_physical_lines,
805 code_lines: run.summary_totals.code_lines,
806 comment_lines: run.summary_totals.comment_lines,
807 blank_lines: run.summary_totals.blank_lines,
808 functions: run.summary_totals.functions,
809 classes: run.summary_totals.classes,
810 variables: run.summary_totals.variables,
811 imports: run.summary_totals.imports,
812 },
813 git_branch: None,
814 git_commit: None,
815 git_author: None,
816 git_tags: None,
817 };
818 reg.add_entry(entry);
819 let _ = reg.save(&state.registry_path);
820 return axum::response::Redirect::to("/view-reports?linked=1").into_response();
821 }
822 Err(e) => {
823 let file_hint = if state.server_mode {
824 String::new()
825 } else {
826 format!("\n\nFile: {}\n\nError: {e}", json_candidate.display())
827 };
828 let html = ErrorTemplate {
829 message: format!(
830 "Could not link this report.\n\nA 'result.json' was found but could not \
831 be parsed — it may have been saved by an older version of OxideSLOC. \
832 Re-running the analysis will create a fresh, compatible record.{file_hint}"
833 ),
834 last_report_url: Some("/view-reports".to_string()),
835 last_report_label: Some("View Reports".to_string()),
836 }
837 .render()
838 .unwrap_or_else(|_| "<pre>Link failed.</pre>".to_string());
839 return Html(html).into_response();
840 }
841 }
842 }
843 let file_hint = if state.server_mode {
844 String::new()
845 } else {
846 format!("\n\nFile: {}", html_path.display())
847 };
848 let html = ErrorTemplate {
849 message: format!(
850 "Could not link this report.\n\nNo matching scan record was found, and no \
851 'result.json' was found in the same folder.{file_hint}"
852 ),
853 last_report_url: Some("/view-reports".to_string()),
854 last_report_label: Some("View Reports".to_string()),
855 }
856 .render()
857 .unwrap_or_else(|_| "<pre>Link failed.</pre>".to_string());
858 Html(html).into_response()
859}
860
861#[derive(Debug, Deserialize)]
862struct OpenPathQuery {
863 path: Option<String>,
864}
865
866async fn open_path_handler(
867 State(state): State<AppState>,
868 Query(query): Query<OpenPathQuery>,
869) -> impl IntoResponse {
870 if state.server_mode {
871 return StatusCode::NOT_FOUND.into_response();
872 }
873 let raw = match query.path.as_deref() {
874 Some(p) if !p.is_empty() => p,
875 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
876 };
877
878 let canonical = match fs::canonicalize(raw) {
879 Ok(p) => p,
880 Err(_) => return (StatusCode::BAD_REQUEST, "path not found").into_response(),
881 };
882
883 let target = if canonical.is_file() {
885 match canonical.parent() {
886 Some(p) => p.to_path_buf(),
887 None => return (StatusCode::BAD_REQUEST, "path has no parent").into_response(),
888 }
889 } else if canonical.is_dir() {
890 canonical
891 } else {
892 return (StatusCode::BAD_REQUEST, "path is not a file or directory").into_response();
894 };
895
896 #[cfg(target_os = "windows")]
897 let _ = std::process::Command::new("explorer.exe")
898 .arg(&target)
899 .stdout(Stdio::null())
900 .stderr(Stdio::null())
901 .spawn();
902 #[cfg(target_os = "macos")]
903 let _ = std::process::Command::new("open")
904 .arg(&target)
905 .stdout(Stdio::null())
906 .stderr(Stdio::null())
907 .spawn();
908 #[cfg(target_os = "linux")]
909 let _ = std::process::Command::new("xdg-open")
910 .arg(&target)
911 .stdout(Stdio::null())
912 .stderr(Stdio::null())
913 .spawn();
914
915 (StatusCode::OK, "ok").into_response()
916}
917
918async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
919 let safe_folder = match folder.as_str() {
920 "icons" | "logo" => folder,
921 _ => return StatusCode::NOT_FOUND.into_response(),
922 };
923
924 let safe_name = Path::new(&file)
925 .file_name()
926 .and_then(|name| name.to_str())
927 .unwrap_or("");
928
929 if safe_name.is_empty() {
930 return StatusCode::NOT_FOUND.into_response();
931 }
932
933 let ext = Path::new(safe_name)
934 .extension()
935 .and_then(|e| e.to_str())
936 .unwrap_or("")
937 .to_ascii_lowercase();
938
939 let content_type = match ext.as_str() {
940 "png" => "image/png",
941 "jpg" | "jpeg" => "image/jpeg",
942 "webp" => "image/webp",
943 "svg" => "image/svg+xml",
944 _ => return StatusCode::NOT_FOUND.into_response(),
945 };
946
947 let path = workspace_root()
948 .join("images")
949 .join(safe_folder)
950 .join(safe_name);
951 match fs::read(path) {
952 Ok(bytes) => ([(header::CONTENT_TYPE, content_type)], bytes).into_response(),
953 Err(_) => StatusCode::NOT_FOUND.into_response(),
954 }
955}
956
957async fn preview_handler(
958 State(state): State<AppState>,
959 Query(query): Query<PreviewQuery>,
960) -> impl IntoResponse {
961 let raw_path = query.path.unwrap_or_else(|| "samples/basic".to_string());
962 let resolved = resolve_input_path(&raw_path);
963
964 if state.server_mode {
965 let config = &state.base_config;
966 if config.discovery.allowed_scan_roots.is_empty() {
967 return Html(
968 r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
969 );
970 }
971 let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
972 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
973 fs::canonicalize(root)
974 .ok()
975 .map(|r| canonical.starts_with(&r))
976 .unwrap_or(false)
977 });
978 if !allowed {
979 return Html(
980 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
981 );
982 }
983 }
984
985 let include_patterns = split_patterns(query.include_globs.as_deref());
986 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
987
988 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
989 Ok(html) => Html(html),
990 Err(err) => Html(format!(
991 r#"<div class="preview-error">Preview failed: {}</div>"#,
992 escape_html(&err.to_string())
993 )),
994 }
995}
996
997async fn analyze_handler(
998 State(state): State<AppState>,
999 Form(form): Form<AnalyzeForm>,
1000) -> impl IntoResponse {
1001 let _permit = match Arc::clone(&state.analyze_semaphore).try_acquire_owned() {
1002 Ok(p) => p,
1003 Err(_) => {
1004 let template = ErrorTemplate {
1005 message:
1006 "Server is busy — too many concurrent analyses. Please try again in a moment."
1007 .to_string(),
1008 last_report_url: None,
1009 last_report_label: None,
1010 };
1011 return (
1012 StatusCode::SERVICE_UNAVAILABLE,
1013 Html(
1014 template
1015 .render()
1016 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
1017 ),
1018 )
1019 .into_response();
1020 }
1021 };
1022
1023 let mut config = state.base_config.clone();
1024 let resolved_path = resolve_input_path(&form.path);
1025
1026 if state.server_mode {
1027 if config.discovery.allowed_scan_roots.is_empty() {
1028 let template = ErrorTemplate {
1029 message: "Scan path rejected: no allowed_scan_roots configured on this server. \
1030 Set allowed_scan_roots in the server config to permit scanning."
1031 .to_string(),
1032 last_report_url: None,
1033 last_report_label: None,
1034 };
1035 return (
1036 StatusCode::FORBIDDEN,
1037 Html(
1038 template
1039 .render()
1040 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
1041 ),
1042 )
1043 .into_response();
1044 }
1045 let canonical = fs::canonicalize(&resolved_path).unwrap_or_else(|_| resolved_path.clone());
1046 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
1047 fs::canonicalize(root)
1048 .ok()
1049 .map(|r| canonical.starts_with(&r))
1050 .unwrap_or(false)
1051 });
1052 if !allowed {
1053 let template = ErrorTemplate {
1054 message: "The requested path is not within an allowed scan directory.".to_string(),
1055 last_report_url: None,
1056 last_report_label: None,
1057 };
1058 return (
1059 StatusCode::FORBIDDEN,
1060 Html(
1061 template
1062 .render()
1063 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
1064 ),
1065 )
1066 .into_response();
1067 }
1068 }
1069 config.discovery.root_paths = vec![resolved_path];
1070
1071 if let Some(policy) = form.mixed_line_policy {
1072 config.analysis.mixed_line_policy = policy;
1073 }
1074
1075 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
1076 config.analysis.generated_file_detection =
1077 form.generated_file_detection.as_deref() != Some("disabled");
1078 config.analysis.minified_file_detection =
1079 form.minified_file_detection.as_deref() != Some("disabled");
1080 config.analysis.vendor_directory_detection =
1081 form.vendor_directory_detection.as_deref() != Some("disabled");
1082 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
1083
1084 if let Some(binary_behavior) = form.binary_file_behavior {
1085 config.analysis.binary_file_behavior = binary_behavior;
1086 }
1087
1088 if let Some(report_title) = form.report_title.as_deref() {
1089 let trimmed = report_title.trim();
1090 if !trimmed.is_empty() {
1091 config.reporting.report_title = trimmed.to_string();
1092 }
1093 }
1094
1095 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
1096 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
1097 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
1098
1099 let project_root_for_exclude = resolve_input_path(&form.path);
1102 let raw_out = form.output_dir.as_deref().unwrap_or("").trim();
1103 let resolved_out_early = if raw_out.is_empty() {
1104 project_root_for_exclude.join("sloc")
1105 } else if Path::new(raw_out).is_absolute() {
1106 PathBuf::from(raw_out)
1107 } else {
1108 workspace_root().join(raw_out)
1109 };
1110 if let Ok(rel) = resolved_out_early.strip_prefix(&project_root_for_exclude) {
1112 if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
1113 let dir = first.to_string();
1114 if !config.discovery.excluded_directories.contains(&dir) {
1115 config.discovery.excluded_directories.push(dir);
1116 }
1117 }
1118 }
1119 if !config
1121 .discovery
1122 .excluded_directories
1123 .iter()
1124 .any(|d| d == "sloc")
1125 {
1126 config
1127 .discovery
1128 .excluded_directories
1129 .push("sloc".to_string());
1130 }
1131
1132 let analysis_result =
1133 tokio::task::spawn_blocking(move || -> Result<(sloc_core::AnalysisRun, String)> {
1134 let run = analyze(&config, "serve")?;
1135 let html = render_html(&run)?;
1136 Ok((run, html))
1137 })
1138 .await
1139 .map_err(|err| anyhow::anyhow!(err.to_string()))
1140 .and_then(|result| result);
1141
1142 let (run, report_html) = match analysis_result {
1143 Ok(value) => value,
1144 Err(err) => {
1145 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
1146 let template = ErrorTemplate {
1147 message: "Analysis failed. Check that the path exists and is readable.".to_string(),
1148 last_report_url: None,
1149 last_report_label: None,
1150 };
1151 return Html(
1152 template
1153 .render()
1154 .unwrap_or_else(|_| "<pre>Analysis failed.</pre>".to_string()),
1155 )
1156 .into_response();
1157 }
1158 };
1159
1160 let run_id = run.tool.run_id.to_string();
1161
1162 let prev_entry: Option<RegistryEntry> = {
1165 let reg = state.registry.lock().await;
1166 reg.entries_for_roots(&run.input_roots)
1167 .into_iter()
1168 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
1169 .cloned()
1170 };
1171
1172 let git_branch = run.git_branch.clone();
1174 let git_commit = run.git_commit_short.clone();
1175 let git_author = run.git_commit_author.clone();
1176 let git_tags = run.git_tags.clone();
1177
1178 let scan_delta = prev_entry.as_ref().and_then(|prev| {
1180 prev.json_path
1181 .as_ref()
1182 .and_then(|p| read_json(p).ok())
1183 .map(|prev_run| compute_delta(&prev_run, &run))
1184 });
1185 let prev_scan_count: usize = {
1186 let reg = state.registry.lock().await;
1187 reg.entries_for_roots(&run.input_roots)
1188 .iter()
1189 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
1190 .count()
1191 };
1192
1193 let output_root = match resolve_output_root(form.output_dir.as_deref()) {
1194 Ok(path) => path,
1195 Err(err) => {
1196 eprintln!("[oxide-sloc][analyze] output directory error: {err:#}");
1197 let template = ErrorTemplate {
1198 message: "Could not create output directory. Check the output path setting."
1199 .to_string(),
1200 last_report_url: None,
1201 last_report_label: None,
1202 };
1203 return Html(
1204 template
1205 .render()
1206 .unwrap_or_else(|_| "<pre>Output directory error.</pre>".to_string()),
1207 )
1208 .into_response();
1209 }
1210 };
1211
1212 let project_label = sanitize_project_label(&form.path);
1213 let run_dir = output_root.join(format!("{}_{}", project_label, run_id));
1214
1215 let artifact_result = persist_run_artifacts(
1216 &run,
1217 &report_html,
1218 &run_dir,
1219 true, form.generate_html.is_some(),
1221 form.generate_pdf.is_some(),
1222 &run.effective_configuration.reporting.report_title,
1223 );
1224
1225 let (artifacts, pending_pdf) = match artifact_result {
1226 Ok(value) => value,
1227 Err(err) => {
1228 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
1229 let template = ErrorTemplate {
1230 message: "Failed to save report artifacts. Check available disk space.".to_string(),
1231 last_report_url: None,
1232 last_report_label: None,
1233 };
1234 return Html(
1235 template
1236 .render()
1237 .unwrap_or_else(|_| "<pre>Artifact write failed.</pre>".to_string()),
1238 )
1239 .into_response();
1240 }
1241 };
1242
1243 {
1244 let mut map = state.artifacts.lock().await;
1245 map.insert(run_id.clone(), artifacts.clone());
1246 }
1247
1248 {
1250 let entry = RegistryEntry {
1251 run_id: run_id.clone(),
1252 timestamp_utc: run.tool.timestamp_utc,
1253 project_label: project_label.clone(),
1254 input_roots: run.input_roots.clone(),
1255 json_path: artifacts.json_path.clone(),
1256 html_path: artifacts.html_path.clone(),
1257 pdf_path: artifacts.pdf_path.clone(),
1258 summary: ScanSummarySnapshot {
1259 files_analyzed: run.summary_totals.files_analyzed,
1260 files_skipped: run.summary_totals.files_skipped,
1261 total_physical_lines: run.summary_totals.total_physical_lines,
1262 code_lines: run.summary_totals.code_lines,
1263 comment_lines: run.summary_totals.comment_lines,
1264 blank_lines: run.summary_totals.blank_lines,
1265 functions: run.summary_totals.functions,
1266 classes: run.summary_totals.classes,
1267 variables: run.summary_totals.variables,
1268 imports: run.summary_totals.imports,
1269 },
1270 git_branch: git_branch.clone(),
1271 git_commit: git_commit.clone(),
1272 git_author: git_author.clone(),
1273 git_tags: git_tags.clone(),
1274 };
1275 let mut reg = state.registry.lock().await;
1276 reg.add_entry(entry);
1277 let _ = reg.save(&state.registry_path);
1278 }
1279
1280 {
1282 let policy_str = serde_json::to_value(form.mixed_line_policy)
1283 .ok()
1284 .filter(|v| !v.is_null())
1285 .and_then(|v| v.as_str().map(String::from))
1286 .unwrap_or_else(|| "code_only".to_string());
1287 let behavior_str = serde_json::to_value(form.binary_file_behavior)
1288 .ok()
1289 .filter(|v| !v.is_null())
1290 .and_then(|v| v.as_str().map(String::from))
1291 .unwrap_or_else(|| "skip".to_string());
1292 let scan_cfg = ScanConfig {
1293 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1294 path: form.path.clone(),
1295 include_globs: form.include_globs.clone().unwrap_or_default(),
1296 exclude_globs: form.exclude_globs.clone().unwrap_or_default(),
1297 submodule_breakdown: form.submodule_breakdown.as_deref() == Some("enabled"),
1298 mixed_line_policy: policy_str,
1299 python_docstrings_as_comments: form.python_docstrings_as_comments.is_some(),
1300 generated_file_detection: form.generated_file_detection.as_deref() != Some("disabled"),
1301 minified_file_detection: form.minified_file_detection.as_deref() != Some("disabled"),
1302 vendor_directory_detection: form.vendor_directory_detection.as_deref()
1303 != Some("disabled"),
1304 include_lockfiles: form.include_lockfiles.as_deref() == Some("enabled"),
1305 binary_file_behavior: behavior_str,
1306 output_dir: form.output_dir.clone().unwrap_or_default(),
1307 report_title: run.effective_configuration.reporting.report_title.clone(),
1308 generate_html: form.generate_html.is_some(),
1309 generate_pdf: form.generate_pdf.is_some(),
1310 };
1311 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
1312 let _ = fs::write(run_dir.join("scan-config.json"), json);
1313 }
1314 }
1315
1316 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
1317 tokio::spawn(async move {
1318 let result = tokio::task::spawn_blocking(move || {
1319 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
1320 if cleanup_src {
1321 let _ = fs::remove_file(&pdf_src);
1322 }
1323 r
1324 })
1325 .await;
1326 match result {
1327 Ok(Err(err)) => eprintln!("[oxide-sloc][pdf] background PDF failed: {err}"),
1328 Err(err) => eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}"),
1329 Ok(Ok(())) => {}
1330 }
1331 });
1332 }
1333
1334 let language_rows = run
1335 .totals_by_language
1336 .iter()
1337 .map(|row| LanguageSummaryRow {
1338 language: row.language.display_name().to_string(),
1339 files: row.files,
1340 physical: row.total_physical_lines,
1341 code: row.code_lines,
1342 comments: row.comment_lines,
1343 blank: row.blank_lines,
1344 mixed: row.mixed_lines_separate,
1345 functions: row.functions,
1346 classes: row.classes,
1347 variables: row.variables,
1348 imports: row.imports,
1349 })
1350 .collect::<Vec<_>>();
1351
1352 let files_analyzed = run.per_file_records.len() as u64;
1353 let files_skipped = run.skipped_file_records.len() as u64;
1354 let physical_lines = language_rows.iter().map(|row| row.physical).sum::<u64>();
1355 let code_lines = language_rows.iter().map(|row| row.code).sum::<u64>();
1356 let comment_lines = language_rows.iter().map(|row| row.comments).sum::<u64>();
1357 let blank_lines = language_rows.iter().map(|row| row.blank).sum::<u64>();
1358 let mixed_lines = language_rows.iter().map(|row| row.mixed).sum::<u64>();
1359 let functions = language_rows.iter().map(|row| row.functions).sum::<u64>();
1360 let classes = language_rows.iter().map(|row| row.classes).sum::<u64>();
1361 let variables = language_rows.iter().map(|row| row.variables).sum::<u64>();
1362 let imports = language_rows.iter().map(|row| row.imports).sum::<u64>();
1363
1364 let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
1366 let prev_fa = prev_sum.map(|s| s.files_analyzed);
1367 let prev_fs = prev_sum.map(|s| s.files_skipped);
1368 let prev_pl = prev_sum.map(|s| s.total_physical_lines);
1369 let prev_cl = prev_sum.map(|s| s.code_lines);
1370 let prev_cml = prev_sum.map(|s| s.comment_lines);
1371 let prev_bl = prev_sum.map(|s| s.blank_lines);
1372 let fmt_prev = |opt: Option<u64>| opt.map(|v| v.to_string()).unwrap_or_else(|| "—".into());
1373 let prev_fa_str = fmt_prev(prev_fa);
1374 let prev_fs_str = fmt_prev(prev_fs);
1375 let prev_pl_str = fmt_prev(prev_pl);
1376 let prev_cl_str = fmt_prev(prev_cl);
1377 let prev_cml_str = fmt_prev(prev_cml);
1378 let prev_bl_str = fmt_prev(prev_bl);
1379 let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
1380 let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
1381 let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
1382 let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
1383 let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
1384 let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
1385 let delta_fa_class = delta_fa_class.to_string();
1386 let delta_fs_class = delta_fs_class.to_string();
1387 let delta_pl_class = delta_pl_class.to_string();
1388 let delta_cl_class = delta_cl_class.to_string();
1389 let delta_cml_class = delta_cml_class.to_string();
1390 let delta_bl_class = delta_bl_class.to_string();
1391
1392 let delta_lines_added: Option<i64> = scan_delta.as_ref().map(|d| {
1394 d.file_deltas
1395 .iter()
1396 .map(|f| match f.status {
1397 sloc_core::FileChangeStatus::Added => f.current_code,
1398 sloc_core::FileChangeStatus::Modified => f.code_delta.max(0),
1399 _ => 0,
1400 })
1401 .sum()
1402 });
1403 let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(|d| {
1404 d.file_deltas
1405 .iter()
1406 .map(|f| match f.status {
1407 sloc_core::FileChangeStatus::Removed => f.baseline_code,
1408 sloc_core::FileChangeStatus::Modified => (-f.code_delta).max(0),
1409 _ => 0,
1410 })
1411 .sum()
1412 });
1413 let (delta_lines_net_str, delta_lines_net_class) =
1414 match (delta_lines_added, delta_lines_removed) {
1415 (Some(a), Some(r)) => {
1416 let net = a - r;
1417 (fmt_delta(net), delta_class(net).to_string())
1418 }
1419 _ => ("—".to_string(), "na".to_string()),
1420 };
1421
1422 let template = ResultTemplate {
1423 report_title: run.effective_configuration.reporting.report_title.clone(),
1424 project_path: form.path,
1425 output_dir: display_path(&artifacts.output_dir),
1426 run_id: run_id.clone(),
1427 files_analyzed,
1428 files_skipped,
1429 physical_lines,
1430 code_lines,
1431 comment_lines,
1432 blank_lines,
1433 mixed_lines,
1434 functions,
1435 classes,
1436 variables,
1437 imports,
1438 html_url: artifacts
1439 .html_path
1440 .as_ref()
1441 .map(|_| format!("/runs/{run_id}/html")),
1442 pdf_url: artifacts
1443 .pdf_path
1444 .as_ref()
1445 .map(|_| format!("/runs/{run_id}/pdf")),
1446 json_url: artifacts
1447 .json_path
1448 .as_ref()
1449 .map(|_| format!("/runs/{run_id}/json")),
1450 html_download_url: artifacts
1451 .html_path
1452 .as_ref()
1453 .map(|_| format!("/runs/{run_id}/html?download=1")),
1454 pdf_download_url: artifacts
1455 .pdf_path
1456 .as_ref()
1457 .map(|_| format!("/runs/{run_id}/pdf?download=1")),
1458 json_download_url: artifacts
1459 .json_path
1460 .as_ref()
1461 .map(|_| format!("/runs/{run_id}/json?download=1")),
1462 html_path: artifacts.html_path.as_ref().map(|path| display_path(path)),
1463 pdf_path: artifacts.pdf_path.as_ref().map(|path| display_path(path)),
1464 json_path: artifacts.json_path.as_ref().map(|path| display_path(path)),
1465 language_rows,
1466 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
1467 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_pst(e.timestamp_utc)),
1468 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
1469 prev_fa_str,
1470 prev_fs_str,
1471 prev_pl_str,
1472 prev_cl_str,
1473 prev_cml_str,
1474 prev_bl_str,
1475 delta_fa_str,
1476 delta_fa_class,
1477 delta_fs_str,
1478 delta_fs_class,
1479 delta_pl_str,
1480 delta_pl_class,
1481 delta_cl_str,
1482 delta_cl_class,
1483 delta_cml_str,
1484 delta_cml_class,
1485 delta_bl_str,
1486 delta_bl_class,
1487 delta_lines_added,
1489 delta_lines_removed,
1490 delta_lines_net_str,
1491 delta_lines_net_class,
1492 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
1493 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
1494 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
1495 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
1496 delta_unmodified_lines: scan_delta.as_ref().map(|d| {
1497 d.file_deltas
1498 .iter()
1499 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
1500 .map(|f| f.current_code as u64)
1501 .sum()
1502 }),
1503 git_branch: git_branch.clone(),
1504 git_commit: git_commit.clone(),
1505 git_author: git_author.clone(),
1506 current_scan_number: prev_scan_count + 1,
1507 prev_scan_count,
1508 submodule_rows: run
1509 .submodule_summaries
1510 .iter()
1511 .map(|s| {
1512 let safe = sanitize_project_label(&s.name);
1513 let artifact_key = format!("sub_{}", safe);
1514 let html_url = if run.effective_configuration.discovery.submodule_breakdown
1515 && form.generate_html.is_some()
1516 {
1517 let parent_path = run.input_roots.first().map(|s| s.as_str()).unwrap_or("");
1518 let sub_run = build_sub_run(&run, s, parent_path);
1519 if let Ok(sub_html) = render_sub_report_html(&sub_run) {
1520 let path = run_dir.join(format!("{}.html", artifact_key));
1521 if fs::write(&path, sub_html.as_bytes()).is_ok() {
1522 Some(format!("/runs/{}/{}", run_id, artifact_key))
1523 } else {
1524 None
1525 }
1526 } else {
1527 None
1528 }
1529 } else {
1530 None
1531 };
1532 SubmoduleRow {
1533 name: s.name.clone(),
1534 relative_path: s.relative_path.clone(),
1535 files_analyzed: s.files_analyzed,
1536 code_lines: s.code_lines,
1537 comment_lines: s.comment_lines,
1538 blank_lines: s.blank_lines,
1539 total_physical_lines: s.total_physical_lines,
1540 html_url,
1541 }
1542 })
1543 .collect(),
1544 scan_config_url: format!("/runs/{run_id}/scan-config"),
1545 };
1546
1547 Html(
1548 template
1549 .render()
1550 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1551 )
1552 .into_response()
1553}
1554
1555fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
1556 let slug: String = report_title
1557 .chars()
1558 .map(|c| {
1559 if c.is_alphanumeric() || c == '-' {
1560 c.to_ascii_lowercase()
1561 } else {
1562 '_'
1563 }
1564 })
1565 .collect::<String>()
1566 .split('_')
1567 .filter(|s| !s.is_empty())
1568 .collect::<Vec<_>>()
1569 .join("_");
1570
1571 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
1572
1573 if slug.is_empty() {
1574 format!("report_{short_id}.pdf")
1575 } else {
1576 format!("{slug}_{short_id}.pdf")
1577 }
1578}
1579
1580async fn artifact_handler(
1581 State(state): State<AppState>,
1582 AxumPath((run_id, artifact)): AxumPath<(String, String)>,
1583 Query(query): Query<ArtifactQuery>,
1584) -> Response {
1585 let artifact_set = {
1586 let registry = state.artifacts.lock().await;
1587 registry.get(&run_id).cloned()
1588 };
1589
1590 let artifact_set = match artifact_set {
1593 Some(a) => a,
1594 None => {
1595 let reg = state.registry.lock().await;
1596 match reg.find_by_run_id(&run_id) {
1597 Some(entry) => {
1598 let output_dir = entry
1599 .html_path
1600 .as_ref()
1601 .or(entry.json_path.as_ref())
1602 .or(entry.pdf_path.as_ref())
1603 .and_then(|p| p.parent().map(PathBuf::from))
1604 .unwrap_or_default();
1605 let pdf_path = entry.pdf_path.clone().or_else(|| {
1608 let candidate = output_dir.join("report.pdf");
1609 if candidate.exists() {
1610 Some(candidate)
1611 } else {
1612 None
1613 }
1614 });
1615 RunArtifacts {
1616 output_dir,
1617 html_path: entry.html_path.clone(),
1618 pdf_path,
1619 json_path: entry.json_path.clone(),
1620 report_title: entry.project_label.clone(),
1621 }
1622 }
1623 None => {
1624 let error_html = ErrorTemplate {
1625 message: format!(
1626 "Report not found. Run ID {} is not in the scan history. \
1627 The report may have been deleted, or this is an old run from \
1628 before the scan registry was introduced.",
1629 &run_id[..run_id.len().min(8)]
1630 ),
1631 last_report_url: Some("/view-reports".to_string()),
1632 last_report_label: Some("View Reports".to_string()),
1633 }
1634 .render()
1635 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
1636 return (StatusCode::NOT_FOUND, Html(error_html)).into_response();
1637 }
1638 }
1639 }
1640 };
1641
1642 let wants_download = matches!(
1643 query.download.as_deref(),
1644 Some("1") | Some("true") | Some("yes")
1645 );
1646
1647 match artifact.as_str() {
1648 "html" => {
1649 let Some(path) = artifact_set.html_path else {
1650 return StatusCode::NOT_FOUND.into_response();
1651 };
1652
1653 match fs::read_to_string(&path) {
1654 Ok(content) => {
1655 if wants_download {
1656 (
1657 [
1658 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
1659 (
1660 header::CONTENT_DISPOSITION,
1661 "attachment; filename=report.html",
1662 ),
1663 ],
1664 content,
1665 )
1666 .into_response()
1667 } else {
1668 Html(content).into_response()
1669 }
1670 }
1671 Err(err) => {
1672 let filename = path
1673 .file_name()
1674 .map(|n| n.to_string_lossy().into_owned())
1675 .unwrap_or_else(|| "report.html".to_string());
1676 let msg = format!(
1677 "HTML report '{filename}' could not be read.\n\n\
1678 Error: {err}\n\n\
1679 If you moved or renamed the output folder, the stored path is now stale. \
1680 Use 'Open HTML folder' from the results page to browse the output directory."
1681 );
1682 let html = ErrorTemplate {
1683 message: msg,
1684 last_report_url: Some("/view-reports".to_string()),
1685 last_report_label: Some("View Reports".to_string()),
1686 }
1687 .render()
1688 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
1689 (StatusCode::NOT_FOUND, Html(html)).into_response()
1690 }
1691 }
1692 }
1693 "pdf" => {
1694 let Some(path) = artifact_set.pdf_path else {
1695 let msg = "PDF report was not generated for this run, or was not recorded in \
1696 the scan registry. Re-run the analysis with PDF output enabled."
1697 .to_string();
1698 let html = ErrorTemplate {
1699 message: msg,
1700 last_report_url: Some("/view-reports".to_string()),
1701 last_report_label: Some("View Reports".to_string()),
1702 }
1703 .render()
1704 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
1705 return (StatusCode::NOT_FOUND, Html(html)).into_response();
1706 };
1707
1708 match fs::read(&path) {
1709 Ok(bytes) => {
1710 let filename = build_pdf_filename(&artifact_set.report_title, &run_id);
1711 let disposition = if wants_download {
1712 format!("attachment; filename=\"{}\"", filename)
1713 } else {
1714 format!("inline; filename=\"{}\"", filename)
1715 };
1716 (
1717 [
1718 (header::CONTENT_TYPE, "application/pdf".to_string()),
1719 (header::CONTENT_DISPOSITION, disposition),
1720 ],
1721 bytes,
1722 )
1723 .into_response()
1724 }
1725 Err(err) => {
1726 let filename = path
1727 .file_name()
1728 .map(|n| n.to_string_lossy().into_owned())
1729 .unwrap_or_else(|| "report.pdf".to_string());
1730 let msg = format!(
1731 "PDF report '{filename}' could not be read.\n\n\
1732 Error: {err}\n\n\
1733 If you moved or renamed the output folder, the stored path is now stale. \
1734 Use 'Open PDF folder' from the results page to browse the output directory."
1735 );
1736 let html = ErrorTemplate {
1737 message: msg,
1738 last_report_url: Some("/view-reports".to_string()),
1739 last_report_label: Some("View Reports".to_string()),
1740 }
1741 .render()
1742 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
1743 (StatusCode::NOT_FOUND, Html(html)).into_response()
1744 }
1745 }
1746 }
1747 "json" => {
1748 let Some(path) = artifact_set.json_path else {
1749 let msg = "JSON result was not generated for this run, or was not recorded in \
1750 the scan registry. Re-run the analysis with JSON output enabled."
1751 .to_string();
1752 let html = ErrorTemplate {
1753 message: msg,
1754 last_report_url: Some("/view-reports".to_string()),
1755 last_report_label: Some("View Reports".to_string()),
1756 }
1757 .render()
1758 .unwrap_or_else(|_| "<pre>JSON not available.</pre>".to_string());
1759 return (StatusCode::NOT_FOUND, Html(html)).into_response();
1760 };
1761
1762 match fs::read(&path) {
1763 Ok(bytes) => {
1764 if wants_download {
1765 (
1766 [
1767 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
1768 (
1769 header::CONTENT_DISPOSITION,
1770 "attachment; filename=result.json",
1771 ),
1772 ],
1773 bytes,
1774 )
1775 .into_response()
1776 } else {
1777 (
1778 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
1779 bytes,
1780 )
1781 .into_response()
1782 }
1783 }
1784 Err(err) => {
1785 let filename = path
1786 .file_name()
1787 .map(|n| n.to_string_lossy().into_owned())
1788 .unwrap_or_else(|| "result.json".to_string());
1789 let msg = format!(
1790 "JSON result '{filename}' could not be read.\n\n\
1791 Error: {err}\n\n\
1792 If you moved or renamed the output folder, the stored path is now stale. \
1793 Use 'Open JSON folder' from the results page to browse the output directory."
1794 );
1795 let html = ErrorTemplate {
1796 message: msg,
1797 last_report_url: Some("/view-reports".to_string()),
1798 last_report_label: Some("View Reports".to_string()),
1799 }
1800 .render()
1801 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
1802 (StatusCode::NOT_FOUND, Html(html)).into_response()
1803 }
1804 }
1805 }
1806 "scan-config" => {
1807 let path = artifact_set.output_dir.join("scan-config.json");
1808 match fs::read(&path) {
1809 Ok(bytes) => (
1810 [
1811 (
1812 header::CONTENT_TYPE,
1813 "application/json; charset=utf-8".to_string(),
1814 ),
1815 (
1816 header::CONTENT_DISPOSITION,
1817 "attachment; filename=\"scan-config.json\"".to_string(),
1818 ),
1819 ],
1820 bytes,
1821 )
1822 .into_response(),
1823 Err(_) => StatusCode::NOT_FOUND.into_response(),
1824 }
1825 }
1826 _ if artifact.starts_with("sub_") => {
1827 if artifact.len() > 128
1828 || !artifact
1829 .chars()
1830 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1831 {
1832 return StatusCode::BAD_REQUEST.into_response();
1833 }
1834 let filename = format!("{}.html", artifact);
1835 let path = artifact_set.output_dir.join(&filename);
1836 match fs::read_to_string(&path) {
1837 Ok(content) => Html(content).into_response(),
1838 Err(_) => {
1839 let html = ErrorTemplate {
1840 message: format!(
1841 "Sub-report '{}' was not found in the run directory.\n\
1842 Re-run the analysis with 'Detect and separate git submodules' \
1843 and HTML output enabled.",
1844 artifact
1845 ),
1846 last_report_url: Some("/view-reports".to_string()),
1847 last_report_label: Some("View Reports".to_string()),
1848 }
1849 .render()
1850 .unwrap_or_else(|_| "<pre>Sub-report not found.</pre>".to_string());
1851 (StatusCode::NOT_FOUND, Html(html)).into_response()
1852 }
1853 }
1854 }
1855 _ => StatusCode::NOT_FOUND.into_response(),
1856 }
1857}
1858
1859struct HistoryEntryRow {
1862 run_id: String,
1863 run_id_short: String,
1864 timestamp: String,
1865 project_label: String,
1866 project_path: String,
1867 files_analyzed: u64,
1868 files_skipped: u64,
1869 code_lines: u64,
1870 comment_lines: u64,
1871 blank_lines: u64,
1872 functions: u64,
1873 classes: u64,
1874 variables: u64,
1875 imports: u64,
1876 git_branch: String,
1877 git_commit: String,
1878 has_html: bool,
1879 has_json: bool,
1880 has_pdf: bool,
1881}
1882
1883fn fmt_pst(dt: chrono::DateTime<chrono::Utc>) -> String {
1884 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset is always valid"))
1885 .format("%Y-%m-%d %H:%M PST")
1886 .to_string()
1887}
1888
1889fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
1890 reg.entries
1891 .iter()
1892 .map(|e| HistoryEntryRow {
1893 run_id: e.run_id.clone(),
1894 run_id_short: e
1895 .run_id
1896 .split('-')
1897 .next_back()
1898 .unwrap_or(&e.run_id)
1899 .chars()
1900 .take(7)
1901 .collect(),
1902 timestamp: fmt_pst(e.timestamp_utc),
1903 project_label: e.project_label.clone(),
1904 project_path: e
1905 .input_roots
1906 .first()
1907 .map(|s| sanitize_path_str(s))
1908 .unwrap_or_default(),
1909 files_analyzed: e.summary.files_analyzed,
1910 files_skipped: e.summary.files_skipped,
1911 code_lines: e.summary.code_lines,
1912 comment_lines: e.summary.comment_lines,
1913 blank_lines: e.summary.blank_lines,
1914 functions: e.summary.functions,
1915 classes: e.summary.classes,
1916 variables: e.summary.variables,
1917 imports: e.summary.imports,
1918 git_branch: e.git_branch.clone().unwrap_or_default(),
1919 git_commit: e.git_commit.clone().unwrap_or_default(),
1920 has_html: e.html_path.as_ref().map(|p| p.exists()).unwrap_or(false),
1921 has_json: e.json_path.as_ref().map(|p| p.exists()).unwrap_or(false),
1922 has_pdf: e.pdf_path.as_ref().map(|p| p.exists()).unwrap_or(false),
1923 })
1924 .collect()
1925}
1926
1927#[derive(Deserialize, Default)]
1928struct HistoryQuery {
1929 linked: Option<String>,
1930}
1931
1932async fn history_handler(
1933 State(state): State<AppState>,
1934 Query(query): Query<HistoryQuery>,
1935) -> impl IntoResponse {
1936 let mut entries = {
1937 let reg = state.registry.lock().await;
1938 make_history_rows(®)
1939 };
1940 entries.retain(|e| e.has_html);
1941 let total_scans = entries.len();
1942 let linked = query.linked.as_deref() == Some("1");
1943 let template = HistoryTemplate {
1944 entries,
1945 total_scans,
1946 linked,
1947 };
1948 Html(
1949 template
1950 .render()
1951 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1952 )
1953 .into_response()
1954}
1955
1956async fn compare_select_handler(State(state): State<AppState>) -> impl IntoResponse {
1957 let mut entries = {
1958 let reg = state.registry.lock().await;
1959 make_history_rows(®)
1960 };
1961 entries.retain(|e| e.has_json);
1962 let total_scans = entries.len();
1963 let template = CompareSelectTemplate {
1964 entries,
1965 total_scans,
1966 };
1967 Html(
1968 template
1969 .render()
1970 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1971 )
1972 .into_response()
1973}
1974
1975#[derive(Deserialize, Default)]
1978struct CompareQuery {
1979 a: Option<String>,
1980 b: Option<String>,
1981}
1982
1983struct CompareFileDeltaRow {
1984 relative_path: String,
1985 language: String,
1986 status: String,
1987 baseline_code: i64,
1988 current_code: i64,
1989 code_delta_str: String,
1990 code_delta_class: String,
1991 comment_delta_str: String,
1992 comment_delta_class: String,
1993 total_delta_str: String,
1994 total_delta_class: String,
1995}
1996
1997fn fmt_delta(n: i64) -> String {
1998 if n > 0 {
1999 format!("+{n}")
2000 } else {
2001 format!("{n}")
2002 }
2003}
2004
2005fn delta_class(n: i64) -> &'static str {
2006 if n > 0 {
2007 "pos"
2008 } else if n < 0 {
2009 "neg"
2010 } else {
2011 "zero"
2012 }
2013}
2014
2015fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
2017 match prev {
2018 Some(p) => {
2019 let d = curr as i64 - p as i64;
2020 (fmt_delta(d), delta_class(d))
2021 }
2022 None => ("—".to_string(), "na"),
2023 }
2024}
2025
2026async fn compare_handler(
2027 State(state): State<AppState>,
2028 Query(query): Query<CompareQuery>,
2029) -> impl IntoResponse {
2030 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
2033 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
2034 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
2035 };
2036
2037 let (maybe_a, maybe_b) = {
2038 let reg = state.registry.lock().await;
2039 (
2040 reg.find_by_run_id(&run_id_a).cloned(),
2041 reg.find_by_run_id(&run_id_b).cloned(),
2042 )
2043 };
2044
2045 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
2046 let html = ErrorTemplate {
2047 message: "One or both run IDs were not found in scan history. \
2048 The runs may have been deleted or the registry may have been reset."
2049 .to_string(),
2050 last_report_url: Some("/compare-scans".to_string()),
2051 last_report_label: Some("Compare Scans".to_string()),
2052 }
2053 .render()
2054 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
2055 return Html(html).into_response();
2056 };
2057
2058 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
2060 (entry_a, entry_b)
2061 } else {
2062 (entry_b, entry_a)
2063 };
2064
2065 if baseline_entry.run_id != run_id_a {
2069 let canonical = format!(
2070 "/compare?a={}&b={}",
2071 baseline_entry.run_id, current_entry.run_id
2072 );
2073 return axum::response::Redirect::to(&canonical).into_response();
2074 }
2075
2076 let (Some(base_json), Some(curr_json)) = (
2077 baseline_entry.json_path.as_ref(),
2078 current_entry.json_path.as_ref(),
2079 ) else {
2080 let html = ErrorTemplate {
2081 message: "Full comparison requires JSON scan data, which was not saved for one or \
2082 both of these runs. JSON is now always saved for new scans — re-run the \
2083 affected projects to enable comparisons."
2084 .to_string(),
2085 last_report_url: Some("/compare-scans".to_string()),
2086 last_report_label: Some("Compare Scans".to_string()),
2087 }
2088 .render()
2089 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
2090 return Html(html).into_response();
2091 };
2092
2093 let baseline_run = match read_json(base_json) {
2094 Ok(r) => r,
2095 Err(e) => {
2096 let message = if state.server_mode {
2097 "Could not load baseline scan data. The scan output folder may have been moved, \
2098 renamed, or deleted. Re-running the analysis will create fresh comparison data."
2099 .to_string()
2100 } else {
2101 format!(
2102 "Could not load baseline scan data.\n\nPath: {}\n\nError: {e}\n\n\
2103 The scan output folder may have been moved, renamed, or deleted. \
2104 Re-running the analysis for this project will create fresh comparison data.",
2105 base_json.display()
2106 )
2107 };
2108 let html = ErrorTemplate {
2109 message,
2110 last_report_url: Some("/compare-scans".to_string()),
2111 last_report_label: Some("Compare Scans".to_string()),
2112 }
2113 .render()
2114 .unwrap_or_else(|_| "<pre>Baseline load failed.</pre>".to_string());
2115 return (StatusCode::NOT_FOUND, Html(html)).into_response();
2116 }
2117 };
2118 let current_run = match read_json(curr_json) {
2119 Ok(r) => r,
2120 Err(e) => {
2121 let message = if state.server_mode {
2122 "Could not load current scan data. The scan output folder may have been moved, \
2123 renamed, or deleted. Re-running the analysis will create fresh comparison data."
2124 .to_string()
2125 } else {
2126 format!(
2127 "Could not load current scan data.\n\nPath: {}\n\nError: {e}\n\n\
2128 The scan output folder may have been moved, renamed, or deleted. \
2129 Re-running the analysis for this project will create fresh comparison data.",
2130 curr_json.display()
2131 )
2132 };
2133 let html = ErrorTemplate {
2134 message,
2135 last_report_url: Some("/compare-scans".to_string()),
2136 last_report_label: Some("Compare Scans".to_string()),
2137 }
2138 .render()
2139 .unwrap_or_else(|_| "<pre>Current load failed.</pre>".to_string());
2140 return (StatusCode::NOT_FOUND, Html(html)).into_response();
2141 }
2142 };
2143
2144 let comparison = compute_delta(&baseline_run, ¤t_run);
2145
2146 let file_rows: Vec<CompareFileDeltaRow> = comparison
2147 .file_deltas
2148 .iter()
2149 .map(|d| CompareFileDeltaRow {
2150 relative_path: d.relative_path.clone(),
2151 language: d.language.clone().unwrap_or_else(|| "—".into()),
2152 status: match d.status {
2153 FileChangeStatus::Added => "added".into(),
2154 FileChangeStatus::Removed => "removed".into(),
2155 FileChangeStatus::Modified => "modified".into(),
2156 FileChangeStatus::Unchanged => "unchanged".into(),
2157 },
2158 baseline_code: d.baseline_code,
2159 current_code: d.current_code,
2160 code_delta_str: fmt_delta(d.code_delta),
2161 code_delta_class: delta_class(d.code_delta).into(),
2162 comment_delta_str: fmt_delta(d.comment_delta),
2163 comment_delta_class: delta_class(d.comment_delta).into(),
2164 total_delta_str: fmt_delta(d.total_delta),
2165 total_delta_class: delta_class(d.total_delta).into(),
2166 })
2167 .collect();
2168
2169 let project_path = baseline_entry
2170 .input_roots
2171 .first()
2172 .map(|s| sanitize_path_str(s))
2173 .unwrap_or_default();
2174 let s = &comparison.summary;
2175 let template = CompareTemplate {
2176 baseline_run_id: baseline_entry.run_id.clone(),
2177 current_run_id: current_entry.run_id.clone(),
2178 baseline_run_id_short: baseline_entry
2179 .run_id
2180 .split('-')
2181 .next_back()
2182 .unwrap_or(&baseline_entry.run_id)
2183 .chars()
2184 .take(7)
2185 .collect(),
2186 current_run_id_short: current_entry
2187 .run_id
2188 .split('-')
2189 .next_back()
2190 .unwrap_or(¤t_entry.run_id)
2191 .chars()
2192 .take(7)
2193 .collect(),
2194 baseline_timestamp: fmt_pst(baseline_entry.timestamp_utc),
2195 current_timestamp: fmt_pst(current_entry.timestamp_utc),
2196 project_path,
2197 baseline_code: s.baseline_code,
2198 current_code: s.current_code,
2199 code_lines_delta_str: fmt_delta(s.code_lines_delta),
2200 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
2201 baseline_files: s.baseline_files,
2202 current_files: s.current_files,
2203 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
2204 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
2205 baseline_comments: s.baseline_comments,
2206 current_comments: s.current_comments,
2207 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
2208 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
2209 files_added: comparison.files_added,
2210 files_removed: comparison.files_removed,
2211 files_modified: comparison.files_modified,
2212 files_unchanged: comparison.files_unchanged,
2213 file_rows,
2214 baseline_git_author: baseline_entry.git_author.clone(),
2215 current_git_author: current_entry.git_author.clone(),
2216 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
2217 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
2218 baseline_git_tags: baseline_entry.git_tags.clone(),
2219 current_git_tags: current_entry.git_tags.clone(),
2220 };
2221
2222 Html(
2223 template
2224 .render()
2225 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2226 )
2227 .into_response()
2228}
2229
2230fn format_number(n: u64) -> String {
2238 let s = n.to_string();
2239 let mut out = String::with_capacity(s.len() + s.len() / 3);
2240 let len = s.len();
2241 for (i, c) in s.chars().enumerate() {
2242 if i > 0 && (len - i).is_multiple_of(3) {
2243 out.push(',');
2244 }
2245 out.push(c);
2246 }
2247 out
2248}
2249
2250fn badge_char_width(c: char) -> f64 {
2251 match c {
2252 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
2253 'm' | 'w' => 9.0,
2254 ' ' => 4.0,
2255 _ => 6.5,
2256 }
2257}
2258
2259fn badge_text_px(text: &str) -> u32 {
2260 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
2261}
2262
2263fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
2264 let lw = badge_text_px(label) + 20;
2265 let rw = badge_text_px(value) + 20;
2266 let total = lw + rw;
2267 let lx = lw / 2;
2268 let rx = lw + rw / 2;
2269 let le = escape_html(label);
2270 let ve = escape_html(value);
2271 let ce = escape_html(color);
2272 format!(
2273 r###"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
2274 <rect width="{total}" height="20" fill="#555"/>
2275 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
2276 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
2277 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
2278 <text x="{lx}" y="13">{le}</text>
2279 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
2280 <text x="{rx}" y="13">{ve}</text>
2281 </g>
2282</svg>"###
2283 )
2284}
2285
2286#[derive(Deserialize)]
2287struct BadgeQuery {
2288 label: Option<String>,
2289 color: Option<String>,
2290}
2291
2292async fn badge_handler(
2293 State(state): State<AppState>,
2294 AxumPath(metric): AxumPath<String>,
2295 Query(query): Query<BadgeQuery>,
2296) -> Response {
2297 let entry = {
2298 let reg = state.registry.lock().await;
2299 reg.entries.first().cloned()
2300 };
2301
2302 let Some(entry) = entry else {
2303 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
2304 return (
2305 [
2306 (header::CONTENT_TYPE, "image/svg+xml"),
2307 (header::CACHE_CONTROL, "no-cache, max-age=0"),
2308 ],
2309 svg,
2310 )
2311 .into_response();
2312 };
2313
2314 let (default_label, value, default_color) = match metric.as_str() {
2315 "code-lines" => (
2316 "code lines",
2317 format_number(entry.summary.code_lines),
2318 "#4a78ee",
2319 ),
2320 "files" => (
2321 "files analyzed",
2322 format_number(entry.summary.files_analyzed),
2323 "#4a9862",
2324 ),
2325 "comment-lines" => (
2326 "comment lines",
2327 format_number(entry.summary.comment_lines),
2328 "#b35428",
2329 ),
2330 "blank-lines" => (
2331 "blank lines",
2332 format_number(entry.summary.blank_lines),
2333 "#7a5db0",
2334 ),
2335 _ => return StatusCode::NOT_FOUND.into_response(),
2336 };
2337
2338 let label = query.label.as_deref().unwrap_or(default_label);
2339 let color = query.color.as_deref().unwrap_or(default_color);
2340 let svg = render_badge_svg(label, &value, color);
2341
2342 (
2343 [
2344 (header::CONTENT_TYPE, "image/svg+xml"),
2345 (header::CACHE_CONTROL, "no-cache, max-age=0"),
2346 ],
2347 svg,
2348 )
2349 .into_response()
2350}
2351
2352#[derive(Serialize)]
2360struct ApiMetricsResponse {
2361 run_id: String,
2362 timestamp: String,
2363 project: String,
2364 summary: ApiSummaryPayload,
2365 languages: Vec<ApiLanguageRow>,
2366}
2367
2368#[derive(Serialize)]
2369struct ApiSummaryPayload {
2370 files_analyzed: u64,
2371 files_skipped: u64,
2372 code_lines: u64,
2373 comment_lines: u64,
2374 blank_lines: u64,
2375 total_physical_lines: u64,
2376 functions: u64,
2377 classes: u64,
2378 variables: u64,
2379 imports: u64,
2380}
2381
2382#[derive(Serialize)]
2383struct ApiLanguageRow {
2384 name: String,
2385 files: u64,
2386 code_lines: u64,
2387 comment_lines: u64,
2388 blank_lines: u64,
2389 functions: u64,
2390 classes: u64,
2391 variables: u64,
2392 imports: u64,
2393}
2394
2395async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
2396 let entry = {
2397 let reg = state.registry.lock().await;
2398 reg.entries.first().cloned()
2399 };
2400 match entry {
2401 Some(e) => build_metrics_response(&e),
2402 None => (
2403 StatusCode::NOT_FOUND,
2404 Json(serde_json::json!({"error": "no scans recorded yet"})),
2405 )
2406 .into_response(),
2407 }
2408}
2409
2410async fn api_metrics_run_handler(
2411 State(state): State<AppState>,
2412 AxumPath(run_id): AxumPath<String>,
2413) -> Response {
2414 let entry = {
2415 let reg = state.registry.lock().await;
2416 reg.find_by_run_id(&run_id).cloned()
2417 };
2418 match entry {
2419 Some(e) => build_metrics_response(&e),
2420 None => (
2421 StatusCode::NOT_FOUND,
2422 Json(serde_json::json!({"error": "run not found"})),
2423 )
2424 .into_response(),
2425 }
2426}
2427
2428fn build_metrics_response(entry: &RegistryEntry) -> Response {
2429 let languages: Vec<ApiLanguageRow> = entry
2430 .json_path
2431 .as_ref()
2432 .and_then(|p| read_json(p).ok())
2433 .map(|run| {
2434 run.totals_by_language
2435 .iter()
2436 .map(|l| ApiLanguageRow {
2437 name: l.language.display_name().to_string(),
2438 files: l.files,
2439 code_lines: l.code_lines,
2440 comment_lines: l.comment_lines,
2441 blank_lines: l.blank_lines,
2442 functions: l.functions,
2443 classes: l.classes,
2444 variables: l.variables,
2445 imports: l.imports,
2446 })
2447 .collect()
2448 })
2449 .unwrap_or_default();
2450
2451 let s = &entry.summary;
2452 Json(ApiMetricsResponse {
2453 run_id: entry.run_id.clone(),
2454 timestamp: entry.timestamp_utc.to_rfc3339(),
2455 project: entry.project_label.clone(),
2456 summary: ApiSummaryPayload {
2457 files_analyzed: s.files_analyzed,
2458 files_skipped: s.files_skipped,
2459 code_lines: s.code_lines,
2460 comment_lines: s.comment_lines,
2461 blank_lines: s.blank_lines,
2462 total_physical_lines: s.total_physical_lines,
2463 functions: s.functions,
2464 classes: s.classes,
2465 variables: s.variables,
2466 imports: s.imports,
2467 },
2468 languages,
2469 })
2470 .into_response()
2471}
2472
2473#[derive(Deserialize)]
2480struct ProjectHistoryQuery {
2481 path: Option<String>,
2482}
2483
2484#[derive(Serialize)]
2485struct ProjectHistoryResponse {
2486 scan_count: usize,
2487 last_scan_id: Option<String>,
2488 last_scan_timestamp: Option<String>,
2489 last_scan_code_lines: Option<u64>,
2490 last_git_branch: Option<String>,
2491 last_git_commit: Option<String>,
2492}
2493
2494async fn project_history_handler(
2495 State(state): State<AppState>,
2496 Query(query): Query<ProjectHistoryQuery>,
2497) -> Response {
2498 let path = query.path.unwrap_or_default();
2499 let resolved = resolve_input_path(&path);
2500 let root_str = resolved.to_string_lossy().replace('\\', "/");
2501
2502 let reg = state.registry.lock().await;
2503 let entries: Vec<_> = reg
2504 .entries
2505 .iter()
2506 .filter(|e| e.input_roots.iter().any(|r| r == &root_str))
2507 .collect();
2508
2509 let scan_count = entries.len();
2510 let last = entries.first();
2511
2512 Json(ProjectHistoryResponse {
2513 scan_count,
2514 last_scan_id: last.map(|e| e.run_id.clone()),
2515 last_scan_timestamp: last.map(|e| fmt_pst(e.timestamp_utc)),
2516 last_scan_code_lines: last.map(|e| e.summary.code_lines),
2517 last_git_branch: last.and_then(|e| e.git_branch.clone()),
2518 last_git_commit: last.and_then(|e| e.git_commit.clone()),
2519 })
2520 .into_response()
2521}
2522
2523#[derive(Deserialize)]
2530struct EmbedQuery {
2531 run_id: Option<String>,
2532 theme: Option<String>,
2533}
2534
2535async fn embed_handler(State(state): State<AppState>, Query(query): Query<EmbedQuery>) -> Response {
2536 let entry = {
2537 let reg = state.registry.lock().await;
2538 if let Some(id) = &query.run_id {
2539 reg.find_by_run_id(id).cloned()
2540 } else {
2541 reg.entries.first().cloned()
2542 }
2543 };
2544
2545 let Some(entry) = entry else {
2546 return Html(
2547 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
2548 .to_string(),
2549 )
2550 .into_response();
2551 };
2552
2553 let dark = query.theme.as_deref() == Some("dark");
2554 let languages: Vec<(String, u64, u64)> = entry
2555 .json_path
2556 .as_ref()
2557 .and_then(|p| read_json(p).ok())
2558 .map(|run| {
2559 run.totals_by_language
2560 .iter()
2561 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
2562 .collect()
2563 })
2564 .unwrap_or_default();
2565
2566 Html(render_embed_widget(&entry, &languages, dark)).into_response()
2567}
2568
2569fn render_embed_widget(
2570 entry: &RegistryEntry,
2571 languages: &[(String, u64, u64)],
2572 dark: bool,
2573) -> String {
2574 let s = &entry.summary;
2575 let total = s.code_lines + s.comment_lines + s.blank_lines;
2576 let code_pct = s
2577 .code_lines
2578 .checked_mul(100)
2579 .and_then(|n| n.checked_div(total))
2580 .unwrap_or(0);
2581
2582 let (bg, fg, surface, muted, border) = if dark {
2583 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
2584 } else {
2585 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
2586 };
2587
2588 let lang_rows: String = languages
2589 .iter()
2590 .map(|(name, files, code)| {
2591 format!(
2592 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
2593 escape_html(name),
2594 format_number(*files),
2595 format_number(*code),
2596 )
2597 })
2598 .collect();
2599
2600 let lang_table = if lang_rows.is_empty() {
2601 String::new()
2602 } else {
2603 format!(
2604 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
2605 )
2606 };
2607
2608 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
2609 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
2610 let project_esc = escape_html(&entry.project_label);
2611 let code_lines = format_number(s.code_lines);
2612 let comment_lines = format_number(s.comment_lines);
2613 let files = format_number(s.files_analyzed);
2614 let code_raw = s.code_lines;
2615 let comment_raw = s.comment_lines;
2616 let blank_raw = s.blank_lines;
2617
2618 format!(
2619 r##"<!doctype html>
2620<html lang="en">
2621<head>
2622 <meta charset="utf-8">
2623 <meta name="viewport" content="width=device-width,initial-scale=1">
2624 <title>OxideSLOC — {project_esc}</title>
2625 <script src="/static/chart.js"></script>
2626 <style>
2627 *{{box-sizing:border-box;margin:0;padding:0}}
2628 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
2629 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
2630 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
2631 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
2632 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
2633 .card .v{{font-size:18px;font-weight:700}}
2634 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
2635 .row{{display:flex;gap:12px;align-items:flex-start}}
2636 .pie{{width:120px;height:120px;flex-shrink:0}}
2637 .lt{{border-collapse:collapse;width:100%;flex:1}}
2638 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
2639 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
2640 .n{{text-align:right}}
2641 .footer{{margin-top:10px;color:{muted};font-size:10px}}
2642 </style>
2643</head>
2644<body>
2645 <h2>{project_esc}</h2>
2646 <div class="sub">{timestamp} · run {run_short}</div>
2647 <div class="cards">
2648 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
2649 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
2650 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
2651 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
2652 </div>
2653 <div class="row">
2654 <canvas class="pie" id="c"></canvas>
2655 {lang_table}
2656 </div>
2657 <div class="footer">oxide-sloc</div>
2658 <script>
2659 new Chart(document.getElementById('c'),{{
2660 type:'doughnut',
2661 data:{{
2662 labels:['Code','Comments','Blank'],
2663 datasets:[{{
2664 data:[{code_raw},{comment_raw},{blank_raw}],
2665 backgroundColor:['#4a78ee','#b35428','#aaa'],
2666 borderWidth:0
2667 }}]
2668 }},
2669 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
2670 }});
2671 </script>
2672</body>
2673</html>"##
2674 )
2675}
2676
2677fn persist_run_artifacts(
2678 run: &sloc_core::AnalysisRun,
2679 report_html: &str,
2680 run_dir: &Path,
2681 generate_json: bool,
2682 generate_html: bool,
2683 generate_pdf: bool,
2684 report_title: &str,
2685) -> Result<(RunArtifacts, PendingPdf)> {
2686 fs::create_dir_all(run_dir)
2687 .with_context(|| format!("failed to create output directory {}", run_dir.display()))?;
2688
2689 let mut html_path = None;
2690 let mut pdf_path = None;
2691 let mut json_path = None;
2692 let mut pending_pdf: Option<(PathBuf, PathBuf, bool)> = None;
2693
2694 if generate_html {
2695 let path = run_dir.join("report.html");
2696 fs::write(&path, report_html)
2697 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
2698 html_path = Some(path);
2699 }
2700
2701 if generate_json {
2702 let path = run_dir.join("result.json");
2703 let json = serde_json::to_string_pretty(run)
2704 .context("failed to serialize analysis run to JSON")?;
2705 fs::write(&path, json)
2706 .with_context(|| format!("failed to write JSON report to {}", path.display()))?;
2707 json_path = Some(path);
2708 }
2709
2710 if generate_pdf {
2711 let source_html_path = if let Some(existing) = html_path.as_ref() {
2712 existing.clone()
2713 } else {
2714 let temp_html = run_dir.join("_report_rendered.html");
2715 fs::write(&temp_html, report_html).with_context(|| {
2716 format!(
2717 "failed to write temporary HTML report to {}",
2718 temp_html.display()
2719 )
2720 })?;
2721 temp_html
2722 };
2723
2724 let pdf_dest = run_dir.join("report.pdf");
2725 let cleanup_src = !generate_html;
2726 pdf_path = Some(pdf_dest.clone());
2727 pending_pdf = Some((source_html_path, pdf_dest, cleanup_src));
2728 }
2729
2730 Ok((
2731 RunArtifacts {
2732 output_dir: run_dir.to_path_buf(),
2733 html_path,
2734 pdf_path,
2735 json_path,
2736 report_title: report_title.to_string(),
2737 },
2738 pending_pdf,
2739 ))
2740}
2741
2742fn resolve_output_root(raw: Option<&str>) -> Result<PathBuf> {
2743 let value = raw.unwrap_or("out/web").trim();
2744 let path = if value.is_empty() {
2745 PathBuf::from("out/web")
2746 } else {
2747 PathBuf::from(value)
2748 };
2749
2750 if path.is_absolute() {
2751 Ok(path)
2752 } else {
2753 Ok(workspace_root().join(path))
2754 }
2755}
2756
2757fn split_patterns(raw: Option<&str>) -> Vec<String> {
2758 raw.unwrap_or("")
2759 .lines()
2760 .flat_map(|line| line.split(','))
2761 .map(|part| part.trim())
2762 .filter(|part| !part.is_empty())
2763 .map(ToOwned::to_owned)
2764 .collect()
2765}
2766
2767fn build_sub_run(
2768 parent: &AnalysisRun,
2769 sub: &sloc_core::SubmoduleSummary,
2770 parent_path: &str,
2771) -> AnalysisRun {
2772 let sub_files: Vec<_> = parent
2773 .per_file_records
2774 .iter()
2775 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
2776 .cloned()
2777 .collect();
2778 let mut config = parent.effective_configuration.clone();
2779 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
2780 AnalysisRun {
2781 tool: parent.tool.clone(),
2782 environment: parent.environment.clone(),
2783 effective_configuration: config,
2784 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
2785 summary_totals: SummaryTotals {
2786 files_considered: sub.files_analyzed,
2787 files_analyzed: sub.files_analyzed,
2788 files_skipped: 0,
2789 total_physical_lines: sub.total_physical_lines,
2790 code_lines: sub.code_lines,
2791 comment_lines: sub.comment_lines,
2792 blank_lines: sub.blank_lines,
2793 mixed_lines_separate: 0,
2794 functions: 0,
2795 classes: 0,
2796 variables: 0,
2797 imports: 0,
2798 },
2799 totals_by_language: sub.language_summaries.clone(),
2800 per_file_records: sub_files,
2801 skipped_file_records: vec![],
2802 warnings: vec![],
2803 submodule_summaries: vec![],
2804 git_commit_short: parent.git_commit_short.clone(),
2805 git_commit_long: parent.git_commit_long.clone(),
2806 git_branch: parent.git_branch.clone(),
2807 git_commit_author: parent.git_commit_author.clone(),
2808 git_tags: parent.git_tags.clone(),
2809 }
2810}
2811
2812fn sanitize_project_label(raw: &str) -> String {
2813 let candidate = Path::new(raw)
2814 .file_name()
2815 .and_then(|name| name.to_str())
2816 .unwrap_or("project");
2817
2818 let mut value = String::with_capacity(candidate.len());
2819 for ch in candidate.chars() {
2820 if ch.is_ascii_alphanumeric() {
2821 value.push(ch.to_ascii_lowercase());
2822 } else {
2823 value.push('-');
2824 }
2825 }
2826
2827 let compact = value.trim_matches('-').to_string();
2828 if compact.is_empty() {
2829 "project".to_string()
2830 } else {
2831 compact
2832 }
2833}
2834
2835fn strip_unc_prefix(path: PathBuf) -> PathBuf {
2838 let s = path.to_string_lossy();
2839 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
2840 return PathBuf::from(format!(r"\\{rest}"));
2841 }
2842 if let Some(rest) = s.strip_prefix(r"\\?\") {
2843 return PathBuf::from(rest);
2844 }
2845 path
2846}
2847
2848fn display_path(path: &Path) -> String {
2849 let s = path.to_string_lossy();
2850 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
2855 return format!(r"\\{rest}");
2856 }
2857 if let Some(rest) = s.strip_prefix(r"\\?\") {
2858 return rest.to_owned();
2859 }
2860 s.into_owned()
2861}
2862
2863fn sanitize_path_str(s: &str) -> String {
2864 if let Some(rest) = s.strip_prefix("//?/UNC/") {
2868 return format!("//{rest}");
2869 }
2870 if let Some(rest) = s.strip_prefix("//?/") {
2871 return rest.to_owned();
2872 }
2873 display_path(Path::new(s))
2874}
2875
2876fn workspace_root() -> PathBuf {
2877 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
2879 let p = PathBuf::from(root);
2880 if p.is_dir() {
2881 return p;
2882 }
2883 }
2884
2885 if let Ok(exe) = std::env::current_exe() {
2891 if let Some(dir) = exe.parent() {
2892 if dir.join("images").is_dir() {
2893 return dir.to_path_buf();
2894 }
2895 }
2896 }
2897
2898 if let Ok(cwd) = std::env::current_dir() {
2901 if cwd.join("images").is_dir() {
2902 return cwd;
2903 }
2904 }
2905
2906 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
2907}
2908
2909fn resolve_input_path(raw: &str) -> PathBuf {
2910 let trimmed = raw.trim();
2911 if trimmed.is_empty() {
2912 return workspace_root().join("samples").join("basic");
2913 }
2914
2915 let candidate = PathBuf::from(trimmed);
2916 let resolved = if candidate.is_absolute() {
2917 candidate
2918 } else {
2919 let rooted = workspace_root().join(&candidate);
2920 if rooted.exists() {
2921 rooted
2922 } else {
2923 workspace_root().join(candidate)
2924 }
2925 };
2926
2927 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
2930 PathBuf::from(display_path(&canonical))
2931}
2932
2933fn build_preview_html(
2934 root: &Path,
2935 include_patterns: &[String],
2936 exclude_patterns: &[String],
2937) -> Result<String> {
2938 if !root.exists() {
2939 return Ok(format!(
2940 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
2941 escape_html(&display_path(root))
2942 ));
2943 }
2944
2945 let _selected = display_path(root);
2946 let mut stats = PreviewStats::default();
2947 let mut rows = Vec::new();
2948 let mut languages = Vec::new();
2949 let mut budget = PreviewBudget {
2950 shown: 0,
2951 max_entries: 600,
2952 max_depth: 9,
2953 };
2954 let mut next_row_id = 1usize;
2955
2956 let root_name = root
2957 .file_name()
2958 .and_then(|name| name.to_str())
2959 .map(|name| name.to_string())
2960 .unwrap_or_else(|| root.to_string_lossy().into_owned());
2961 let root_modified = root
2962 .metadata()
2963 .ok()
2964 .and_then(|meta| meta.modified().ok())
2965 .map(format_system_time)
2966 .unwrap_or_else(|| "-".to_string());
2967
2968 rows.push(PreviewRow {
2969 row_id: 0,
2970 parent_row_id: None,
2971 depth: 0,
2972 name: format!("{}/", root_name),
2973 kind: PreviewKind::Dir,
2974 is_dir: true,
2975 language: None,
2976 modified: root_modified,
2977 type_label: "Directory".to_string(),
2978 });
2979 collect_preview_rows(
2980 root,
2981 root,
2982 0,
2983 Some(0),
2984 &mut next_row_id,
2985 &mut budget,
2986 &mut stats,
2987 &mut rows,
2988 &mut languages,
2989 include_patterns,
2990 exclude_patterns,
2991 )?;
2992
2993 let mut out = String::new();
2994 out.push_str(r#"<div class="explorer-wrap">"#);
2995 out.push_str(r#"<div class="explorer-toolbar compact">"#);
2996 out.push_str(r#"<div class="explorer-title-group">"#);
2997 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
2998 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
2999 out.push_str(r#"</div></div>"#);
3000
3001 out.push_str(r#"<div class="scope-stats">"#);
3002 out.push_str(&format!(r#"<button type="button" class="scope-stat-button" data-filter="dir" data-tooltip="Total directories in the project scope. Click to filter the explorer to directories only."><span class="scope-stat-label">Directories</span><span class="scope-stat-value">{}</span></button>"#, stats.directories));
3003 out.push_str(&format!(r#"<button type="button" class="scope-stat-button" data-filter="file" data-tooltip="Total files found in the project scope. Click to show only files in the explorer."><span class="scope-stat-label">Files</span><span class="scope-stat-value">{}</span></button>"#, stats.files));
3004 out.push_str(&format!(r#"<button type="button" class="scope-stat-button supported" data-filter="supported" data-tooltip="Files with a supported language analyzer — counted in SLOC totals. Click to filter to supported files."><span class="scope-stat-label">Supported files</span><span class="scope-stat-value">{}</span></button>"#, stats.supported));
3005 out.push_str(&format!(r#"<button type="button" class="scope-stat-button skipped" data-filter="skipped" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection. Click to see skipped files."><span class="scope-stat-label">Skipped by policy</span><span class="scope-stat-value">{}</span></button>"#, stats.skipped));
3006 out.push_str(&format!(r#"<button type="button" class="scope-stat-button unsupported" data-filter="unsupported" data-tooltip="Files outside the supported language set — listed but not counted. Click to filter to unsupported files."><span class="scope-stat-label">Unsupported files</span><span class="scope-stat-value">{}</span></button>"#, stats.unsupported));
3007 out.push_str(r#"<button type="button" class="scope-stat-button reset" data-filter="reset-view" data-tooltip="Clear all filters and return to the full project view."><span class="scope-stat-label">Reset view</span><span class="scope-stat-value">All</span></button>"#);
3008 out.push_str(r#"</div>"#);
3009
3010 out.push_str(r#"<div class="scope-info-row">"#);
3011 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
3012 if languages.is_empty() {
3013 out.push_str(
3014 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
3015 );
3016 } else {
3017 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
3018 for language in &languages {
3019 if let Some(icon) = language_icon_file(language) {
3020 out.push_str(&format!(r#"<button type="button" class="language-pill has-icon detected-language-chip" data-language-filter="{}"><img src="/images/icons/{}" alt="{} icon" /><span>{}</span></button>"#, escape_html(&language.to_ascii_lowercase()), icon, escape_html(language), escape_html(language)));
3021 } else if let Some(svg) = language_inline_svg(language) {
3022 out.push_str(&format!(r#"<button type="button" class="language-pill has-icon detected-language-chip" data-language-filter="{}">{}<span>{}</span></button>"#, escape_html(&language.to_ascii_lowercase()), svg, escape_html(language)));
3023 } else {
3024 out.push_str(&format!(
3025 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
3026 escape_html(&language.to_ascii_lowercase()),
3027 escape_html(language)
3028 ));
3029 }
3030 }
3031 }
3032 out.push_str(r#"</div></div>"#);
3033 out.push_str(r#"<div class="preview-note stronger">This preview is generated before the run starts. It shows what is currently supported, what default policies skip, and which files are outside the enabled analyzer set for this build.</div>"#);
3034 out.push_str(r#"</div>"#);
3035
3036 out.push_str(r#"<div class="file-explorer-shell">"#);
3037 out.push_str(r#"<div class="file-explorer-controls"><div class="file-explorer-actions"><button type="button" class="mini-button explorer-action" data-explorer-action="expand-all">Expand all</button><button type="button" class="mini-button explorer-action" data-explorer-action="collapse-all">Collapse all</button><button type="button" class="mini-button explorer-action" data-explorer-action="clear-filters">Reset view</button></div><div class="file-explorer-search-row"><select class="explorer-filter-select" id="explorer-filter-select"><option value="all">All rows</option><option value="dir">Directories only</option><option value="file">Files only</option><option value="supported">Supported only</option><option value="skipped">Skipped by policy</option><option value="unsupported">Unsupported only</option></select><input type="text" class="explorer-search" id="explorer-search" placeholder="Filter by file or folder name" /></div></div>"#);
3038 out.push_str(r#"<div class="file-explorer-header"><button type="button" class="tree-sort-button" data-sort-key="name" data-sort-order="none"><span>Name</span><span class="tree-sort-indicator">↕</span></button><button type="button" class="tree-sort-button" data-sort-key="date" data-sort-order="none"><span>Date</span><span class="tree-sort-indicator">↕</span></button><button type="button" class="tree-sort-button" data-sort-key="type" data-sort-order="none"><span>Type</span><span class="tree-sort-indicator">↕</span></button><button type="button" class="tree-sort-button" data-sort-key="status" data-sort-order="none"><span>Status</span><span class="tree-sort-indicator">↕</span></button></div>"#);
3039 out.push_str(r#"<div class="file-explorer-tree">"#);
3040 for row in rows {
3041 let status_label = row.kind.label();
3042 let lang_attr = row.language.unwrap_or("");
3043 let toggle_html = if row.is_dir {
3044 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
3045 .to_string()
3046 } else {
3047 r#"<span class="tree-bullet">•</span>"#.to_string()
3048 };
3049 out.push_str(&format!(r#"<div class="tree-row kind-{} status-{}" data-kind="{}" data-status="{}" data-language="{}" data-row-id="{}" data-parent-id="{}" data-dir="{}" data-expanded="true" data-name-lower="{}" data-sort-name="{}" data-sort-date="{}" data-sort-type="{}" data-sort-status="{}"><div class="tree-name-cell" style="--depth:{}">{}<span class="tree-node {}">{}</span></div><div class="tree-date-cell">{}</div><div class="tree-type-cell">{}</div><div class="tree-status-cell"><span class="badge {}">{}</span></div></div>"#, if row.is_dir { "dir" } else { "file" }, row.kind.filter_key(), if row.is_dir { "dir" } else { "file" }, row.kind.filter_key(), escape_html(lang_attr), row.row_id, row.parent_row_id.map(|id| id.to_string()).unwrap_or_default(), if row.is_dir { "true" } else { "false" }, escape_html(&row.name.to_ascii_lowercase()), escape_html(&row.name.to_ascii_lowercase()), escape_html(&row.modified), escape_html(&row.type_label.to_ascii_lowercase()), escape_html(status_label), row.depth, toggle_html, if row.is_dir { "tree-node-dir" } else { row.kind.node_class() }, escape_html(&row.name), escape_html(&row.modified), escape_html(&row.type_label), row.kind.badge_class(), status_label));
3050 }
3051 if budget.shown >= budget.max_entries {
3052 out.push_str(r#"<div class="tree-row more-row" data-kind="file" data-status="more" data-row-id="999999" data-parent-id="" data-dir="false" data-expanded="true" data-name-lower="preview truncated"><div class="tree-name-cell" style="--depth:0"><span class="tree-bullet">•</span><span class="tree-node tree-node-more">... preview truncated for readability ...</span></div><div class="tree-date-cell">-</div><div class="tree-type-cell">Preview note</div><div class="tree-status-cell"></div></div>"#);
3053 }
3054 out.push_str(r#"</div></div></div>"#);
3055
3056 Ok(out)
3057}
3058
3059#[derive(Default)]
3060struct PreviewStats {
3061 directories: usize,
3062 files: usize,
3063 supported: usize,
3064 skipped: usize,
3065 unsupported: usize,
3066}
3067
3068struct PreviewRow {
3069 row_id: usize,
3070 parent_row_id: Option<usize>,
3071 depth: usize,
3072 name: String,
3073 kind: PreviewKind,
3074 is_dir: bool,
3075 language: Option<&'static str>,
3076 modified: String,
3077 type_label: String,
3078}
3079
3080#[derive(Copy, Clone)]
3081enum PreviewKind {
3082 Dir,
3083 Supported,
3084 Skipped,
3085 Unsupported,
3086}
3087
3088impl PreviewKind {
3089 fn filter_key(self) -> &'static str {
3090 match self {
3091 PreviewKind::Dir => "dir",
3092 PreviewKind::Supported => "supported",
3093 PreviewKind::Skipped => "skipped",
3094 PreviewKind::Unsupported => "unsupported",
3095 }
3096 }
3097
3098 fn label(self) -> &'static str {
3099 match self {
3100 PreviewKind::Dir => "dir",
3101 PreviewKind::Supported => "supported",
3102 PreviewKind::Skipped => "skipped by policy",
3103 PreviewKind::Unsupported => "unsupported",
3104 }
3105 }
3106
3107 fn badge_class(self) -> &'static str {
3108 match self {
3109 PreviewKind::Dir => "badge badge-dir",
3110 PreviewKind::Supported => "badge badge-scan",
3111 PreviewKind::Skipped => "badge badge-skip",
3112 PreviewKind::Unsupported => "badge badge-unsupported",
3113 }
3114 }
3115
3116 fn node_class(self) -> &'static str {
3117 match self {
3118 PreviewKind::Dir => "tree-node-dir",
3119 PreviewKind::Supported => "tree-node-supported",
3120 PreviewKind::Skipped => "tree-node-skipped",
3121 PreviewKind::Unsupported => "tree-node-unsupported",
3122 }
3123 }
3124}
3125
3126struct PreviewBudget {
3127 shown: usize,
3128 max_entries: usize,
3129 max_depth: usize,
3130}
3131
3132#[allow(clippy::too_many_arguments)]
3133fn collect_preview_rows(
3134 root: &Path,
3135 dir: &Path,
3136 depth: usize,
3137 parent_row_id: Option<usize>,
3138 next_row_id: &mut usize,
3139 budget: &mut PreviewBudget,
3140 stats: &mut PreviewStats,
3141 rows: &mut Vec<PreviewRow>,
3142 languages: &mut Vec<&'static str>,
3143 include_patterns: &[String],
3144 exclude_patterns: &[String],
3145) -> Result<()> {
3146 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
3147 return Ok(());
3148 }
3149
3150 let mut entries = fs::read_dir(dir)
3151 .with_context(|| format!("failed to read directory {}", dir.display()))?
3152 .filter_map(|entry| entry.ok())
3153 .collect::<Vec<_>>();
3154 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
3155
3156 for entry in entries {
3157 if budget.shown >= budget.max_entries {
3158 break;
3159 }
3160
3161 let path = entry.path();
3162 let name = entry.file_name().to_string_lossy().into_owned();
3163 let metadata = match entry.metadata() {
3164 Ok(meta) => meta,
3165 Err(_) => continue,
3166 };
3167 let row_id = *next_row_id;
3168 *next_row_id += 1;
3169 let modified = metadata
3170 .modified()
3171 .ok()
3172 .map(format_system_time)
3173 .unwrap_or_else(|| "-".to_string());
3174
3175 if metadata.is_dir() {
3176 let relative = preview_relative_path(root, &path);
3177 if should_skip_preview_directory(&relative, exclude_patterns) {
3178 continue;
3179 }
3180
3181 stats.directories += 1;
3182 rows.push(PreviewRow {
3183 row_id,
3184 parent_row_id,
3185 depth: depth + 1,
3186 name: format!("{}/", name),
3187 kind: PreviewKind::Dir,
3188 is_dir: true,
3189 language: None,
3190 modified,
3191 type_label: "Directory".to_string(),
3192 });
3193 budget.shown += 1;
3194 if !matches!(name.as_str(), ".git" | "node_modules" | "target") {
3195 collect_preview_rows(
3196 root,
3197 &path,
3198 depth + 1,
3199 Some(row_id),
3200 next_row_id,
3201 budget,
3202 stats,
3203 rows,
3204 languages,
3205 include_patterns,
3206 exclude_patterns,
3207 )?;
3208 }
3209 continue;
3210 }
3211
3212 if metadata.is_file() {
3213 let relative = preview_relative_path(root, &path);
3214 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
3215 continue;
3216 }
3217
3218 stats.files += 1;
3219 let kind = classify_preview_file(&name);
3220 match kind {
3221 PreviewKind::Supported => stats.supported += 1,
3222 PreviewKind::Skipped => stats.skipped += 1,
3223 PreviewKind::Unsupported => stats.unsupported += 1,
3224 PreviewKind::Dir => {}
3225 }
3226 let language = detect_language_name(&name);
3227 if let Some(language) = language {
3228 if !languages.contains(&language) {
3229 languages.push(language);
3230 }
3231 }
3232 rows.push(PreviewRow {
3233 row_id,
3234 parent_row_id,
3235 depth: depth + 1,
3236 name: name.clone(),
3237 kind,
3238 is_dir: false,
3239 language,
3240 modified,
3241 type_label: preview_type_label(&name, language, kind),
3242 });
3243 budget.shown += 1;
3244 }
3245 }
3246
3247 Ok(())
3248}
3249
3250fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
3251 if let Some(language) = language {
3252 return format!("{} source", language);
3253 }
3254 let lower = name.to_ascii_lowercase();
3255 let ext = Path::new(&lower)
3256 .extension()
3257 .and_then(|e| e.to_str())
3258 .unwrap_or("");
3259 match kind {
3260 PreviewKind::Skipped => {
3261 if lower.ends_with(".min.js") {
3262 "Minified asset".to_string()
3263 } else if [
3264 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
3265 ]
3266 .contains(&ext)
3267 {
3268 "Binary or archive".to_string()
3269 } else {
3270 "Skipped file".to_string()
3271 }
3272 }
3273 PreviewKind::Unsupported => {
3274 if ext.is_empty() {
3275 "Unsupported file".to_string()
3276 } else {
3277 format!("{} file", ext.to_ascii_uppercase())
3278 }
3279 }
3280 PreviewKind::Supported => "Supported source".to_string(),
3281 PreviewKind::Dir => "Directory".to_string(),
3282 }
3283}
3284
3285fn format_system_time(time: SystemTime) -> String {
3286 let secs = match time.duration_since(UNIX_EPOCH) {
3287 Ok(duration) => duration.as_secs() as i64,
3288 Err(_) => return "-".to_string(),
3289 };
3290 let days = secs.div_euclid(86_400);
3291 let secs_of_day = secs.rem_euclid(86_400);
3292 let (year, month, day) = civil_from_days(days);
3293 let hour = secs_of_day / 3_600;
3294 let minute = (secs_of_day % 3_600) / 60;
3295 format!(
3296 "{:04}-{:02}-{:02} {:02}:{:02}",
3297 year, month, day, hour, minute
3298 )
3299}
3300
3301fn civil_from_days(days: i64) -> (i32, u32, u32) {
3302 let z = days + 719_468;
3303 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
3304 let doe = z - era * 146_097;
3305 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
3306 let y = yoe + era * 400;
3307 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
3308 let mp = (5 * doy + 2) / 153;
3309 let d = doy - (153 * mp + 2) / 5 + 1;
3310 let m = mp + if mp < 10 { 3 } else { -9 };
3311 let year = y + if m <= 2 { 1 } else { 0 };
3312 (year as i32, m as u32, d as u32)
3313}
3314
3315fn detect_language_name(name: &str) -> Option<&'static str> {
3316 let lower = name.to_ascii_lowercase();
3317 if lower.ends_with(".c") || lower.ends_with(".h") {
3318 Some("C")
3319 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
3320 .iter()
3321 .any(|s| lower.ends_with(s))
3322 {
3323 Some("C++")
3324 } else if lower.ends_with(".cs") {
3325 Some("C#")
3326 } else if lower.ends_with(".py") {
3327 Some("Python")
3328 } else if lower.ends_with(".sh") {
3329 Some("Shell")
3330 } else if [".ps1", ".psm1", ".psd1"]
3331 .iter()
3332 .any(|s| lower.ends_with(s))
3333 {
3334 Some("PowerShell")
3335 } else {
3336 None
3337 }
3338}
3339
3340fn language_icon_file(language: &str) -> Option<&'static str> {
3341 match language {
3342 "C" => Some("c.png"),
3343 "C++" => Some("cpp.png"),
3344 "C#" => Some("c-sharp.png"),
3345 "Python" => Some("python.png"),
3346 "Shell" => Some("shell.png"),
3347 "PowerShell" => Some("powershell.png"),
3348 "JavaScript" => Some("java-script.png"),
3349 "HTML" => Some("html-5.png"),
3350 "Java" => Some("java.png"),
3351 "Visual Basic" => Some("visual-basic.png"),
3352 _ => None,
3353 }
3354}
3355
3356fn language_inline_svg(language: &str) -> Option<&'static str> {
3361 match language {
3362 "Go" => Some(
3363 r##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 100 100" aria-hidden="true"><rect width="100" height="100" rx="16" fill="#00ACD7"/><text x="50" y="68" text-anchor="middle" font-family="sans-serif" font-weight="900" font-size="46" fill="#fff">Go</text></svg>"##,
3364 ),
3365 "Rust" => Some(
3366 r##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 100 100" aria-hidden="true"><rect width="100" height="100" rx="16" fill="#B7410E"/><text x="50" y="68" text-anchor="middle" font-family="sans-serif" font-weight="900" font-size="46" fill="#fff">Rs</text></svg>"##,
3367 ),
3368 "TypeScript" => Some(
3369 r##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 100 100" aria-hidden="true"><rect width="100" height="100" rx="16" fill="#3178C6"/><text x="50" y="68" text-anchor="middle" font-family="sans-serif" font-weight="900" font-size="46" fill="#fff">TS</text></svg>"##,
3370 ),
3371 _ => None,
3372 }
3373}
3374
3375fn classify_preview_file(name: &str) -> PreviewKind {
3376 let lower = name.to_ascii_lowercase();
3377
3378 let scannable = [
3379 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
3380 ".psm1", ".psd1",
3381 ]
3382 .iter()
3383 .any(|suffix| lower.ends_with(suffix));
3384
3385 if scannable {
3386 PreviewKind::Supported
3387 } else if lower.ends_with(".min.js")
3388 || lower.ends_with(".lock")
3389 || lower.ends_with(".png")
3390 || lower.ends_with(".jpg")
3391 || lower.ends_with(".jpeg")
3392 || lower.ends_with(".gif")
3393 || lower.ends_with(".zip")
3394 || lower.ends_with(".pdf")
3395 || lower.ends_with(".pyc")
3396 || lower.ends_with(".xz")
3397 || lower.ends_with(".tar")
3398 || lower.ends_with(".gz")
3399 {
3400 PreviewKind::Skipped
3401 } else {
3402 PreviewKind::Unsupported
3403 }
3404}
3405
3406fn preview_relative_path(root: &Path, path: &Path) -> String {
3407 path.strip_prefix(root)
3408 .ok()
3409 .unwrap_or(path)
3410 .to_string_lossy()
3411 .replace('\\', "/")
3412 .trim_matches('/')
3413 .to_string()
3414}
3415
3416fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
3417 if relative.is_empty() {
3418 return false;
3419 }
3420
3421 exclude_patterns.iter().any(|pattern| {
3422 wildcard_match(pattern, relative)
3423 || wildcard_match(pattern, &format!("{relative}/"))
3424 || wildcard_match(pattern, &format!("{relative}/placeholder"))
3425 })
3426}
3427
3428fn should_include_preview_file(
3429 relative: &str,
3430 include_patterns: &[String],
3431 exclude_patterns: &[String],
3432) -> bool {
3433 if relative.is_empty() {
3434 return true;
3435 }
3436
3437 let included = include_patterns.is_empty()
3438 || include_patterns
3439 .iter()
3440 .any(|pattern| wildcard_match(pattern, relative));
3441 let excluded = exclude_patterns
3442 .iter()
3443 .any(|pattern| wildcard_match(pattern, relative));
3444
3445 included && !excluded
3446}
3447
3448fn wildcard_match(pattern: &str, candidate: &str) -> bool {
3449 let pattern = pattern.trim().replace('\\', "/");
3450 let candidate = candidate.trim().replace('\\', "/");
3451 let p = pattern.as_bytes();
3452 let c = candidate.as_bytes();
3453 let mut pi = 0usize;
3454 let mut ci = 0usize;
3455 let mut star: Option<usize> = None;
3456 let mut star_match = 0usize;
3457
3458 while ci < c.len() {
3459 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
3460 pi += 1;
3461 ci += 1;
3462 } else if pi < p.len() && p[pi] == b'*' {
3463 while pi < p.len() && p[pi] == b'*' {
3464 pi += 1;
3465 }
3466 star = Some(pi);
3467 star_match = ci;
3468 } else if let Some(star_pi) = star {
3469 star_match += 1;
3470 ci = star_match;
3471 pi = star_pi;
3472 } else {
3473 return false;
3474 }
3475 }
3476
3477 while pi < p.len() && p[pi] == b'*' {
3478 pi += 1;
3479 }
3480
3481 pi == p.len()
3482}
3483
3484fn escape_html(value: &str) -> String {
3485 value
3486 .replace('&', "&")
3487 .replace('<', "<")
3488 .replace('>', ">")
3489 .replace('"', """)
3490 .replace('\'', "'")
3491}
3492
3493#[derive(Clone)]
3494struct LanguageSummaryRow {
3495 language: String,
3496 files: u64,
3497 physical: u64,
3498 code: u64,
3499 comments: u64,
3500 blank: u64,
3501 mixed: u64,
3502 functions: u64,
3503 classes: u64,
3504 variables: u64,
3505 imports: u64,
3506}
3507
3508#[derive(Clone)]
3509struct SubmoduleRow {
3510 name: String,
3511 relative_path: String,
3512 files_analyzed: u64,
3513 code_lines: u64,
3514 comment_lines: u64,
3515 blank_lines: u64,
3516 total_physical_lines: u64,
3517 html_url: Option<String>,
3518}
3519
3520#[derive(Template)]
3521#[template(
3522 source = r##"
3523<!doctype html>
3524<html lang="en">
3525<head>
3526 <meta charset="utf-8">
3527 <title>OxideSLOC | samples/basic</title>
3528 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
3529 <style>
3530 :root {
3531 --bg: #efe9e2;
3532 --surface: #fcfaf7;
3533 --surface-2: #f7f0e8;
3534 --surface-3: #efe3d5;
3535 --line: #dfcfbf;
3536 --line-strong: #cfb29c;
3537 --text: #2f241c;
3538 --muted: #6f6257;
3539 --muted-2: #917f71;
3540 --nav: #b85d33;
3541 --nav-2: #7a371b;
3542 --accent: #2563eb;
3543 --accent-2: #1d4ed8;
3544 --oxide: #b85d33;
3545 --oxide-2: #8f4220;
3546 --success-bg: #eaf9ee;
3547 --success-text: #1c8746;
3548 --warn-bg: #fff2d8;
3549 --warn-text: #926000;
3550 --danger-bg: #fdeaea;
3551 --danger-text: #b33b3b;
3552 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
3553 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
3554 --radius: 14px;
3555 }
3556
3557 body.dark-theme {
3558 --bg: #1b1511;
3559 --surface: #261c17;
3560 --surface-2: #2d221d;
3561 --surface-3: #372922;
3562 --line: #524238;
3563 --line-strong: #6c5649;
3564 --text: #f5ece6;
3565 --muted: #c7b7aa;
3566 --muted-2: #aa9485;
3567 --nav: #b85d33;
3568 --nav-2: #7a371b;
3569 --accent: #6f9bff;
3570 --accent-2: #4a78ee;
3571 --oxide: #d37a4c;
3572 --oxide-2: #b35428;
3573 --success-bg: #163927;
3574 --success-text: #8fe2a8;
3575 --warn-bg: #3c2d11;
3576 --warn-text: #f3cb75;
3577 --danger-bg: #3d1f1f;
3578 --danger-text: #ff9f9f;
3579 --shadow: 0 14px 28px rgba(0,0,0,0.28);
3580 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
3581 }
3582
3583 * { box-sizing: border-box; }
3584 html, body { margin: 0; min-height: 100vh; font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); }
3585 html { scrollbar-gutter: stable; }
3586 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
3587 .top-nav, .page, .loading { position: relative; z-index: 2; }
3588 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
3589 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
3590 .top-nav { position: sticky; top: 0; z-index: 30; background: linear-gradient(180deg, var(--nav), var(--nav-2)); border-bottom: 1px solid rgba(255,255,255,0.12); box-shadow: 0 4px 14px rgba(0,0,0,0.18); }
3591 .top-nav-inner { max-width: 1720px; margin: 0 auto; padding: 4px 24px; min-height: 56px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 18px; }
3592 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
3593 .brand-logo { width: 42px; height: 46px; object-fit: contain; flex: 0 0 auto; filter: drop-shadow(0 4px 10px rgba(0,0,0,0.22)); }
3594 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
3595 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
3596 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
3597 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
3598 .nav-project-pill { width: 100%; max-width: 240px; display:none; align-items:center; justify-content:center; gap: 10px; min-height: 38px; padding: 0 14px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.18); color: #fff; background: rgba(255,255,255,0.10); font-size: 12px; font-weight: 700; box-shadow: inset 0 1px 0 rgba(255,255,255,0.08); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3599 .nav-project-pill.visible { display:inline-flex; }
3600 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
3601 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; }
3602 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: wrap; }
3603 .nav-pill, .theme-toggle { display: inline-flex; align-items: center; gap: 8px; min-height: 38px; padding: 0 14px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.18); color: #fff; background: rgba(255,255,255,0.08); font-size: 12px; font-weight: 700; box-shadow: inset 0 1px 0 rgba(255,255,255,0.08); text-decoration:none; transition:background .15s ease,transform .15s ease; }
3604 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
3605 .nav-pill code { color: #fff; background: rgba(0,0,0,0.28); border: 1px solid rgba(255,255,255,0.10); padding: 3px 8px; border-radius: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
3606 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
3607 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
3608 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
3609 .theme-toggle .icon-sun { display:none; }
3610 body.dark-theme .theme-toggle .icon-sun { display:block; }
3611 body.dark-theme .theme-toggle .icon-moon { display:none; }
3612 .status-dot { width: 8px; height: 8px; border-radius: 999px; background: #26d768; box-shadow: 0 0 0 4px rgba(38,215,104,0.14); flex:0 0 auto; }
3613 .server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}.server-status-tip::before{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{display:block;}
3614 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
3615 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
3616 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
3617 .workbench-box { border: 1px solid var(--line-strong); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
3618 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
3619 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; }
3620 .wb-stats-header { padding: 10px 24px 0; }
3621 .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
3622 .ws-left { display:flex; align-items:center; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
3623 .ws-stat { display:flex; flex-direction:column; gap: 6px; flex:0 0 auto; min-width:110px; padding: 12px 18px; border-radius: 10px; background: rgba(184,93,51,0.06); border: 1px solid rgba(184,93,51,0.15); }
3624 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
3625 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
3626 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
3627 .ws-badge { display:inline-flex; align-items:center; padding: 1px 8px; border-radius: 999px; background: rgba(184,93,51,0.10); border: 1px solid rgba(184,93,51,0.20); color: var(--oxide-2); font-size: 12px; font-weight: 800; position:relative; cursor:help; overflow: visible; }
3628 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
3629 .ws-lang-tooltip { display:none; position:absolute; top:calc(100% + 8px); left:0; z-index:200; background:var(--surface); border:1px solid var(--line-strong); border-radius:12px; box-shadow:0 10px 30px rgba(0,0,0,0.18); padding:14px 16px; pointer-events:none; min-width:400px; }
3630 .ws-badge:hover .ws-lang-tooltip { display:block; }
3631 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:9px; }
3632 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
3633 .ws-lang-item { padding:3px 6px; border-radius:5px; background:rgba(184,93,51,0.08); border:1px solid rgba(184,93,51,0.14); color:var(--oxide-2); font-size:11px; font-weight:700; text-align:center; white-space:nowrap; }
3634 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
3635 .ws-divider { display: none; }
3636 .ws-path-link { background:none; border:none; padding:0; font:inherit; font-size:13px; font-weight:700; color:var(--oxide-2); cursor:pointer; text-decoration:underline; text-decoration-style:dotted; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; max-width:100%; }
3637 .ws-path-link:hover { color:var(--oxide); }
3638 body.dark-theme .ws-path-link { color:var(--oxide); }
3639 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
3640 .ws-stat-output .ws-value { overflow:hidden; display:block; }
3641 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
3642 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
3643 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
3644 .ws-mini-box-lg { flex:2 1 0; }
3645 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
3646 .ws-mini-box-br { flex:1.5 1 0; }
3647 .scope-legend-row { display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap; padding:6px 12px; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); font-size:13px; flex-shrink:0; border-left:3px solid var(--line-strong); }
3648 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
3649 .path-scope-grid { display:grid; grid-template-columns: 1fr 1px auto; gap:0; align-items:stretch; }
3650 .path-scope-grid .input-group { width:100%; align-self:start; }
3651 .path-scope-sep { background:var(--line); margin:4px 14px; }
3652 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
3653 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
3654 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
3655 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
3656 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
3657 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
3658 .ws-mini-box { display:flex; flex-direction:column; gap: 6px; padding: 12px 14px; border-radius: 10px; background: rgba(184,93,51,0.06); border: 1px solid rgba(184,93,51,0.15); min-width: 0; flex: 1 1 0; }
3659 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
3660 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
3661 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
3662 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
3663 .ws-action-link { display:inline-flex; align-items:center; justify-content:center; gap: 7px; padding: 12px 22px; border-radius: 10px; font-size: 13px; font-weight: 800; color: var(--oxide-2); text-decoration:none; border: 1px solid rgba(184,93,51,0.20); background: rgba(184,93,51,0.06); transition: background 0.15s ease, border-color 0.15s ease; white-space:nowrap; align-self:stretch; }
3664 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
3665 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
3666 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
3667 .summary-card, .card, .step-nav, .explainer-card, .review-card, .workspace-card, .artifact-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease, transform 0.18s ease; }
3668 .summary-card:hover, .workspace-card:hover, .explainer-card:hover, .artifact-card:hover, .review-card:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); transform: translateY(-2px); }
3669 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
3670 .side-info-card { padding: 18px; }
3671 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
3672 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
3673 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
3674 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
3675 .summary-label, .section-kicker, .meta-label, .field-help-title { font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted-2); }
3676 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
3677 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
3678 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
3679 .coverage-pill, .language-pill, .soft-chip { display:inline-flex; align-items:center; min-height: 32px; padding: 0 12px; border-radius: 999px; border:1px solid var(--line); background: var(--surface-2); color: var(--text); font-size: 13px; font-weight: 700; }
3680 .layout { display:grid; grid-template-columns: 218px minmax(0, 1fr); gap: 18px; align-items:stretch; min-height: calc(100vh - 57px); }
3681 .side-stack { display:grid; gap: 16px; align-items:start; align-self: stretch; }
3682 .step-nav { padding: 20px 16px; position: sticky; top: 57px; z-index: 25; }
3683 .step-nav h3 { margin: 6px 4px 20px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
3684 .step-button { width:100%; display:flex; align-items:center; gap:12px; border:none; background:transparent; border-radius: 12px; padding: 14px 12px; color: var(--text); cursor:pointer; text-align:left; font-size:15px; font-weight:700; transition: background 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; animation: stepEntrance 0.3s ease both; }
3685 .step-button:hover { background: var(--surface-2); }
3686 .step-button.active { background: rgba(37,99,235,0.09); box-shadow: inset 0 0 0 1px rgba(37,99,235,0.18); color: var(--accent-2); }
3687 .step-num { width:22px; height:22px; border-radius:999px; display:inline-flex; align-items:center; justify-content:center; background: var(--surface-3); color: var(--text); font-size:12px; font-weight:800; flex:0 0 auto; }
3688 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
3689 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
3690 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
3691 .step-nav-summary { margin:8px 4px 0; padding:10px 12px; border-radius:10px; background:rgba(184,93,51,0.05); border:1px solid rgba(184,93,51,0.14); }
3692 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
3693 .step-nav-sum-row:last-child { border-bottom:none; }
3694 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
3695 .step-nav-sum-val { font-size:12px; font-weight:700; color:var(--text); text-align:right; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:120px; }
3696 .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
3697 .quick-scan-section { padding: 10px 4px 14px; }
3698 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
3699 .quick-scan-btn { width:100%; display:flex; align-items:center; justify-content:center; gap:8px; padding:11px 14px; border-radius:14px; border:none; background:linear-gradient(135deg,#e07b3a,#b85028); color:#fff; font-size:14px; font-weight:800; cursor:pointer; box-shadow:0 6px 18px rgba(184,80,40,0.28); transition:transform 0.15s ease,box-shadow 0.15s ease; }
3700 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
3701 .quick-scan-btn:active { transform:translateY(0); }
3702 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
3703 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
3704 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
3705 @keyframes stepPulse { 0%,100%{box-shadow:0 0 0 0 rgba(37,99,235,0.2);} 60%{box-shadow:0 0 0 5px rgba(37,99,235,0.07);} }
3706 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
3707 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
3708 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
3709 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
3710 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
3711 .card-header { padding: 22px 22px 18px; border-bottom:1px solid var(--line); background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); position: sticky; top: 57px; z-index: 20; border-radius: var(--radius) var(--radius) 0 0; }
3712 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
3713 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
3714 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
3715 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
3716 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
3717 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
3718 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
3719 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
3720 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
3721 .card-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
3722 .card-body { padding: 22px; }
3723 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
3724 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
3725 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
3726 .section { margin-bottom: 22px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
3727 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
3728 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
3729 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
3730 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
3731 .field { min-width:0; }
3732 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
3733 input[type="text"], textarea, select { width:100%; min-width:0; border-radius: 10px; border:1px solid var(--line-strong); background: #fff; color: var(--text); font-size: 15px; padding: 12px 14px; transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease, background 0.15s ease; }
3734 body.dark-theme input[type="text"], body.dark-theme textarea, body.dark-theme select, body.dark-theme code, body.dark-theme .preview-code { background: #201813; color: var(--text); }
3735 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
3736 input[type="text"]:focus, textarea:focus, select:focus { outline:none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,0.13); transform: translateY(-1px); }
3737 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
3738 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
3739 .path-history-badge { margin-top: 6px; padding: 4px 10px; border-radius: 6px; font-size: 12px; line-height: 1.4; display: inline-flex; align-items: center; gap: 4px; }
3740 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
3741 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
3742 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
3743 .input-group.compact { grid-template-columns: 1fr auto auto; }
3744 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
3745 .path-info-card { padding: 16px 18px; border-radius: 14px; border: 1px solid var(--line); background: linear-gradient(135deg, var(--surface-2), rgba(184,93,51,0.03)); }
3746 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
3747 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
3748 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
3749 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
3750 .path-info-val { font-size: 13px; font-weight: 800; color: var(--text); text-align:right; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:120px; }
3751 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
3752 .mini-button, button.primary, button.secondary, .artifact-toggle { min-height: 42px; border-radius: 10px; border:1px solid var(--line-strong); background: var(--surface-2); color: var(--text); padding: 0 14px; font-size: 14px; font-weight: 800; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; }
3753 .mini-button:hover, button.primary:hover, button.secondary:hover, .artifact-toggle:hover { transform: translateY(-1px); box-shadow: 0 10px 18px rgba(0,0,0,0.08); }
3754 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
3755 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
3756 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
3757 button.secondary { background: var(--surface); }
3758 .wizard-actions { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-top: 22px; padding-top: 18px; border-top:1px solid var(--line); }
3759 .section + .wizard-actions { border-top: none; padding-top: 0; }
3760 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
3761 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
3762 .field-help-grid.coupled-help { margin-top: 12px; }
3763 .field-help-grid.preset-grid { align-items: start; }
3764 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:stretch; margin-bottom: 16px; }
3765 .preset-inline-row .field { margin: 0; }
3766 .preset-inline-row .explainer-card { margin: 0; }
3767 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
3768 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
3769 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
3770 .output-field-row .field { margin: 0; }
3771 .output-field-aside { padding: 16px 18px; border-radius: 14px; border: 1px solid var(--line); background: var(--surface-2); font-size: 14px; color: var(--muted); line-height: 1.6; }
3772 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
3773 .step3-subtitle { margin-bottom: 28px; }
3774 .counting-intro { margin-bottom: 22px; max-width: none; }
3775 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
3776 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
3777 .counting-top-grid .hint { margin-top: 14px; padding: 12px 14px; border-left: 4px solid var(--oxide); background: linear-gradient(180deg, rgba(184,93,51,0.06), transparent), var(--surface-2); border-radius: 10px; }
3778 .subsection-bar { margin: 24px 0 14px; padding: 10px 14px; border-radius: 12px; border: 1px solid var(--line); background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface-2); font-size: 12px; font-weight: 900; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
3779 .section-spacer-top { margin-top: 28px; }
3780 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
3781 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
3782 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
3783 .code-sample { margin-top: 10px; padding: 14px 16px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; font-size: 13px; color: var(--text); }
3784 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
3785 .preset-summary-chip { display:inline-flex; align-items:center; min-height: 30px; padding: 0 12px; border-radius: 999px; border:1px solid var(--line); background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface-2); color: var(--text); font-size: 12px; font-weight: 800; }
3786 .preset-note { margin-top: 12px; padding: 12px 14px; border-radius: 12px; border:1px solid var(--line); background: linear-gradient(180deg, rgba(184,93,51,0.08), transparent), var(--surface-2); color: var(--muted); font-size: 13px; line-height: 1.6; }
3787 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
3788 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
3789 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
3790 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
3791 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
3792 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
3793 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
3794 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; }
3795 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
3796 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
3797 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
3798 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
3799 .advanced-rule-row { display:grid; grid-template-columns: 220px 220px minmax(0, 1fr); gap: 14px; align-items:center; padding: 16px; border:1px solid var(--line); border-radius: 14px; background: var(--surface-2); }
3800 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
3801 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
3802 .docstring-example-inset { padding: 14px 16px 14px 32px; background: var(--surface-2); border-left: 3px solid var(--line-strong); border-radius: 0 0 10px 10px; margin-top: -1px; }
3803 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
3804 .always-tracked-tip { display:flex; align-items:flex-start; gap: 14px; padding: 16px 18px; border-radius: 14px; border: 1px solid rgba(37,99,235,0.18); background: linear-gradient(135deg, rgba(37,99,235,0.05), rgba(37,99,235,0.02)); margin-top: 8px; }
3805 .always-tracked-tip-icon { flex: 0 0 auto; width: 28px; height: 28px; border-radius: 50%; background: rgba(37,99,235,0.12); color: var(--accent-2); display:flex; align-items:center; justify-content:center; font-size: 14px; font-weight: 900; margin-top: 2px; }
3806 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
3807 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
3808 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
3809 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
3810 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
3811 .advanced-rule-description strong { color: var(--text); }
3812 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
3813 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
3814 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
3815 .review-link:hover { text-decoration: underline; }
3816 .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; }
3817 .artifact-card { position:relative; padding: 16px; cursor:pointer; }
3818 .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
3819 .artifact-card .marker { position:absolute; top: 12px; right: 12px; width: 22px; height: 22px; border-radius: 999px; border:2px solid var(--line-strong); display:flex; align-items:center; justify-content:center; font-size: 12px; color: transparent; }
3820 .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
3821 .artifact-icon { width: 42px; height: 42px; border-radius: 12px; background: var(--surface-2); border:1px solid var(--line); display:flex; align-items:center; justify-content:center; font-size: 22px; font-weight: 900; }
3822 .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
3823 .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
3824 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
3825 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
3826 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
3827 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
3828 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
3829 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
3830 .review-card ul { padding-left: 18px; margin: 0; }
3831 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
3832 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
3833 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
3834 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
3835 .review-card { min-height: 200px; }
3836 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
3837 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
3838 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
3839 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
3840 .lang-overflow-chip { position:relative; cursor:default; }
3841 .lang-overflow-tip { display:none; position:absolute; top:calc(100% + 6px); left:0; z-index:300; background:var(--surface); border:1px solid var(--line-strong); border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,0.16); padding:10px 14px; min-width:160px; white-space:pre-line; font-size:12px; font-weight:600; color:var(--text); line-height:1.7; pointer-events:none; }
3842 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
3843 .git-inline-row { align-items:start; }
3844 .mixed-line-card { display:flex; flex-direction:column; }
3845 .preset-inline-row .toggle-card { justify-content: center; }
3846 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
3847 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
3848 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
3849 .explorer-title { font-size: 18px; font-weight: 850; }
3850 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
3851 .explorer-subtitle.wide { max-width: none; }
3852 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
3853 .better-spacing { align-items:flex-start; justify-content:flex-end; }
3854 .badge { display:inline-flex; align-items:center; min-height: 30px; padding: 0 12px; border-radius: 999px; font-size: 13px; font-weight: 800; border:1px solid transparent; }
3855 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
3856 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
3857 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
3858 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
3859 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
3860 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
3861 .scope-stat-button { appearance:none; text-align:left; border:1px solid var(--line); background: var(--surface); border-radius: 14px; padding: 14px 16px; cursor:pointer; transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease, background .15s ease; }
3862 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
3863 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
3864 .scope-stat-button.supported { background: var(--success-bg); }
3865 .scope-stat-button.skipped { background: var(--warn-bg); }
3866 .scope-stat-button.unsupported { background: var(--danger-bg); }
3867 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
3868 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
3869 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
3870 [data-tooltip] { position: relative; }
3871 [data-tooltip]::after { content: attr(data-tooltip); display: none; position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%); background: var(--text); color: var(--bg); padding: 7px 12px; border-radius: 8px; font-size: 12px; font-weight: 600; white-space: normal; width: max-content; min-width: 180px; max-width: 280px; text-align: center; line-height: 1.5; pointer-events: none; z-index: 400; box-shadow: 0 4px 14px rgba(0,0,0,0.22); }
3872 [data-tooltip]:hover::after { display: block; }
3873 .scope-stat-button[data-tooltip] { cursor: pointer; }
3874 .badge[data-tooltip] { cursor: help; }
3875 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
3876 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
3877 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
3878 .preview-note.stronger { background: linear-gradient(180deg, rgba(184,93,51,0.08), transparent), var(--surface-2); border-left: 4px solid var(--oxide); font-size: 15px; line-height: 1.65; }
3879 .preview-code, code { display:block; margin-top: 8px; padding: 10px 12px; border-radius: 10px; border:1px solid var(--line); background: #fff; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; overflow-wrap:anywhere; }
3880 code { display:inline-block; margin-top:0; padding:2px 7px; }
3881 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
3882 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
3883 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
3884 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
3885 .language-pill.muted-pill { color: var(--muted); }
3886 button.language-pill { appearance:none; cursor:pointer; }
3887 .detected-language-chip.active { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(37,99,235,0.12); background: linear-gradient(180deg, rgba(37,99,235,0.10), transparent), var(--surface-2); }
3888 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
3889 .file-explorer-controls { display:flex; justify-content:space-between; gap: 12px; align-items:center; padding: 12px 14px; border-bottom:1px solid var(--line); background: linear-gradient(180deg, var(--surface-2), rgba(255,255,255,0.35)); flex-wrap: nowrap; }
3890 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
3891 .file-explorer-search-row { margin-left: auto; }
3892 .explorer-filter-select { min-width: 170px; width: 170px; }
3893 .explorer-search { min-width: 300px; width: 300px; }
3894 .file-explorer-header { display:grid; grid-template-columns: minmax(0, 1fr) 170px 160px 200px; gap: 12px; padding: 11px 14px; background: linear-gradient(180deg, var(--surface-2), transparent); border-bottom:1px solid var(--line); }
3895 .tree-sort-button { display:flex; align-items:center; justify-content:space-between; gap: 10px; width:100%; padding: 4px 8px; border:none; border-radius: 10px; background: transparent; color: var(--muted-2); font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; cursor:pointer; }
3896 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
3897 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
3898 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
3899 .file-explorer-tree { max-height: 560px; overflow:auto; }
3900 .tree-row { display:grid; grid-template-columns: minmax(0, 1fr) 170px 160px 200px; gap: 12px; align-items:center; padding: 0 14px; border-bottom:1px solid rgba(0,0,0,0.04); }
3901 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
3902 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
3903 .tree-row.hidden-by-filter { display:none !important; }
3904 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 9px 0; }
3905 .tree-name-cell { display:flex; align-items:center; gap: 10px; padding-left: calc(var(--depth) * 18px + 8px); position: relative; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; min-width:0; }
3906 .tree-toggle { width: 28px; height: 28px; display:inline-flex; align-items:center; justify-content:center; border:none; background: var(--surface-2); color: var(--muted-2); cursor:pointer; font-size: 18px; line-height: 1; flex:0 0 28px; border-radius: 8px; border: 1px solid var(--line); font-weight: 900; }
3907 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
3908 .tree-bullet { color: var(--muted-2); width: 28px; text-align:center; flex: 0 0 28px; font-size: 14px; }
3909 .tree-node { display:inline-flex; align-items:center; min-width:0; }
3910 .tree-node-dir { color: var(--text); font-weight: 800; }
3911 .tree-node-supported { color: var(--success-text); }
3912 .tree-node-skipped { color: var(--warn-text); }
3913 .tree-node-unsupported { color: var(--danger-text); }
3914 .tree-node-more { color: var(--muted-2); font-style: italic; }
3915 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 13px; }
3916 .tree-status-cell { display:flex; justify-content:flex-start; }
3917 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
3918 .loading { position: fixed; inset: 0; display:none; align-items:center; justify-content:center; background: rgba(17,24,39,0.28); z-index: 100; }
3919 .loading.active { display:flex; }
3920 .loading-card { width: min(540px, calc(100vw - 40px)); border-radius: 18px; border: 1px solid var(--line); background: var(--surface); box-shadow: 0 20px 40px rgba(0,0,0,0.18); padding: 24px; text-align:center; }
3921 .spinner { width:44px; height:44px; margin:0 auto 16px; border-radius:999px; border:4px solid rgba(0,0,0,0.10); border-top-color: var(--accent); animation: spin .9s linear infinite; }
3922 @keyframes spin { to { transform: rotate(360deg);} }
3923 .progress-bar { width:100%; height:8px; margin-top:14px; background: var(--surface-3); border-radius:999px; overflow:hidden; }
3924 .progress-bar span { display:block; width:42%; height:100%; background: linear-gradient(90deg, var(--accent), #6b8cff); animation: pulseBar 1.4s ease-in-out infinite; }
3925 @keyframes pulseBar { 0% { transform: translateX(-35%); width:25%; } 50% { transform: translateX(130%); width:44%; } 100% { transform: translateX(250%); width:25%; } }
3926 .hidden { display:none !important; }
3927 .site-footer { position: relative; z-index: 2; margin-top: 24px; padding: 20px 24px; border-top: 1px solid var(--line); background: rgba(0,0,0,0.04); text-align: center; color: var(--muted); font-size: 13px; line-height: 1.7; }
3928 .site-footer a { color: var(--muted-2); font-weight: 700; text-decoration: none; }
3929 .site-footer a:hover { color: var(--text); text-decoration: underline; }
3930 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
3931 @media (max-width: 980px) { .field-grid, .artifact-grid, .review-grid, .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split, .glob-guidance-grid { grid-template-columns: 1fr; } .layout { grid-template-columns: 1fr; } .step-nav { position:static; } .top-nav-inner { grid-template-columns: 1fr; justify-items: stretch; } .nav-project-slot, .nav-status { justify-content:flex-start; } .input-group { grid-template-columns: 1fr 1fr; } .input-group.compact { grid-template-columns: 1fr 1fr; } .better-spacing { justify-content:flex-start; } .file-explorer-controls { flex-direction: column; align-items:flex-start; flex-wrap: wrap; } .file-explorer-search-row { margin-left: 0; flex-wrap: wrap; width: 100%; } .explorer-search { min-width: 0; width: 100%; } .file-explorer-header, .tree-row { grid-template-columns: minmax(0, 1fr) 110px 110px 140px; } .advanced-rule-row, .advanced-rule-row.static-note, .output-identity-grid, .counting-top-grid, .preset-inline-row { grid-template-columns: 1fr; } .wizard-progress { max-width: none; } .path-row-grid { grid-template-columns: 1fr; } .ws-left { flex-wrap: wrap; } .scan-pills-row { flex-wrap: wrap; } }
3932 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
3933 @keyframes floatCode{0%{opacity:0;transform:translateY(0) rotate(var(--rot));}10%{opacity:var(--op);}85%{opacity:var(--op);}100%{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}
3934 </style>
3935</head>
3936<body>
3937 <div class="background-watermarks" aria-hidden="true">
3938 <img src="/images/logo/logo-text.png" alt="" />
3939 <img src="/images/logo/logo-text.png" alt="" />
3940 <img src="/images/logo/logo-text.png" alt="" />
3941 <img src="/images/logo/logo-text.png" alt="" />
3942 <img src="/images/logo/logo-text.png" alt="" />
3943 <img src="/images/logo/logo-text.png" alt="" />
3944 <img src="/images/logo/logo-text.png" alt="" />
3945 <img src="/images/logo/logo-text.png" alt="" />
3946 <img src="/images/logo/logo-text.png" alt="" />
3947 <img src="/images/logo/logo-text.png" alt="" />
3948 <img src="/images/logo/logo-text.png" alt="" />
3949 <img src="/images/logo/logo-text.png" alt="" />
3950 <img src="/images/logo/logo-text.png" alt="" />
3951 <img src="/images/logo/logo-text.png" alt="" />
3952 </div>
3953 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
3954 <div class="top-nav">
3955 <div class="top-nav-inner">
3956 <a class="brand" href="/">
3957 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
3958 <div class="brand-copy">
3959 <div class="brand-title">OxideSLOC</div>
3960 <div class="brand-subtitle">Local analysis workbench</div>
3961 </div>
3962 </a>
3963 <div class="nav-project-slot">
3964 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
3965 <span class="nav-project-label">Project</span>
3966 <span class="nav-project-value" id="nav-project-title">samples/basic</span>
3967 </div>
3968 </div>
3969 <div class="nav-status">
3970 <a class="nav-pill" href="/">Home</a>
3971 <a class="nav-pill" href="/view-reports">View Reports</a>
3972 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
3973 <div class="server-status-wrap">
3974 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
3975 <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
3976 </div>
3977 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
3978 <svg class="icon-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 1 0 9.8 9.8z"></path></svg>
3979 <svg class="icon-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2"></path><path d="M12 20v2"></path><path d="M2 12h2"></path><path d="M20 12h2"></path><path d="M4.9 4.9l1.4 1.4"></path><path d="M17.7 17.7l1.4 1.4"></path><path d="M4.9 19.1l1.4-1.4"></path><path d="M17.7 6.3l1.4-1.4"></path></svg>
3980 </button>
3981 </div>
3982 </div>
3983 </div>
3984
3985 <div class="loading" id="loading">
3986 <div class="loading-card">
3987 <div class="spinner"></div>
3988 <h2>Scanning project...</h2>
3989 <p>This build still performs web scans synchronously. For very large repositories, keep this tab open while the Rust analysis core completes the run.</p>
3990 <div class="progress-bar"><span></span></div>
3991 </div>
3992 </div>
3993
3994 <div class="page">
3995 <div class="workbench-strip">
3996 <div class="workbench-box wb-stats">
3997 <div class="wb-stats-header">
3998 <span class="wb-stats-title">Analysis session</span>
3999 </div>
4000 <div class="ws-left">
4001 <div class="ws-stat">
4002 <span class="ws-label">Analyzers</span>
4003 <span class="ws-value">
4004 <span class="ws-badge">41 languages
4005 <div class="ws-lang-tooltip">
4006 <div class="ws-lang-tooltip-hdr">41 supported languages</div>
4007 <div class="ws-lang-grid">
4008 <span class="ws-lang-item">Assembly</span>
4009 <span class="ws-lang-item">C</span>
4010 <span class="ws-lang-item">C++</span>
4011 <span class="ws-lang-item">C#</span>
4012 <span class="ws-lang-item">Clojure</span>
4013 <span class="ws-lang-item">CSS</span>
4014 <span class="ws-lang-item">Dart</span>
4015 <span class="ws-lang-item">Dockerfile</span>
4016 <span class="ws-lang-item">Elixir</span>
4017 <span class="ws-lang-item">Erlang</span>
4018 <span class="ws-lang-item">F#</span>
4019 <span class="ws-lang-item">Go</span>
4020 <span class="ws-lang-item">Groovy</span>
4021 <span class="ws-lang-item">Haskell</span>
4022 <span class="ws-lang-item">HTML</span>
4023 <span class="ws-lang-item">Java</span>
4024 <span class="ws-lang-item">JavaScript</span>
4025 <span class="ws-lang-item">Julia</span>
4026 <span class="ws-lang-item">Kotlin</span>
4027 <span class="ws-lang-item">Lua</span>
4028 <span class="ws-lang-item">Makefile</span>
4029 <span class="ws-lang-item">Nim</span>
4030 <span class="ws-lang-item">Obj-C</span>
4031 <span class="ws-lang-item">OCaml</span>
4032 <span class="ws-lang-item">Perl</span>
4033 <span class="ws-lang-item">PHP</span>
4034 <span class="ws-lang-item">PowerShell</span>
4035 <span class="ws-lang-item">Python</span>
4036 <span class="ws-lang-item">R</span>
4037 <span class="ws-lang-item">Ruby</span>
4038 <span class="ws-lang-item">Rust</span>
4039 <span class="ws-lang-item">Scala</span>
4040 <span class="ws-lang-item">SCSS</span>
4041 <span class="ws-lang-item">Shell</span>
4042 <span class="ws-lang-item">SQL</span>
4043 <span class="ws-lang-item">Svelte</span>
4044 <span class="ws-lang-item">Swift</span>
4045 <span class="ws-lang-item">TypeScript</span>
4046 <span class="ws-lang-item">Vue</span>
4047 <span class="ws-lang-item">XML</span>
4048 <span class="ws-lang-item">Zig</span>
4049 </div>
4050 </div>
4051 </span>
4052 </span>
4053 </div>
4054 <div class="ws-divider"></div>
4055 <div class="ws-stat"><span class="ws-label">Mode</span><span class="ws-value">Localhost workbench</span></div>
4056 <div class="ws-divider"></div>
4057 <div class="ws-stat"><span class="ws-label">Active project</span><span class="ws-value" id="live-report-title">—</span></div>
4058 <div class="ws-divider"></div>
4059 <div class="ws-stat ws-stat-output">
4060 <span class="ws-label">Output</span>
4061 <span class="ws-value">
4062 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
4063 <span id="ws-output-root">project/sloc</span>
4064 </button>
4065 </span>
4066 </div>
4067 </div>
4068 </div>
4069 <div class="workbench-box ws-history-group">
4070 <div class="ws-history-label">Scan history</div>
4071 <div class="ws-history-inner">
4072 <div class="ws-mini-box ws-mini-box-sm">
4073 <div class="ws-mini-label">Scans</div>
4074 <div class="ws-mini-value" id="ws-scan-count">—</div>
4075 </div>
4076 <div class="ws-mini-box ws-mini-box-lg">
4077 <div class="ws-mini-label">Last Scan</div>
4078 <div class="ws-mini-value" id="ws-last-scan">—</div>
4079 </div>
4080 <div class="ws-mini-box ws-mini-box-br">
4081 <div class="ws-mini-label">Branch</div>
4082 <div class="ws-mini-value" id="ws-branch">—</div>
4083 </div>
4084 </div>
4085 </div>
4086 </div>
4087
4088 <div class="layout">
4089 <aside class="side-stack">
4090 <section class="step-nav">
4091 <h3>Guided scan setup</h3>
4092 <button type="button" class="step-button active" data-step-target="1"><span class="step-num">1</span><span>Select project</span></button>
4093 <button type="button" class="step-button" data-step-target="2"><span class="step-num">2</span><span>Counting rules</span></button>
4094 <button type="button" class="step-button" data-step-target="3"><span class="step-num">3</span><span>Outputs and reports</span></button>
4095 <button type="button" class="step-button" data-step-target="4"><span class="step-num">4</span><span>Review and run</span></button>
4096
4097 <div class="step-nav-info" id="step-nav-info">
4098 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
4099 <div class="step-nav-info-desc" id="step-nav-info-desc">Choose a project folder, apply scope filters, and preview which files will be counted.</div>
4100 </div>
4101
4102 <div class="step-nav-summary" id="step-nav-summary" style="display:none;">
4103 <div class="step-nav-sum-row"><span class="step-nav-sum-key">Path</span><span class="step-nav-sum-val" id="snav-path">—</span></div>
4104 <div class="step-nav-sum-row"><span class="step-nav-sum-key">Output</span><span class="step-nav-sum-val" id="snav-output">—</span></div>
4105 <div class="step-nav-sum-row"><span class="step-nav-sum-key">Title</span><span class="step-nav-sum-val" id="snav-title">—</span></div>
4106 </div>
4107
4108 <div class="quick-scan-divider"></div>
4109 <div class="quick-scan-section">
4110 <div class="quick-scan-label">No customization needed?</div>
4111 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
4112 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
4113 Quick Scan
4114 </button>
4115 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
4116 </div>
4117 </section>
4118
4119 </aside>
4120
4121 <section class="card">
4122 <div class="card-header">
4123 <div class="card-title-row">
4124 <div>
4125 <h1 class="card-title">Guided scan configuration</h1>
4126 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
4127 </div>
4128 <div class="wizard-progress" aria-label="Scan setup progress">
4129 <div class="wizard-progress-top">
4130 <span class="wizard-progress-label">Setup progress</span>
4131 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
4132 </div>
4133 <div class="wizard-progress-track">
4134 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
4135 </div>
4136 </div>
4137 </div>
4138 </div>
4139 <div class="card-body">
4140 <form method="post" action="/analyze" id="analyze-form">
4141 <div class="wizard-step active" data-step="1">
4142 <div class="section">
4143 <div class="section-kicker">Step 1</div>
4144 <h2>Select project and preview scope</h2>
4145 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
4146 <div class="field" style="margin:10px 0 0;">
4147 <label for="path">Project path</label>
4148 <div class="path-scope-grid">
4149 <div class="input-group">
4150 <input id="path" name="path" type="text" value="samples/basic" placeholder="/path/to/repository" required />
4151 <button type="button" class="mini-button oxide" id="browse-path">Browse</button>
4152 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
4153 </div>
4154 <div class="path-scope-sep"></div>
4155 <div class="scope-legend-row">
4156 <span class="scope-legend-label">Scope legend:</span>
4157 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
4158 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
4159 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
4160 </div>
4161 </div>
4162 <div class="hint">Browse opens the native folder picker through the Rust backend, so you do not need to type local paths manually.</div>
4163 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
4164 </div>
4165
4166 <div style="height:1px;background:var(--line);margin:28px 0;"></div>
4167
4168 <div id="preview-panel" style="margin-top:0;">
4169 <div class="preview-error">Loading preview...</div>
4170 </div>
4171 </div>
4172
4173 <div class="section">
4174 <div class="field-grid">
4175 <div class="field">
4176 <label for="include_globs">Include globs</label>
4177 <textarea id="include_globs" name="include_globs" placeholder="examples: src/**/*.py scripts/*.sh"></textarea>
4178 <div class="hint">Use line-separated or comma-separated patterns when you want to narrow the scan to only certain folders or file types. If you leave this empty, everything under the project path is eligible first, and then exclude rules trim it down.</div>
4179 </div>
4180 <div class="field">
4181 <label for="exclude_globs">Exclude globs</label>
4182 <textarea id="exclude_globs" name="exclude_globs" placeholder="examples: vendor/** **/*.min.js"></textarea>
4183 <div class="hint">Use this to remove noisy areas from the scope such as dependency trees, generated output, build folders, snapshots, or minified assets.</div>
4184 </div>
4185 </div>
4186 <div class="glob-guidance-grid">
4187 <div class="glob-guidance-card">
4188 <strong>How to read them</strong>
4189 <p><code>*</code> matches within a name, <code>**</code> reaches across nested folders, and patterns are usually written relative to the selected project path.</p>
4190 </div>
4191 <div class="glob-guidance-card">
4192 <strong>Common include examples</strong>
4193 <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
4194 </div>
4195 <div class="glob-guidance-card">
4196 <strong>Common exclude examples</strong>
4197 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
4198 </div>
4199 </div>
4200 </div>
4201
4202 <div class="section" style="margin-top:14px;">
4203 <div class="preset-inline-row git-inline-row">
4204 <div class="toggle-card" style="margin:0;">
4205 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
4206 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
4207 <label class="checkbox">
4208 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
4209 <div>
4210 <span>Detect and separate git submodules</span>
4211 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
4212 </div>
4213 </label>
4214 </div>
4215 <div class="explainer-card prominent" style="margin:0;">
4216 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
4217 <div class="advanced-rule-description"><strong>Purpose:</strong> Group each git submodule's files into its own section in the report so you can see per-submodule SLOC totals alongside overall figures.<br /><strong>Good default when:</strong> your repository contains nested sub-projects managed as git submodules.<br /><strong>Turn it off when:</strong> the repository has no submodules, or you only need aggregate totals across the whole tree.</div>
4218 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
4219 path = libs/core
4220 url = https://github.com/org/core.git
4221
4222[submodule "libs/ui"]
4223 path = libs/ui
4224 url = https://github.com/org/ui.git</div>
4225 </div>
4226 </div>
4227 </div>
4228
4229 <div class="wizard-actions">
4230 <div class="left"></div>
4231 <div class="right">
4232 <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
4233 </div>
4234 </div>
4235 </div>
4236
4237 <div class="wizard-step" data-step="2">
4238 <div class="section">
4239 <div class="section-kicker">Step 2</div>
4240 <h2>Choose counting behavior</h2>
4241 <p class="card-subtitle counting-intro">These settings decide how mixed code-plus-comment lines and Python docstrings are classified. Pure comment lines, block comments, physical lines, and blank lines are still tracked by supported analyzers even when they do not share a line with executable code. Counting methodology follows IEEE Std 1045-1992 physical SLOC.</p>
4242 <div class="subsection-bar">Primary line classification</div>
4243 <div class="preset-inline-row" style="align-items:start;">
4244 <div class="toggle-card mixed-line-card" style="margin:0;">
4245 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
4246 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
4247 <select id="mixed_line_policy" name="mixed_line_policy">
4248 <option value="code_only">Code only</option>
4249 <option value="code_and_comment">Code and comment</option>
4250 <option value="comment_only">Comment only</option>
4251 <option value="separate_mixed_category">Separate mixed category</option>
4252 </select>
4253 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
4254 </div>
4255 <div class="explainer-card prominent" style="margin:0;">
4256 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
4257 <div class="explainer-body" id="mixed-policy-description"></div>
4258 <div class="code-sample" id="mixed-policy-example"></div>
4259 </div>
4260 </div>
4261 </div>
4262
4263 <div class="subsection-bar">Additional scan rules</div>
4264 <div class="scan-rules-grid">
4265 <div class="preset-inline-row">
4266 <div class="toggle-card" style="margin:0;">
4267 <div class="field-help-title">Generated files</div>
4268 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
4269 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
4270 </div>
4271 <div class="explainer-card prominent" style="margin:0;">
4272 <div class="advanced-rule-description"><strong>Purpose:</strong> Keep generated code and assets out of SLOC totals so counts reflect authored source.<br /><strong>Good default when:</strong> you want implementation-only totals.<br /><strong>Turn it off when:</strong> you intentionally want generated SDKs, compiled templates, or codegen output included.</div>
4273 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
4274# Files matching codegen patterns are excluded:
4275# *.generated.cs *.pb.go *.g.dart</div>
4276 </div>
4277 </div>
4278 <div class="preset-inline-row">
4279 <div class="toggle-card" style="margin:0;">
4280 <div class="field-help-title">Minified files</div>
4281 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
4282 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
4283 </div>
4284 <div class="explainer-card prominent" style="margin:0;">
4285 <div class="advanced-rule-description"><strong>Purpose:</strong> Prevent compressed assets from distorting file and line counts.<br /><strong>Good default when:</strong> your repo includes built JavaScript or bundled web assets.<br /><strong>Turn it off when:</strong> minified files are the actual subject of the review.</div>
4286 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
4287# Heuristic: very long lines + low whitespace ratio
4288# jquery.min.js bundle.min.css → skipped</div>
4289 </div>
4290 </div>
4291 <div class="preset-inline-row">
4292 <div class="toggle-card" style="margin:0;">
4293 <div class="field-help-title">Vendor directories</div>
4294 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
4295 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
4296 </div>
4297 <div class="explainer-card prominent" style="margin:0;">
4298 <div class="advanced-rule-description"><strong>Purpose:</strong> Skip bundled third-party dependencies so totals reflect your first-party code.<br /><strong>Good default when:</strong> you only want authored source in the report.<br /><strong>Turn it off when:</strong> vendored code is part of what you need to measure.</div>
4299 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
4300# Directories named vendor/ node_modules/ third_party/
4301# → entire subtree is excluded from totals</div>
4302 </div>
4303 </div>
4304 <div class="preset-inline-row">
4305 <div class="toggle-card" style="margin:0;">
4306 <div class="field-help-title">Lockfiles and manifests</div>
4307 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
4308 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
4309 </div>
4310 <div class="explainer-card prominent" style="margin:0;">
4311 <div class="advanced-rule-description"><strong>Purpose:</strong> Decide whether package lockfiles and generated manifests belong in the scan scope.<br /><strong>Good default when:</strong> you want implementation-focused totals.<br /><strong>Turn it off when:</strong> your review needs to include dependency metadata or footprint accounting.</div>
4312 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
4313# Files like package-lock.json Cargo.lock yarn.lock
4314# → skipped unless this is enabled</div>
4315 </div>
4316 </div>
4317 <div class="preset-inline-row">
4318 <div class="toggle-card" style="margin:0;">
4319 <div class="field-help-title">Binary handling</div>
4320 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
4321 <select name="binary_file_behavior" id="binary_file_behavior"><option value="skip" selected>Skip binary files</option><option value="fail">Fail on binary files</option></select>
4322 </div>
4323 <div class="explainer-card prominent" style="margin:0;">
4324 <div class="advanced-rule-description"><strong>Purpose:</strong> Control how the scan reacts when binaries are found inside the selected scope.<br /><strong>Good default when:</strong> your repo has images, fonts, or other assets alongside source.<br /><strong>Turn it off when:</strong> you want the run to fail-fast and force cleanup of binary assets in the path.</div>
4325 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
4326# Detected via long lines + low whitespace heuristic
4327# .png .exe .so → skipped silently</div>
4328 </div>
4329 </div>
4330 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
4331 <div class="toggle-card" style="margin:0;">
4332 <div class="field-help-title">Python docstrings</div>
4333 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
4334 <label class="checkbox">
4335 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
4336 <span>Count as comment-style lines</span>
4337 </label>
4338 </div>
4339 <div class="explainer-card prominent" style="margin:0;">
4340 <div class="advanced-rule-description" id="python-docstring-live-help">Enabled: docstrings contribute to comment-style totals. Disable to count only inline comments and explicit comment lines.</div>
4341 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
4342 </div>
4343 </div>
4344 </div>
4345 <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px;">
4346 <div class="always-tracked-tip">
4347 <div class="always-tracked-tip-icon">ℹ</div>
4348 <div class="always-tracked-tip-body">
4349 <div class="field-help-title">Always tracked — not configurable</div>
4350 <h4>Comment and blank-line basics</h4>
4351 <div class="advanced-rule-description">Pure comment lines, multi-line comment blocks, blank lines, and total physical lines are always included by every supported analyzer. The mixed-line policy above only affects lines where executable code and comment text share the same line.</div>
4352 </div>
4353 </div>
4354 <div class="always-tracked-tip">
4355 <div class="always-tracked-tip-icon">→</div>
4356 <div class="always-tracked-tip-body">
4357 <div class="field-help-title">What these settings change</div>
4358 <h4>Lines on the boundary</h4>
4359 <div class="advanced-rule-description">The rules on this page only affect lines that live on the boundary between code and comments. A line like <code style="font-size:12px;">x = 1 # counter</code> is the boundary case — it contains both executable code and inline comment text. Every other category is always counted the same regardless of these settings.</div>
4360 </div>
4361 </div>
4362 </div>
4363
4364 <div class="wizard-actions">
4365 <div class="left">
4366 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
4367 </div>
4368 <div class="right">
4369 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
4370 </div>
4371 </div>
4372 </div>
4373
4374 <div class="wizard-step" data-step="3">
4375 <div class="section">
4376 <div class="section-kicker">Step 3</div>
4377 <h2>Output and report identity</h2>
4378 <p class="card-subtitle step3-subtitle">Choose where generated files should be saved, what the exported report title should be, and which artifact bundle fits your workflow.</p>
4379 <div class="preset-inline-row" style="align-items:start;">
4380 <div class="toggle-card" style="margin:0;">
4381 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
4382 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
4383 <select id="scan_preset">
4384 <option value="balanced">Balanced local scan</option>
4385 <option value="code_focused">Code focused</option>
4386 <option value="comment_audit">Comment audit</option>
4387 <option value="deep_review">Deep review</option>
4388 </select>
4389 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
4390 </div>
4391 <div class="explainer-card">
4392 <div class="field-help-title">Selected scan preset</div>
4393 <div class="explainer-body" id="scan-preset-description"></div>
4394 <div class="preset-summary-row" id="scan-preset-summary"></div>
4395 <div class="code-sample" id="scan-preset-example"></div>
4396 <div class="preset-note" id="scan-preset-note"></div>
4397 </div>
4398 </div>
4399 <hr class="step3-separator" />
4400 <div class="preset-inline-row" style="align-items:start;">
4401 <div class="toggle-card" style="margin:0;">
4402 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
4403 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
4404 <select id="artifact_preset">
4405 <option value="review">Review bundle</option>
4406 <option value="full">Full bundle</option>
4407 <option value="html_only">HTML only</option>
4408 <option value="machine">Machine bundle</option>
4409 </select>
4410 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
4411 </div>
4412 <div class="explainer-card">
4413 <div class="field-help-title">Selected artifact preset</div>
4414 <div class="explainer-body" id="artifact-preset-description"></div>
4415 <div class="preset-summary-row" id="artifact-preset-summary"></div>
4416 <div class="code-sample" id="artifact-preset-example"></div>
4417 </div>
4418 </div>
4419 </div>
4420
4421 <div class="section section-spacer-top">
4422 <div class="output-field-row">
4423 <div class="field">
4424 <label for="output_dir">Output directory</label>
4425 <div class="input-group compact">
4426 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
4427 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
4428 <button type="button" class="mini-button" id="use-default-output">Use default</button>
4429 </div>
4430 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
4431 </div>
4432 <div class="output-field-aside">
4433 <strong>Where reports land</strong>
4434 Each run creates a timestamped subfolder here containing the selected artifacts. This path is separate from the project being scanned and does not affect what files are analyzed.
4435 </div>
4436 </div>
4437 </div>
4438
4439 <div class="section section-spacer-top">
4440 <div class="output-field-row">
4441 <div class="field">
4442 <label for="report_title">Report title</label>
4443 <input id="report_title" name="report_title" type="text" value="samples/basic" placeholder="Project report title" />
4444 <div class="hint">Appears in HTML and PDF output headers.</div>
4445 </div>
4446 <div class="output-field-aside">
4447 <strong>Shown in exported artifacts</strong>
4448 This title is embedded in the HTML and PDF reports and stays visible in the workbench header while you configure the run. It defaults to the last folder name of the selected project path.
4449 </div>
4450 </div>
4451 </div>
4452
4453 <div class="section">
4454 <div class="section-kicker">Artifacts</div>
4455 <div class="artifact-grid">
4456 <div class="artifact-card selected" data-artifact="html">
4457 <div class="marker">✓</div>
4458 <div class="artifact-icon">H</div>
4459 <h4>HTML report</h4>
4460 <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
4461 <div class="artifact-tags">
4462 <span class="soft-chip">Best for visual review</span>
4463 <span class="soft-chip">Embeddable preview</span>
4464 </div>
4465 <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
4466 </div>
4467 <div class="artifact-card selected" data-artifact="pdf">
4468 <div class="marker">✓</div>
4469 <div class="artifact-icon">P</div>
4470 <h4>PDF export</h4>
4471 <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
4472 <div class="artifact-tags">
4473 <span class="soft-chip">Portable snapshot</span>
4474 <span class="soft-chip">Good for handoff</span>
4475 </div>
4476 <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
4477 </div>
4478 <div class="artifact-card selected" data-artifact="json" style="opacity:0.75;pointer-events:none;">
4479 <div class="marker" style="background:var(--oxide);border-color:var(--oxide);color:#fff;">✓</div>
4480 <div class="artifact-icon">J</div>
4481 <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--oxide-2);">Always on</span></h4>
4482 <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
4483 <div class="artifact-tags">
4484 <span class="soft-chip">Required for compare</span>
4485 <span class="soft-chip">Auto-enabled</span>
4486 </div>
4487 <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
4488 </div>
4489 </div>
4490 <div class="hint" style="margin-top:16px;">Artifact cards are selectable. Presets above can also toggle them for common workflows.</div>
4491 </div>
4492
4493 <div class="wizard-actions">
4494 <div class="left">
4495 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
4496 </div>
4497 <div class="right">
4498 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
4499 </div>
4500 </div>
4501 </div>
4502
4503 <div class="wizard-step" data-step="4">
4504 <div class="section">
4505 <div class="section-kicker">Step 4</div>
4506 <h2>Review selections and run</h2>
4507 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
4508 <div class="review-grid">
4509 <div class="review-card highlight">
4510 <div class="review-card-head"><h4>What will be scanned</h4><button type="button" class="review-link jump-step" data-step-target="1">Edit step 1</button></div>
4511 <ul id="review-scan-summary"></ul>
4512 </div>
4513 <div class="review-card highlight">
4514 <div class="review-card-head"><h4>How it will be counted</h4><button type="button" class="review-link jump-step" data-step-target="2">Edit step 2</button></div>
4515 <ul id="review-count-summary"></ul>
4516 </div>
4517 <div class="review-card">
4518 <div class="review-card-head"><h4>Output & artifacts</h4><button type="button" class="review-link jump-step" data-step-target="3">Edit step 3</button></div>
4519 <ul id="review-artifact-summary"></ul>
4520 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
4521 </div>
4522 <div class="review-card">
4523 <div class="review-card-head"><h4>Scope preview snapshot</h4><button type="button" class="review-link jump-step" data-step-target="1">Review scope</button></div>
4524 <ul id="review-preview-summary"></ul>
4525 </div>
4526 </div>
4527 </div>
4528
4529 <div class="wizard-actions">
4530 <div class="left">
4531 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
4532 </div>
4533 <div class="right">
4534 <button type="submit" id="submit-button" class="primary">Run analysis</button>
4535 </div>
4536 </div>
4537 </div></form>
4538 </div>
4539 </section>
4540 </div>
4541 </div>
4542
4543 <script>
4544 (function () {
4545 var form = document.getElementById("analyze-form");
4546 var loading = document.getElementById("loading");
4547 var submitButton = document.getElementById("submit-button");
4548 var pathInput = document.getElementById("path");
4549 var outputDirInput = document.getElementById("output_dir");
4550 var reportTitleInput = document.getElementById("report_title");
4551 var previewPanel = document.getElementById("preview-panel");
4552 var refreshButton = document.getElementById("refresh-preview");
4553 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
4554 var useSamplePath = document.getElementById("use-sample-path");
4555 var useDefaultOutput = document.getElementById("use-default-output");
4556 var browsePath = document.getElementById("browse-path");
4557 var browseOutputDir = document.getElementById("browse-output-dir");
4558 var themeToggle = document.getElementById("theme-toggle");
4559 var mixedLinePolicy = document.getElementById("mixed_line_policy");
4560 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
4561 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
4562 var scanPreset = document.getElementById("scan_preset");
4563 var artifactPreset = document.getElementById("artifact_preset");
4564 var includeGlobsInput = document.getElementById("include_globs");
4565 var excludeGlobsInput = document.getElementById("exclude_globs");
4566 var liveReportTitle = document.getElementById("live-report-title");
4567 var navProjectPill = document.getElementById("nav-project-pill");
4568 var navProjectTitle = document.getElementById("nav-project-title");
4569 var reportTitlePreview = null;
4570 var wizardProgressFill = document.getElementById("wizard-progress-fill");
4571 var wizardProgressValue = document.getElementById("wizard-progress-value");
4572 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
4573 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
4574 var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
4575 var reportTitleTouched = false;
4576 var currentStep = 1;
4577 var previewTimer = null;
4578 var quickScanBtn = document.getElementById("quick-scan-btn");
4579
4580 if (quickScanBtn) {
4581 quickScanBtn.addEventListener("click", function () {
4582 var pathVal = pathInput ? pathInput.value.trim() : "";
4583 if (!pathVal) {
4584 alert("Please enter or browse to a project path first.");
4585 return;
4586 }
4587 quickScanBtn.disabled = true;
4588 quickScanBtn.textContent = "Scanning...";
4589 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
4590 if (loading) loading.classList.add("active");
4591 if (form) form.submit();
4592 });
4593 }
4594
4595 var mixedPolicyInfo = {
4596 code_only: {
4597 description: "Treat a line that contains both executable code and an inline comment as a code line only. This is the simplest and most common default when you want line counts to emphasize executable logic.",
4598 example: 'Example line:\n\nx = 1 # initialize counter\n\nResult:\n- counts as code\n- does not add to comment totals\n- useful for compact implementation-focused reports'
4599 },
4600 code_and_comment: {
4601 description: "Count mixed lines in both buckets. This is useful when you want the report to reflect that a single line contributes executable logic and reviewer-facing commentary at the same time.",
4602 example: 'Example line:\n\nx = 1 # initialize counter\n\nResult:\n- counts as code\n- also counts as comment\n- useful when documentation density matters'
4603 },
4604 comment_only: {
4605 description: "Treat mixed lines as comment lines only. This is unusual, but can be useful when auditing how much annotation or commentary exists inline, especially in heavily documented scripts.",
4606 example: 'Example line:\n\nx = 1 # initialize counter\n\nResult:\n- does not add to code totals\n- counts as comment\n- useful for specialized comment-centric audits'
4607 },
4608 separate_mixed_category: {
4609 description: "Place mixed lines into their own bucket so they are not hidden inside pure code or pure comment totals. This gives you the most explicit view of how much code and commentary are co-located on one line.",
4610 example: 'Example line:\n\nx = 1 # initialize counter\n\nResult:\n- goes into a separate mixed-line bucket\n- keeps pure code and pure comment counts cleaner\n- useful for deeper review and comparison'
4611 }
4612 };
4613
4614 var scanPresetInfo = {
4615 balanced: {
4616 description: "Balanced local scan is the default starting point for most repositories. It keeps scope guards enabled, counts mixed lines conservatively, and gives you a practical everyday review setup.",
4617 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
4618 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
4619 note: "Best when you want a stable local overview before making deeper adjustments.",
4620 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
4621 },
4622 code_focused: {
4623 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
4624 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
4625 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
4626 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
4627 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
4628 },
4629 comment_audit: {
4630 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
4631 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
4632 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
4633 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
4634 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
4635 },
4636 deep_review: {
4637 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
4638 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
4639 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
4640 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
4641 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
4642 }
4643 };
4644
4645 var artifactPresetInfo = {
4646 review: {
4647 description: "Review bundle enables HTML and PDF so you can inspect the result in-browser and still save a portable snapshot for sharing or archiving.",
4648 chips: ["HTML", "PDF"],
4649 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
4650 },
4651 full: {
4652 description: "Full bundle enables HTML, PDF, and JSON. It is the best choice when you want both human-readable outputs and a machine-friendly artifact for later processing.",
4653 chips: ["HTML", "PDF", "JSON"],
4654 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
4655 },
4656 html_only: {
4657 description: "HTML only keeps the run lightweight and browser-first. It is ideal for quick local inspection when you do not need a fixed snapshot or automation output.",
4658 chips: ["HTML only", "Fast local review"],
4659 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
4660 },
4661 machine: {
4662 description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
4663 chips: ["HTML", "JSON"],
4664 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
4665 }
4666 };
4667
4668 function applyTheme(theme) {
4669 if (theme === "dark") document.body.classList.add("dark-theme");
4670 else document.body.classList.remove("dark-theme");
4671 }
4672
4673 function loadSavedTheme() {
4674 var saved = null;
4675 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
4676 applyTheme(saved === "dark" ? "dark" : "light");
4677 }
4678
4679 function updateScrollProgress() {
4680 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
4681 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
4682 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
4683 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
4684 var step = Math.min(Math.max(currentStep, 1), 4);
4685 var base = stepBase[step];
4686 var end = stepEnd[step];
4687
4688 var scrollFrac = 0;
4689 var activePanel = document.querySelector(".wizard-step.active");
4690 if (activePanel) {
4691 var scrollTop = window.scrollY || window.pageYOffset || 0;
4692 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
4693 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
4694 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
4695 var scrolled = scrollTop + viewH - panelTop;
4696 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
4697 }
4698
4699 var percent = Math.round(base + (end - base) * scrollFrac);
4700 percent = Math.min(end, Math.max(base, percent));
4701 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
4702 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
4703 }
4704
4705 function updateWizardProgress() {
4706 updateScrollProgress();
4707 }
4708
4709 var stepDescriptions = [
4710 "Choose a project folder, apply scope filters, and preview which files will be counted.",
4711 "Configure how mixed code-plus-comment lines and docstrings are classified.",
4712 "Pick your output formats, scan preset, and where reports are saved.",
4713 "Review all settings and launch the analysis."
4714 ];
4715
4716 function updateStepNav(step) {
4717 var infoLabel = document.getElementById("step-nav-info-label");
4718 var infoDesc = document.getElementById("step-nav-info-desc");
4719 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
4720 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
4721
4722 var summary = document.getElementById("step-nav-summary");
4723 if (summary) summary.style.display = step > 1 ? "" : "none";
4724
4725 var snavPath = document.getElementById("snav-path");
4726 var snavOutput = document.getElementById("snav-output");
4727 var snavTitle = document.getElementById("snav-title");
4728 var pv = pathInput ? pathInput.value.trim() : "";
4729 var ov = outputDirInput ? outputDirInput.value.trim() : "";
4730 var tv = reportTitleInput ? reportTitleInput.value.trim() : "";
4731 if (snavPath) snavPath.textContent = pv || "—";
4732 if (snavOutput) snavOutput.textContent = ov || "auto";
4733 if (snavTitle) snavTitle.textContent = tv || "—";
4734 }
4735
4736 function setStep(step, pushHistory) {
4737 currentStep = step;
4738 stepPanels.forEach(function (panel) {
4739 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
4740 });
4741 stepButtons.forEach(function (button) {
4742 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
4743 });
4744 updateWizardProgress();
4745 updateStepNav(step);
4746
4747 if (pushHistory !== false) {
4748 try {
4749 history.pushState({ wizardStep: step }, "", "#step" + step);
4750 } catch (e) {}
4751 }
4752
4753 var wizardTop =
4754 document.querySelector(".page-shell") ||
4755 document.querySelector(".page") ||
4756 document.querySelector(".card") ||
4757 document.body;
4758
4759 var top = 0;
4760 try {
4761 top = Math.max(0, wizardTop.getBoundingClientRect().top + window.scrollY - 16);
4762 } catch (e) {
4763 top = 0;
4764 }
4765
4766 window.scrollTo({ top: top, behavior: "smooth" });
4767 }
4768
4769 window.addEventListener("popstate", function (e) {
4770 if (e.state && e.state.wizardStep) {
4771 setStep(e.state.wizardStep, false);
4772 } else {
4773 var hashMatch = location.hash.match(/^#step([1-4])$/);
4774 if (hashMatch) setStep(Number(hashMatch[1]), false);
4775 }
4776 });
4777
4778 function inferTitleFromPath(value) {
4779 if (!value) return "project";
4780 var cleaned = value.replace(/[\/\\]+$/, "");
4781 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
4782 return parts.length ? parts[parts.length - 1] : value;
4783 }
4784
4785 function updateReportTitleFromPath() {
4786 var inferred = inferTitleFromPath(pathInput.value || "samples/basic");
4787 if (!reportTitleTouched) {
4788 reportTitleInput.value = inferred;
4789 }
4790 var title = reportTitleInput.value || inferred;
4791 if (liveReportTitle) liveReportTitle.textContent = title;
4792 if (reportTitlePreview) reportTitlePreview.textContent = title;
4793 document.title = "OxideSLOC | " + title;
4794
4795 var projectPath = (pathInput.value || "").trim();
4796 if (navProjectPill && navProjectTitle) {
4797 if (projectPath.length > 0) {
4798 navProjectTitle.textContent = inferred;
4799 navProjectPill.classList.add("visible");
4800 } else {
4801 navProjectTitle.textContent = "";
4802 navProjectPill.classList.remove("visible");
4803 }
4804 }
4805 }
4806
4807 function updateMixedPolicyUI() {
4808 var key = mixedLinePolicy.value || "code_only";
4809 var info = mixedPolicyInfo[key];
4810 document.getElementById("mixed-policy-description").textContent = info.description;
4811 document.getElementById("mixed-policy-example").textContent = info.example;
4812 }
4813
4814 function updatePythonDocstringUI() {
4815 var checked = !!pythonDocstrings.checked;
4816 document.getElementById("python-docstring-example").textContent = checked
4817 ? 'def greet():\n """Greet the user.""" ← comment\n print("hi")'
4818 : 'def greet():\n """Greet the user.""" ← not counted\n print("hi")';
4819 document.getElementById("python-docstring-live-help").textContent = checked
4820 ? "Enabled: docstrings contribute to comment-style totals."
4821 : "Disabled: docstrings are not counted as comment content.";
4822 }
4823
4824 function renderPresetChips(targetId, chips) {
4825 var target = document.getElementById(targetId);
4826 if (!target) return;
4827 target.innerHTML = (chips || []).map(function (chip) {
4828 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
4829 }).join('');
4830 }
4831
4832 function updatePresetDescriptions() {
4833 var scanInfo = scanPresetInfo[scanPreset.value];
4834 var artifactInfo = artifactPresetInfo[artifactPreset.value];
4835 document.getElementById("scan-preset-description").textContent = scanInfo.description;
4836 document.getElementById("scan-preset-example").textContent = scanInfo.example;
4837 document.getElementById("scan-preset-note").textContent = scanInfo.note;
4838 document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
4839 document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
4840 renderPresetChips("scan-preset-summary", scanInfo.chips);
4841 renderPresetChips("artifact-preset-summary", artifactInfo.chips);
4842 }
4843
4844 function applyScanPreset() {
4845 var info = scanPresetInfo[scanPreset.value];
4846 if (!info || !info.apply) return;
4847 mixedLinePolicy.value = info.apply.mixed;
4848 pythonDocstrings.checked = !!info.apply.docstrings;
4849 document.getElementById("generated_file_detection").value = info.apply.generated;
4850 document.getElementById("minified_file_detection").value = info.apply.minified;
4851 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
4852 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
4853 document.getElementById("binary_file_behavior").value = info.apply.binary;
4854 updateMixedPolicyUI();
4855 updatePythonDocstringUI();
4856 }
4857
4858 function applyArtifactPreset() {
4859 var enabled = { html: false, pdf: false, json: false };
4860 if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
4861 if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; enabled.json = true; }
4862 if (artifactPreset.value === "html_only") { enabled.html = true; }
4863 if (artifactPreset.value === "machine") { enabled.json = true; enabled.html = true; }
4864
4865 artifactCards.forEach(function (card) {
4866 var artifact = card.getAttribute("data-artifact");
4867 var checked = !!enabled[artifact];
4868 var checkbox = card.querySelector(".artifact-checkbox");
4869 checkbox.checked = checked;
4870 card.classList.toggle("selected", checked);
4871 });
4872 }
4873
4874 function toggleArtifactCard(card) {
4875 var checkbox = card.querySelector(".artifact-checkbox");
4876 checkbox.checked = !checkbox.checked;
4877 card.classList.toggle("selected", checkbox.checked);
4878 }
4879
4880 function updateReview() {
4881 var scanSummary = document.getElementById("review-scan-summary");
4882 var countSummary = document.getElementById("review-count-summary");
4883 var artifactSummary = document.getElementById("review-artifact-summary");
4884 var outputSummary = document.getElementById("review-output-summary");
4885 var previewSummary = document.getElementById("review-preview-summary");
4886 var readinessSummary = document.getElementById("review-readiness-summary");
4887 var includeText = document.getElementById("include_globs").value.trim();
4888 var excludeText = document.getElementById("exclude_globs").value.trim();
4889 var sidePathPreview = document.getElementById("side-path-preview");
4890 var sideOutputPreview = document.getElementById("side-output-preview");
4891 var sideTitlePreview = document.getElementById("side-title-preview");
4892
4893 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "samples/basic"; }
4894 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
4895 if (sideTitlePreview) {
4896 var rt = document.getElementById("report_title");
4897 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
4898 }
4899
4900 scanSummary.innerHTML = ""
4901 + "<li>Path: " + escapeHtml(pathInput.value || "samples/basic") + "</li>"
4902 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
4903 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
4904
4905 countSummary.innerHTML = ""
4906 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
4907 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
4908 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
4909 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
4910 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
4911 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
4912 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
4913 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
4914
4915 var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.querySelector("h4").textContent; });
4916 artifactSummary.innerHTML = ""
4917 + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
4918 + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
4919
4920 outputSummary.innerHTML = ""
4921 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
4922 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value || "samples/basic")) + "</li>";
4923
4924 if (previewSummary) {
4925 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
4926 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
4927 var statMap = {};
4928 statButtons.forEach(function (button) {
4929 var valueNode = button.querySelector('.scope-stat-value');
4930 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
4931 });
4932 previewSummary.innerHTML = ''
4933 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
4934 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
4935 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
4936 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
4937 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
4938 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
4939
4940 if (readinessSummary) {
4941 var selectedArtifactsCount = selectedArtifacts.length;
4942 readinessSummary.innerHTML = ''
4943 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
4944 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
4945 + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
4946 + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
4947 }
4948 }
4949 }
4950
4951 function escapeHtml(value) {
4952 return String(value)
4953 .replace(/&/g, "&")
4954 .replace(/</g, "<")
4955 .replace(/>/g, ">")
4956 .replace(/"/g, """)
4957 .replace(/'/g, "'");
4958 }
4959
4960 function isPythonVisible() {
4961 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
4962 }
4963
4964 function syncPythonVisibility() {
4965 var html = previewPanel.textContent || "";
4966 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
4967 pythonWraps.forEach(function (node) {
4968 node.classList.toggle("hidden", !hasPython);
4969 });
4970 }
4971
4972 function attachPreviewInteractions() {
4973 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
4974 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
4975 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
4976 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
4977 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
4978 var searchInput = previewPanel.querySelector("#explorer-search");
4979 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
4980 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
4981 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
4982 var activeFilter = "all";
4983 var activeLanguage = "";
4984 var searchTerm = "";
4985 var currentSortKey = null;
4986 var currentSortOrder = "asc";
4987 var childRows = {};
4988
4989 rows.forEach(function (row) {
4990 var parentId = row.getAttribute("data-parent-id") || "";
4991 var rowId = row.getAttribute("data-row-id") || "";
4992 if (!childRows[parentId]) childRows[parentId] = [];
4993 childRows[parentId].push(rowId);
4994 });
4995
4996 function rowById(id) {
4997 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
4998 }
4999
5000 function hasCollapsedAncestor(row) {
5001 var parentId = row.getAttribute("data-parent-id");
5002 while (parentId) {
5003 var parent = rowById(parentId);
5004 if (!parent) break;
5005 if (parent.getAttribute("data-expanded") === "false") return true;
5006 parentId = parent.getAttribute("data-parent-id");
5007 }
5008 return false;
5009 }
5010
5011 function updateToggleGlyph(row) {
5012 var toggle = row.querySelector(".tree-toggle");
5013 if (!toggle) return;
5014 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
5015 }
5016
5017 function rowSortValue(row, key) {
5018 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
5019 }
5020
5021 function updateSortButtons() {
5022 sortButtons.forEach(function (button) {
5023 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
5024 var indicator = button.querySelector(".tree-sort-indicator");
5025 button.classList.toggle("active", isActive);
5026 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
5027 if (indicator) {
5028 indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
5029 }
5030 });
5031 }
5032
5033 function sortSiblingRows() {
5034 if (!treeContainer) {
5035 updateSortButtons();
5036 return;
5037 }
5038
5039 var rowMap = {};
5040 var childrenMap = {};
5041 rows.forEach(function (row) {
5042 var rowId = row.getAttribute("data-row-id");
5043 var parentId = row.getAttribute("data-parent-id") || "";
5044 rowMap[rowId] = row;
5045 if (!childrenMap[parentId]) childrenMap[parentId] = [];
5046 childrenMap[parentId].push(rowId);
5047 });
5048
5049 Object.keys(childrenMap).forEach(function (parentId) {
5050 if (!parentId) return;
5051 childrenMap[parentId].sort(function (a, b) {
5052 var rowA = rowMap[a];
5053 var rowB = rowMap[b];
5054 if (!currentSortKey) {
5055 return Number(a) - Number(b);
5056 }
5057 var valueA = rowSortValue(rowA, currentSortKey);
5058 var valueB = rowSortValue(rowB, currentSortKey);
5059 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
5060 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
5061 var fallbackA = rowSortValue(rowA, "name");
5062 var fallbackB = rowSortValue(rowB, "name");
5063 if (fallbackA < fallbackB) return -1;
5064 if (fallbackA > fallbackB) return 1;
5065 return Number(a) - Number(b);
5066 });
5067 });
5068
5069 var orderedIds = [];
5070 function pushChildren(parentId) {
5071 (childrenMap[parentId] || []).forEach(function (childId) {
5072 orderedIds.push(childId);
5073 pushChildren(childId);
5074 });
5075 }
5076
5077 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
5078 orderedIds.push(topId);
5079 pushChildren(topId);
5080 });
5081
5082 orderedIds.forEach(function (id) {
5083 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
5084 });
5085 updateSortButtons();
5086 }
5087
5088 function updateLanguageButtons() {
5089 languageButtons.forEach(function (button) {
5090 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
5091 var isActive = languageValue === activeLanguage;
5092 button.classList.toggle("active", isActive);
5093 });
5094 }
5095
5096 function rowSelfMatches(row) {
5097 var kind = row.getAttribute("data-kind");
5098 var status = row.getAttribute("data-status");
5099 var language = (row.getAttribute("data-language") || "").toLowerCase();
5100 var name = row.getAttribute("data-name-lower") || "";
5101 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
5102 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
5103 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
5104 var passesLanguage = !activeLanguage || language === activeLanguage;
5105 return passesFilter && passesSearch && passesLanguage;
5106 }
5107
5108 function hasMatchingDescendant(rowId) {
5109 return (childRows[rowId] || []).some(function (childId) {
5110 var childRow = rowById(childId);
5111 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
5112 });
5113 }
5114
5115 function rowMatches(row) {
5116 if (rowSelfMatches(row)) return true;
5117 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
5118 }
5119
5120 function resetViewState() {
5121 activeFilter = "all";
5122 activeLanguage = "";
5123 searchTerm = "";
5124 currentSortKey = null;
5125 currentSortOrder = "asc";
5126 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
5127 if (searchInput) searchInput.value = "";
5128 if (filterSelect) filterSelect.value = "all";
5129 updateLanguageButtons();
5130 }
5131
5132 function applyVisibility() {
5133 rows.forEach(function (row) {
5134 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
5135 row.classList.toggle("hidden-by-filter", !visible);
5136 row.style.display = visible ? "grid" : "none";
5137 });
5138 buttons.forEach(function (button) {
5139 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
5140 });
5141 if (filterSelect) filterSelect.value = activeFilter;
5142 }
5143
5144 buttons.forEach(function (button) {
5145 button.addEventListener("click", function () {
5146 var filterValue = button.getAttribute("data-filter") || "all";
5147 if (filterValue === "reset-view") {
5148 resetViewState();
5149 sortSiblingRows();
5150 applyVisibility();
5151 return;
5152 }
5153 activeFilter = filterValue;
5154 applyVisibility();
5155 });
5156 });
5157
5158 rows.forEach(function (row) {
5159 updateToggleGlyph(row);
5160 var toggle = row.querySelector(".tree-toggle");
5161 if (toggle) {
5162 toggle.addEventListener("click", function () {
5163 var expanded = row.getAttribute("data-expanded") !== "false";
5164 row.setAttribute("data-expanded", expanded ? "false" : "true");
5165 updateToggleGlyph(row);
5166 applyVisibility();
5167 });
5168 }
5169 });
5170
5171 actionButtons.forEach(function (button) {
5172 button.addEventListener("click", function () {
5173 var action = button.getAttribute("data-explorer-action");
5174 if (action === "expand-all") {
5175 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
5176 } else if (action === "collapse-all") {
5177 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
5178 } else if (action === "clear-filters") {
5179 resetViewState();
5180 }
5181 sortSiblingRows();
5182 applyVisibility();
5183 });
5184 });
5185
5186 if (filterSelect) {
5187 filterSelect.addEventListener("change", function () {
5188 activeFilter = filterSelect.value || "all";
5189 applyVisibility();
5190 });
5191 }
5192
5193 languageButtons.forEach(function (button) {
5194 button.addEventListener("click", function () {
5195 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
5196 updateLanguageButtons();
5197 applyVisibility();
5198 });
5199 });
5200
5201 sortButtons.forEach(function (button) {
5202 button.addEventListener("click", function () {
5203 var sortKey = button.getAttribute("data-sort-key");
5204 if (currentSortKey === sortKey) {
5205 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
5206 } else {
5207 currentSortKey = sortKey;
5208 currentSortOrder = "asc";
5209 }
5210 sortSiblingRows();
5211 applyVisibility();
5212 });
5213 });
5214
5215 if (searchInput) {
5216 searchInput.addEventListener("input", function () {
5217 searchTerm = searchInput.value.trim().toLowerCase();
5218 applyVisibility();
5219 });
5220 }
5221
5222 updateLanguageButtons();
5223 sortSiblingRows();
5224 applyVisibility();
5225 }
5226
5227 function loadPreview() {
5228 if (!previewPanel || !pathInput) return;
5229 var path = pathInput.value || "samples/basic";
5230 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
5231 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
5232 previewPanel.innerHTML = '<div class="preview-error">Refreshing preview...</div>';
5233 var previewUrl = "/preview?path=" + encodeURIComponent(path)
5234 + "&include_globs=" + encodeURIComponent(includeValue)
5235 + "&exclude_globs=" + encodeURIComponent(excludeValue);
5236 fetch(previewUrl)
5237 .then(function (response) { return response.text(); })
5238 .then(function (html) {
5239 previewPanel.innerHTML = html;
5240 attachPreviewInteractions();
5241 syncPythonVisibility();
5242 updateReview();
5243 setTimeout(collapseLanguagePills, 50);
5244 })
5245 .catch(function (err) {
5246 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
5247 });
5248 }
5249
5250 function pickDirectory(targetInput, kind) {
5251 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
5252 if (browseButton) browseButton.disabled = true;
5253
5254 if (previewPanel && targetInput === pathInput) {
5255 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
5256 }
5257
5258 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
5259 .then(function (response) { return response.json(); })
5260 .then(function (data) {
5261 if (data && data.selected_path) {
5262 targetInput.value = data.selected_path;
5263
5264 if (targetInput === pathInput) {
5265 updateReportTitleFromPath();
5266 autoSetOutputDir(data.selected_path);
5267 fetchProjectHistory(data.selected_path);
5268 loadPreview();
5269 }
5270
5271 updateReview();
5272 } else if (targetInput === pathInput) {
5273 // Cancelled — keep existing value and refresh preview with current path
5274 loadPreview();
5275 }
5276 })
5277 .catch(function () {
5278 window.alert("Directory picker request failed.");
5279 if (previewPanel && targetInput === pathInput) {
5280 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
5281 }
5282 })
5283 .finally(function () {
5284 if (browseButton) browseButton.disabled = false;
5285 });
5286 }
5287
5288 if (themeToggle) {
5289 themeToggle.addEventListener("click", function () {
5290 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
5291 applyTheme(nextTheme);
5292 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
5293 });
5294 }
5295
5296 stepButtons.forEach(function (button) {
5297 button.addEventListener("click", function () {
5298 setStep(Number(button.getAttribute("data-step-target")));
5299 });
5300 });
5301
5302 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
5303 button.addEventListener("click", function () {
5304 setStep(Number(button.getAttribute("data-step-target")) || 1);
5305 });
5306 });
5307
5308 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
5309 button.addEventListener("click", function () {
5310 updateReview();
5311 setStep(Number(button.getAttribute("data-next")));
5312 });
5313 });
5314
5315 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
5316 button.addEventListener("click", function () {
5317 setStep(Number(button.getAttribute("data-prev")));
5318 });
5319 });
5320
5321 if (useSamplePath) {
5322 useSamplePath.addEventListener("click", function () {
5323 pathInput.value = "samples/basic";
5324 updateReportTitleFromPath();
5325 loadPreview();
5326 });
5327 }
5328
5329 if (useDefaultOutput) {
5330 useDefaultOutput.addEventListener("click", function () {
5331 delete outputDirInput.dataset.userEdited;
5332 autoSetOutputDir(pathInput ? pathInput.value : "");
5333 updateReview();
5334 });
5335 }
5336
5337 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
5338 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
5339
5340 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
5341
5342 // ── Language pill overflow: collapse to "+N more" chip ─────────────
5343 function collapseLanguagePills() {
5344 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
5345 rows.forEach(function(row) {
5346 // Remove any previous overflow chip
5347 var prev = row.querySelector('.lang-overflow-chip');
5348 if (prev) prev.remove();
5349 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
5350 pills.forEach(function(p) { p.style.display = ''; });
5351 if (!pills.length) return;
5352
5353 // Measure after restoring all pills
5354 var containerRight = row.getBoundingClientRect().right;
5355 var hidden = [];
5356 for (var i = pills.length - 1; i >= 1; i--) {
5357 var rect = pills[i].getBoundingClientRect();
5358 if (rect.right > containerRight + 2) {
5359 hidden.unshift(pills[i]);
5360 pills[i].style.display = 'none';
5361 } else {
5362 break;
5363 }
5364 }
5365
5366 if (hidden.length) {
5367 var chip = document.createElement('button');
5368 chip.type = 'button';
5369 chip.className = 'language-pill lang-overflow-chip';
5370 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
5371 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
5372 row.appendChild(chip);
5373 }
5374 });
5375 }
5376
5377 // Run after preview loads (preview panel populates language pills)
5378 var _origLoadPreviewCb = window.__previewLoaded;
5379 document.addEventListener('previewLoaded', collapseLanguagePills);
5380 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
5381 setTimeout(collapseLanguagePills, 400);
5382
5383 // ── Project history & output dir auto-set ──────────────────────────
5384 var wsOutputRoot = document.getElementById("ws-output-root");
5385 var wsScanCount = document.getElementById("ws-scan-count");
5386 var wsLastScan = document.getElementById("ws-last-scan");
5387 var historyBadge = document.getElementById("path-history-badge");
5388 var historyTimer = null;
5389
5390 var wsOutputLink = document.getElementById("ws-output-link");
5391 function syncStripOutputRoot() {
5392 var val = outputDirInput ? outputDirInput.value : "";
5393 var display = val || "project/sloc";
5394 if (wsOutputRoot) wsOutputRoot.textContent = display;
5395 if (wsOutputLink) wsOutputLink.dataset.folder = val;
5396 }
5397
5398 function autoSetOutputDir(projectPath) {
5399 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
5400 if (!projectPath || !projectPath.trim()) return;
5401 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
5402 outputDirInput.value = cleaned + "/sloc";
5403 syncStripOutputRoot();
5404 updateReview();
5405 }
5406
5407 var wsBranch = document.getElementById("ws-branch");
5408
5409 function fetchProjectHistory(projectPath) {
5410 if (!projectPath || !projectPath.trim()) {
5411 if (wsScanCount) wsScanCount.textContent = "—";
5412 if (wsLastScan) wsLastScan.textContent = "—";
5413 if (wsBranch) wsBranch.textContent = "—";
5414 if (historyBadge) historyBadge.style.display = "none";
5415 return;
5416 }
5417 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
5418 .then(function (r) { return r.ok ? r.json() : null; })
5419 .then(function (data) {
5420 if (!data) return;
5421 var countStr = data.scan_count > 0
5422 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
5423 : "never";
5424 var tsStr = data.last_scan_timestamp
5425 ? data.last_scan_timestamp.replace(" UTC","")
5426 : "—";
5427 if (wsScanCount) wsScanCount.textContent = countStr;
5428 if (wsLastScan) wsLastScan.textContent = tsStr;
5429 if (wsBranch) wsBranch.textContent = data.last_git_branch || "—";
5430 if (data.scan_count > 0) {
5431 if (historyBadge) {
5432 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
5433 historyBadge.textContent = data.scan_count + " previous scan" +
5434 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
5435 "Last: " + (data.last_scan_timestamp || "—") +
5436 " — " + (data.last_scan_code_lines ? Number(data.last_scan_code_lines).toLocaleString() : "?") + " code lines.";
5437 historyBadge.className = "path-history-badge found";
5438 historyBadge.style.display = "";
5439 }
5440 } else {
5441 if (historyBadge) historyBadge.style.display = "none";
5442 }
5443 })
5444 .catch(function () {});
5445 }
5446
5447 function onPathChange() {
5448 var val = pathInput ? pathInput.value : "";
5449 updateReportTitleFromPath();
5450 autoSetOutputDir(val);
5451 clearTimeout(historyTimer);
5452 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
5453 if (previewTimer) clearTimeout(previewTimer);
5454 previewTimer = setTimeout(loadPreview, 280);
5455 }
5456
5457 if (pathInput) {
5458 pathInput.addEventListener("input", onPathChange);
5459 }
5460
5461 if (outputDirInput) {
5462 outputDirInput.addEventListener("input", function () {
5463 outputDirInput.dataset.userEdited = "1";
5464 syncStripOutputRoot();
5465 updateReview();
5466 });
5467 }
5468
5469 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
5470 if (!node) return;
5471 node.addEventListener("input", function () {
5472 updateReview();
5473 if (previewTimer) clearTimeout(previewTimer);
5474 previewTimer = setTimeout(loadPreview, 280);
5475 });
5476 });
5477
5478 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
5479 var node = document.getElementById(id);
5480 if (node) node.addEventListener("change", updateReview);
5481 });
5482
5483 if (reportTitleInput) {
5484 reportTitleInput.addEventListener("input", function () {
5485 reportTitleTouched = reportTitleInput.value.trim().length > 0;
5486 updateReportTitleFromPath();
5487 updateReview();
5488 });
5489 }
5490
5491 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
5492 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
5493 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); });
5494 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); });
5495
5496 artifactCards.forEach(function (card) {
5497 card.addEventListener("click", function () {
5498 toggleArtifactCard(card);
5499 updateReview();
5500 });
5501 });
5502
5503 if (form && loading && submitButton) {
5504 form.addEventListener("submit", function () {
5505 submitButton.disabled = true;
5506 submitButton.textContent = "Scanning...";
5507 loading.classList.add("active");
5508 });
5509 }
5510
5511 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
5512 btn.addEventListener('click', function () {
5513 var folder = btn.getAttribute('data-folder') || btn.dataset.folder || '';
5514 if (!folder) return;
5515 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
5516 });
5517 });
5518
5519 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
5520 if (wsOutputLink) {
5521 wsOutputLink.addEventListener('click', function () {
5522 var folder = wsOutputLink.dataset.folder || '';
5523 if (!folder) return;
5524 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
5525 });
5526 }
5527
5528 loadSavedTheme();
5529 updateMixedPolicyUI();
5530 updatePythonDocstringUI();
5531 applyScanPreset();
5532 updatePresetDescriptions();
5533 applyArtifactPreset();
5534 updateReview();
5535 updateScrollProgress(); // initialise bar to 0% (step 1)
5536 window.addEventListener("scroll", updateScrollProgress, { passive: true });
5537 onPathChange(); // seed output dir, history badge, and preview from initial path
5538 loadPreview();
5539 updateStepNav(1);
5540
5541 // Restore step from URL hash on initial load (e.g., back-forward cache)
5542 (function() {
5543 var hashMatch = location.hash.match(/^#step([1-4])$/);
5544 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
5545 })();
5546
5547 (function randomizeWatermarks() {
5548 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
5549 if (!wms.length) return;
5550 var placed = [];
5551 function tooClose(top, left) {
5552 for (var i = 0; i < placed.length; i++) {
5553 var dt = Math.abs(placed[i][0] - top);
5554 var dl = Math.abs(placed[i][1] - left);
5555 if (dt < 16 && dl < 12) return true;
5556 }
5557 return false;
5558 }
5559 function pick(leftBand) {
5560 for (var attempt = 0; attempt < 50; attempt++) {
5561 var top = Math.random() * 88 + 2;
5562 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
5563 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
5564 }
5565 var top = Math.random() * 88 + 2;
5566 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
5567 placed.push([top, left]);
5568 return [top, left];
5569 }
5570 var half = Math.floor(wms.length / 2);
5571 wms.forEach(function (img, i) {
5572 var pos = pick(i < half);
5573 var size = Math.floor(Math.random() * 80 + 110);
5574 var rot = (Math.random() * 360).toFixed(1);
5575 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
5576 img.style.cssText = "width:" + size + "px;top:" + pos[0].toFixed(1) + "%;left:" + pos[1].toFixed(1) + "%;transform:rotate(" + rot + "deg);opacity:" + op + ";";
5577 });
5578 })();
5579
5580 (function spawnCodeParticles() {
5581 var container = document.getElementById('code-particles');
5582 if (!container) return;
5583 var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
5584 for (var i = 0; i < 38; i++) {
5585 (function(idx) {
5586 var el = document.createElement('span');
5587 el.className = 'code-particle';
5588 el.textContent = snippets[idx % snippets.length];
5589 var left = Math.random() * 94 + 2;
5590 var top = Math.random() * 88 + 6;
5591 var dur = (Math.random() * 10 + 9).toFixed(1);
5592 var delay = (Math.random() * 18).toFixed(1);
5593 var rot = (Math.random() * 26 - 13).toFixed(1);
5594 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
5595 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
5596 container.appendChild(el);
5597 })(i);
5598 }
5599 })();
5600 })();
5601 </script>
5602 <script>
5603 (function () {
5604 var raw = {{ prefill_json|safe }};
5605 if (!raw || typeof raw !== 'object' || !raw.path) return;
5606 function setVal(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
5607 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
5608 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
5609 setVal('path-input', raw.path || '');
5610 setVal('include-globs', raw.include_globs || '');
5611 setVal('exclude-globs', raw.exclude_globs || '');
5612 setVal('output-dir', raw.output_dir || '');
5613 setVal('report-title', raw.report_title || '');
5614 if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
5615 setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
5616 setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
5617 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
5618 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
5619 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
5620 if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
5621 setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
5622 setChecked('generate-html', raw.generate_html !== false);
5623 setChecked('generate-pdf', !!raw.generate_pdf);
5624 // Trigger dynamic UI updates after pre-fill.
5625 setTimeout(function () {
5626 var pathEl = document.getElementById('path-input');
5627 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
5628 var policyEl = document.getElementById('mixed-line-policy');
5629 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
5630 }, 80);
5631 })();
5632 </script>
5633 <footer class="site-footer">
5634 oxide-sloc v{{ version }} — local source line analysis workbench ·
5635 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
5636 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
5637 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
5638 </footer>
5639</body>
5640</html>
5641"##,
5642 ext = "html"
5643)]
5644struct IndexTemplate {
5645 version: &'static str,
5646 prefill_json: String,
5647}
5648
5649#[derive(Template)]
5652#[template(
5653 source = r##"
5654<!doctype html>
5655<html lang="en">
5656<head>
5657 <meta charset="utf-8">
5658 <meta name="viewport" content="width=device-width, initial-scale=1">
5659 <title>OxideSLOC — Source Line Analysis Workbench</title>
5660 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
5661 <style>
5662 :root {
5663 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
5664 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
5665 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
5666 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
5667 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
5668 }
5669 body.dark-theme {
5670 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
5671 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
5672 }
5673 *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}
5674 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
5675 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
5676 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
5677 .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
5678 @keyframes floatCode{0%{opacity:0;transform:translateY(0) rotate(var(--rot));}10%{opacity:var(--op);}85%{opacity:var(--op);}100%{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}
5679 .top-nav{position:sticky;top:0;z-index:30;background:linear-gradient(180deg,var(--nav),var(--nav-2));border-bottom:1px solid rgba(255,255,255,0.12);box-shadow:0 4px 14px rgba(0,0,0,0.18);}
5680 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
5681 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;} .brand-logo{width:42px;height:46px;object-fit:contain;flex:0 0 auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}
5682 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
5683 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;} .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;}
5684 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
5685 .nav-pill,.theme-toggle{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}
5686 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
5687 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
5688 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
5689 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
5690 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
5691 .status-dot{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}
5692 .server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}.server-status-tip::before{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{display:block;}
5693 .page{max-width:1100px;margin:0 auto;padding:48px 24px 60px;position:relative;z-index:1;}
5694 .hero{text-align:center;margin-bottom:52px;}
5695 .hero-logo{width:88px;height:97px;object-fit:contain;margin-bottom:20px;filter:drop-shadow(0 8px 22px rgba(184,93,51,0.30));animation:logoBob 3.6s ease-in-out infinite;}
5696 @keyframes logoBob{0%,100%{transform:translateY(0) scale(1);}40%{transform:translateY(-18px) scale(1.07);}60%{transform:translateY(-14px) scale(1.05);}}
5697 .hero-title{font-size:51px;font-weight:900;letter-spacing:-0.04em;margin:0 0 10px;
5698 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
5699 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
5700 animation:titleShimmer 4s linear infinite;}
5701 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
5702 body.dark-theme .hero-title{background:linear-gradient(90deg,#d37a4c 0%,#f0a070 25%,#9bb8ff 50%,#d37a4c 75%,#f0a070 100%);background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
5703 .hero-subtitle{font-size:18px;color:var(--muted);line-height:1.6;max-width:600px;margin:0 auto;animation:fadeSlideUp 0.9s ease both;}
5704 @keyframes fadeSlideUp{from{opacity:0;transform:translateY(18px);}to{opacity:1;transform:translateY(0);}}
5705 .action-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-bottom:32px;}
5706 @media(max-width:760px){.action-grid{grid-template-columns:1fr 1fr;}}
5707 @media(max-width:480px){.action-grid{grid-template-columns:1fr;}}
5708 .action-card{display:flex;flex-direction:column;align-items:flex-start;padding:28px 26px 24px;border-radius:var(--radius);border:1px solid var(--line-strong);background:var(--surface);box-shadow:var(--shadow);text-decoration:none;color:var(--text);transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;animation:cardRise 0.7s ease both;}
5709 .action-card:nth-child(1){animation-delay:0.1s;} .action-card:nth-child(2){animation-delay:0.2s;} .action-card:nth-child(3){animation-delay:0.3s;}
5710 @keyframes cardRise{from{opacity:0;transform:translateY(24px);}to{opacity:1;transform:translateY(0);}}
5711 .action-card:hover{transform:translateY(-6px) scale(1.012);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
5712 .action-card-icon{width:52px;height:52px;border-radius:16px;display:flex;align-items:center;justify-content:center;margin-bottom:18px;flex:0 0 auto;transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
5713 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
5714 .action-card-icon svg{width:26px;height:26px;stroke:currentColor;fill:none;stroke-width:2;}
5715 .action-card.scan .action-card-icon{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 8px 22px rgba(184,80,40,0.30);}
5716 .action-card.view .action-card-icon{background:linear-gradient(135deg,#3b82f6,#1d4ed8);color:#fff;box-shadow:0 8px 22px rgba(59,130,246,0.28);}
5717 .action-card.compare .action-card-icon{background:linear-gradient(135deg,#8b5cf6,#6d28d9);color:#fff;box-shadow:0 8px 22px rgba(139,92,246,0.28);}
5718 .action-card-title{font-size:20px;font-weight:850;letter-spacing:-0.02em;margin:0 0 8px;}
5719 .action-card-desc{font-size:14px;color:var(--muted);line-height:1.6;margin:0 0 20px;flex:1;}
5720 .action-card-cta{display:inline-flex;align-items:center;gap:7px;font-size:13px;font-weight:800;color:var(--oxide-2);transition:gap 0.15s ease;}
5721 body.dark-theme .action-card-cta{color:var(--oxide);}
5722 .action-card.view .action-card-cta{color:var(--accent-2);}
5723 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
5724 .action-card.compare .action-card-cta{color:#7c3aed;}
5725 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
5726 .action-card:hover .action-card-cta{gap:12px;}
5727 .divider{height:1px;background:var(--line);margin:40px 0;}
5728 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:16px;}
5729 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
5730 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
5731 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:18px 20px;text-align:center;position:relative;cursor:default;
5732 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
5733 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
5734 .info-chip-val{font-size:22px;font-weight:900;color:var(--oxide);}
5735 body.dark-theme .info-chip-val{color:var(--oxide);}
5736 .info-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
5737 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
5738 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
5739 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
5740 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
5741 border:6px solid transparent;border-top-color:var(--text);}
5742 .info-chip:hover .info-chip-tip{display:block;}
5743 .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
5744 .site-footer a{color:var(--muted);}
5745 </style>
5746</head>
5747<body>
5748 <div class="background-watermarks" aria-hidden="true">
5749 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5750 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5751 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5752 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5753 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5754 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5755 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5756 </div>
5757 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
5758 <div class="top-nav">
5759 <div class="top-nav-inner">
5760 <a class="brand" href="/">
5761 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
5762 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Source line analysis workbench</div></div>
5763 </a>
5764 <div class="nav-right">
5765 <a class="nav-pill" href="/">Home</a>
5766 <a class="nav-pill" href="/view-reports">View Reports</a>
5767 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
5768 <div class="server-status-wrap">
5769 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
5770 <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
5771 </div>
5772 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
5773 <svg class="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
5774 <svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
5775 </button>
5776 </div>
5777 </div>
5778 </div>
5779
5780 <div class="page">
5781 <div class="hero">
5782 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
5783 <h1 class="hero-title">OxideSLOC</h1>
5784 <p class="hero-subtitle">A fast, self-contained source line analysis workbench. Count code, track history, and compare scan snapshots — no setup required.</p>
5785 </div>
5786
5787 <div class="action-grid">
5788 <a class="action-card scan" href="/scan-setup">
5789 <div class="action-card-icon">
5790 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
5791 </div>
5792 <div class="action-card-title">Scan Project</div>
5793 <p class="action-card-desc">Start a new scan, reload saved settings from a config file, or quickly re-run a recent project with one click.</p>
5794 <span class="action-card-cta">Start scanning <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="9 18 15 12 9 6"></polyline></svg></span>
5795 </a>
5796
5797 <a class="action-card view" href="/view-reports">
5798 <div class="action-card-icon">
5799 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
5800 </div>
5801 <div class="action-card-title">View Reports</div>
5802 <p class="action-card-desc">Browse previously recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
5803 <span class="action-card-cta">Open reports <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="9 18 15 12 9 6"></polyline></svg></span>
5804 </a>
5805
5806 <a class="action-card compare" href="/compare-scans">
5807 <div class="action-card-icon">
5808 <svg viewBox="0 0 24 24"><line x1="18" y1="20" x2="18" y2="10"></line><line x1="12" y1="20" x2="12" y2="4"></line><line x1="6" y1="20" x2="6" y2="14"></line></svg>
5809 </div>
5810 <div class="action-card-title">Compare Scans</div>
5811 <p class="action-card-desc">Pick any two scan builds to see a side-by-side delta — added, removed, and modified files with exact line-count changes.</p>
5812 <span class="action-card-cta">Compare builds <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="9 18 15 12 9 6"></polyline></svg></span>
5813 </a>
5814 </div>
5815
5816 <div class="divider"></div>
5817
5818 <div class="info-strip">
5819 <div class="info-chip">
5820 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
5821 <div class="info-chip-val">41</div>
5822 <div class="info-chip-label">Languages</div>
5823 </div>
5824 <div class="info-chip">
5825 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
5826 <div class="info-chip-val">100%</div>
5827 <div class="info-chip-label">Self-contained</div>
5828 </div>
5829 <div class="info-chip">
5830 <div class="info-chip-tip">Self-contained HTML reports with<br>light/dark theme — share without a server</div>
5831 <div class="info-chip-val">HTML</div>
5832 <div class="info-chip-label">Exportable reports</div>
5833 </div>
5834 <div class="info-chip">
5835 <div class="info-chip-tip">Detects .gitmodules and produces<br>per-submodule breakdowns automatically</div>
5836 <div class="info-chip-val">Git</div>
5837 <div class="info-chip-label">Submodule support</div>
5838 </div>
5839 <div class="info-chip">
5840 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
5841 <div class="info-chip-val">IEEE</div>
5842 <div class="info-chip-label">1045-1992</div>
5843 </div>
5844 </div>
5845 </div>
5846
5847 <footer class="site-footer">
5848 oxide-sloc — local source line analysis workbench ·
5849 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
5850 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
5851 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
5852 </footer>
5853
5854 <script>
5855 (function () {
5856 var storageKey = 'oxide-sloc-theme';
5857 var body = document.body;
5858 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
5859 var toggle = document.getElementById('theme-toggle');
5860 if (toggle) toggle.addEventListener('click', function () {
5861 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
5862 body.classList.toggle('dark-theme', next === 'dark');
5863 try { localStorage.setItem(storageKey, next); } catch(e) {}
5864 });
5865 (function randomizeWatermarks() {
5866 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
5867 if (!wms.length) return;
5868 var placed = [];
5869 function tooClose(top, left) {
5870 for (var i = 0; i < placed.length; i++) {
5871 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
5872 if (dt < 16 && dl < 12) return true;
5873 }
5874 return false;
5875 }
5876 function pick(leftBand) {
5877 for (var attempt = 0; attempt < 50; attempt++) {
5878 var top = Math.random() * 88 + 2;
5879 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
5880 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
5881 }
5882 var top = Math.random() * 88 + 2;
5883 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
5884 placed.push([top, left]); return [top, left];
5885 }
5886 var half = Math.floor(wms.length / 2);
5887 wms.forEach(function (img, i) {
5888 var pos = pick(i < half);
5889 var size = Math.floor(Math.random() * 100 + 120);
5890 var rot = (Math.random() * 360).toFixed(1);
5891 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
5892 img.style.cssText = 'width:' + size + 'px;top:' + pos[0].toFixed(1) + '%;left:' + pos[1].toFixed(1) + '%;transform:rotate(' + rot + 'deg);opacity:' + op + ';';
5893 });
5894 })();
5895
5896 (function spawnCodeParticles() {
5897 var container = document.getElementById('code-particles');
5898 if (!container) return;
5899 var snippets = [
5900 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
5901 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
5902 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
5903 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
5904 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
5905 ];
5906 var count = 38;
5907 for (var i = 0; i < count; i++) {
5908 (function(idx) {
5909 var el = document.createElement('span');
5910 el.className = 'code-particle';
5911 var text = snippets[idx % snippets.length];
5912 el.textContent = text;
5913 var left = Math.random() * 94 + 2;
5914 var top = Math.random() * 88 + 6;
5915 var dur = (Math.random() * 10 + 9).toFixed(1);
5916 var delay = (Math.random() * 18).toFixed(1);
5917 var rot = (Math.random() * 26 - 13).toFixed(1);
5918 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
5919 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;'
5920 + '--rot:' + rot + 'deg;--op:' + op + ';'
5921 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
5922 container.appendChild(el);
5923 })(i);
5924 }
5925 })();
5926 })();
5927 </script>
5928</body>
5929</html>
5930"##,
5931 ext = "html"
5932)]
5933struct SplashTemplate {}
5934
5935#[derive(Template)]
5938#[template(
5939 source = r##"
5940<!doctype html>
5941<html lang="en">
5942<head>
5943 <meta charset="utf-8">
5944 <meta name="viewport" content="width=device-width, initial-scale=1">
5945 <title>OxideSLOC — Start a Scan</title>
5946 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
5947 <style>
5948 :root {
5949 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
5950 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
5951 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
5952 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
5953 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
5954 }
5955 body.dark-theme {
5956 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
5957 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
5958 }
5959 *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}
5960 .top-nav{position:sticky;top:0;z-index:30;background:linear-gradient(180deg,var(--nav),var(--nav-2));border-bottom:1px solid rgba(255,255,255,0.12);box-shadow:0 4px 14px rgba(0,0,0,0.18);}
5961 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
5962 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
5963 .brand-logo{width:42px;height:46px;object-fit:contain;flex:0 0 auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}
5964 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
5965 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
5966 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;}
5967 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
5968 .nav-pill,.theme-toggle{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}
5969 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
5970 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
5971 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
5972 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
5973 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
5974 .page{max-width:960px;margin:0 auto;padding:40px 24px 64px;position:relative;z-index:1;}
5975 .page-header{text-align:center;margin-bottom:32px;}
5976 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
5977 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
5978 .breadcrumb{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted);margin-bottom:28px;}
5979 .breadcrumb a{color:var(--muted);text-decoration:none;} .breadcrumb a:hover{color:var(--oxide);}
5980 .breadcrumb svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2;}
5981 /* Cards */
5982 .option-grid{display:flex;flex-direction:column;gap:16px;}
5983 .option-card{background:var(--surface);border:1.5px solid var(--line-strong);border-radius:var(--radius);padding:22px 26px;box-shadow:var(--shadow);transition:border-color 0.18s ease,box-shadow 0.18s ease;}
5984 .option-card:hover{border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
5985 /* Two-column layout inside each card */
5986 .card-body{display:grid;grid-template-columns:1fr 240px;gap:24px;align-items:center;}
5987 .card-left{display:flex;align-items:flex-start;gap:16px;min-width:0;}
5988 .option-icon{width:46px;height:46px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex:0 0 auto;}
5989 .option-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
5990 .option-icon.new-scan{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 6px 18px rgba(184,80,40,0.28);}
5991 .option-icon.load-config{background:linear-gradient(135deg,#3b82f6,#1d4ed8);color:#fff;box-shadow:0 6px 18px rgba(59,130,246,0.28);}
5992 .option-icon.rescan{background:linear-gradient(135deg,#8b5cf6,#6d28d9);color:#fff;box-shadow:0 6px 18px rgba(139,92,246,0.28);}
5993 .card-text{min-width:0;}
5994 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 4px;}
5995 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
5996 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
5997 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
5998 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
5999 /* Right CTA column */
6000 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
6001 .btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:11px 20px;border-radius:10px;font-size:13px;font-weight:700;text-decoration:none;cursor:pointer;border:none;transition:transform 0.15s ease,box-shadow 0.15s ease;white-space:nowrap;}
6002 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
6003 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
6004 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
6005 body.dark-theme .btn-secondary{color:var(--oxide);}
6006 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
6007 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
6008 /* File input overlay — must be full-width so it aligns with other card-right buttons */
6009 .file-input-wrap{position:relative;width:100%;}
6010 .file-input-wrap .btn{width:100%;}
6011 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
6012 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
6013 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
6014 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
6015 .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
6016 @keyframes floatCode{0%{opacity:0;transform:translateY(0) rotate(var(--rot));}10%{opacity:var(--op);}85%{opacity:var(--op);}100%{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}
6017 /* Recent list (card 3 — full-width section below header) */
6018 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
6019 .recent-list{display:flex;flex-direction:column;gap:8px;}
6020 .recent-item{display:flex;align-items:center;gap:12px;padding:11px 16px;border-radius:10px;border:1px solid var(--line);background:var(--surface-2);cursor:pointer;transition:border-color 0.15s ease,background 0.15s ease;}
6021 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
6022 .recent-item-info{flex:1;min-width:0;}
6023 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
6024 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
6025 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
6026 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
6027 .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
6028 .site-footer a{color:var(--muted);}
6029 @media(max-width:680px){
6030 .card-body{grid-template-columns:1fr;}
6031 .card-right{flex-direction:row;flex-wrap:wrap;}
6032 .btn{flex:1;}
6033 }
6034 </style>
6035</head>
6036<body>
6037 <div class="background-watermarks" aria-hidden="true">
6038 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6039 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6040 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6041 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6042 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6043 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6044 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6045 </div>
6046 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
6047 <div class="top-nav">
6048 <div class="top-nav-inner">
6049 <a class="brand" href="/">
6050 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
6051 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Source line analysis workbench</div></div>
6052 </a>
6053 <div class="nav-right">
6054 <a class="nav-pill" href="/">Home</a>
6055 <a class="nav-pill" href="/view-reports">View Reports</a>
6056 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
6057 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
6058 <svg class="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
6059 <svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
6060 </button>
6061 </div>
6062 </div>
6063 </div>
6064
6065 <div class="page">
6066 <div class="breadcrumb">
6067 <a href="/">Home</a>
6068 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
6069 <span>Scan Setup</span>
6070 </div>
6071
6072 <div class="page-header">
6073 <h1>How would you like to scan?</h1>
6074 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
6075 </div>
6076
6077 <div class="option-grid">
6078
6079 <!-- Option 1: New scan -->
6080 <div class="option-card">
6081 <div class="card-body">
6082 <div class="card-left">
6083 <div class="option-icon new-scan">
6084 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
6085 </div>
6086 <div class="card-text">
6087 <div class="option-title">Start a new scan</div>
6088 <p class="option-desc">Walk through the 4-step guided wizard — pick a project folder, configure counting rules, choose output formats, then review before running.</p>
6089 <ul class="feature-list">
6090 <li>Live project scope preview before you run</li>
6091 <li>4 line-counting modes with interactive examples</li>
6092 <li>HTML, PDF, and JSON output — your choice</li>
6093 <li>IEEE 1045-1992 compliant physical SLOC counting</li>
6094 </ul>
6095 </div>
6096 </div>
6097 <div class="card-right">
6098 <a class="btn btn-primary" href="/scan">
6099 Configure & scan
6100 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
6101 </a>
6102 <p class="card-tip">Full 4-step setup · all options</p>
6103 </div>
6104 </div>
6105 </div>
6106
6107 <!-- Option 2: Load from config file -->
6108 <div class="option-card">
6109 <div class="card-body">
6110 <div class="card-left">
6111 <div class="option-icon load-config">
6112 <svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="12" y1="18" x2="12" y2="12"></line><line x1="9" y1="15" x2="15" y2="15"></line></svg>
6113 </div>
6114 <div class="card-text">
6115 <div class="option-title">Load a saved config</div>
6116 <p class="option-desc">Upload a <strong>scan-config.json</strong> exported from a previous run. The wizard opens pre-filled — you can still tweak anything before running.</p>
6117 <ul class="feature-list">
6118 <li>All 15 settings restored from the file</li>
6119 <li>Fully editable — change path or output dir</li>
6120 <li>Works with any scan-config.json</li>
6121 </ul>
6122 </div>
6123 </div>
6124 <div class="card-right">
6125 <div class="file-input-wrap">
6126 <button class="btn btn-secondary" id="load-config-btn" type="button">
6127 <svg viewBox="0 0 24 24"><polyline points="16 16 12 12 8 16"></polyline><line x1="12" y1="12" x2="12" y2="21"></line><path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"></path></svg>
6128 Choose config file
6129 </button>
6130 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
6131 </div>
6132 <p class="card-tip" id="config-file-name">Exported after every scan</p>
6133 </div>
6134 </div>
6135 </div>
6136
6137 <!-- Option 3: Re-scan recent project -->
6138 <div class="option-card" id="recent-card">
6139 <div class="card-body">
6140 <div class="card-left" style="grid-column:1/-1;">
6141 <div class="option-icon rescan">
6142 <svg viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
6143 </div>
6144 <div class="card-text">
6145 <div class="option-title">Re-scan a recent project</div>
6146 <p class="option-desc">Pick a recent run to instantly restore all its settings in the wizard — path, output folder, filters, and more. Tweak anything before scanning.</p>
6147 <ul class="feature-list">
6148 <li>All 15+ settings restored from the saved config</li>
6149 <li>Path and output dir are editable before running</li>
6150 <li>Only scans with a saved config appear here</li>
6151 </ul>
6152 </div>
6153 </div>
6154 </div>
6155 <div class="section-divider"></div>
6156 <div class="recent-list" id="recent-list">
6157 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
6158 </div>
6159 </div>
6160
6161 </div>
6162 </div>
6163
6164 <footer class="site-footer">
6165 oxide-sloc — local source line analysis workbench ·
6166 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
6167 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
6168 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
6169 </footer>
6170
6171 <script>
6172 (function () {
6173 var storageKey = 'oxide-sloc-theme';
6174 var body = document.body;
6175 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
6176 var toggle = document.getElementById('theme-toggle');
6177 if (toggle) toggle.addEventListener('click', function () {
6178 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
6179 body.classList.toggle('dark-theme', next === 'dark');
6180 try { localStorage.setItem(storageKey, next); } catch(e) {}
6181 });
6182
6183 (function randomizeWatermarks() {
6184 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6185 if (!wms.length) return;
6186 var placed = [];
6187 function tooClose(top, left) { for (var i = 0; i < placed.length; i++) { var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left); if (dt < 16 && dl < 12) return true; } return false; }
6188 function pick(leftBand) { for (var attempt = 0; attempt < 50; attempt++) { var top = Math.random() * 88 + 2; var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74; if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; } } var top = Math.random() * 88 + 2; var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74; placed.push([top, left]); return [top, left]; }
6189 var half = Math.floor(wms.length / 2);
6190 wms.forEach(function (img, i) { var pos = pick(i < half); var size = Math.floor(Math.random() * 100 + 120); var rot = (Math.random() * 360).toFixed(1); var op = (Math.random() * 0.08 + 0.12).toFixed(2); img.style.cssText = 'width:' + size + 'px;top:' + pos[0].toFixed(1) + '%;left:' + pos[1].toFixed(1) + '%;transform:rotate(' + rot + 'deg);opacity:' + op + ';'; });
6191 })();
6192 (function spawnCodeParticles() {
6193 var container = document.getElementById('code-particles');
6194 if (!container) return;
6195 var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
6196 var count = 38;
6197 for (var i = 0; i < count; i++) { (function(idx) { var el = document.createElement('span'); el.className = 'code-particle'; el.textContent = snippets[idx % snippets.length]; var left = Math.random() * 94 + 2; var top = Math.random() * 88 + 6; var dur = (Math.random() * 10 + 9).toFixed(1); var delay = (Math.random() * 18).toFixed(1); var rot = (Math.random() * 26 - 13).toFixed(1); var op = (Math.random() * 0.09 + 0.06).toFixed(3); el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;'; container.appendChild(el); })(i); }
6198 })();
6199
6200 // Recent scans data injected from server
6201 var recentScans = {{ recent_scans_json|safe }};
6202
6203 function configToParams(cfg) {
6204 var p = new URLSearchParams();
6205 p.set('prefilled', '1');
6206 if (cfg.path) p.set('path', cfg.path);
6207 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
6208 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
6209 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
6210 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
6211 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
6212 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
6213 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
6214 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
6215 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
6216 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
6217 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
6218 if (cfg.report_title) p.set('report_title', cfg.report_title);
6219 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
6220 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
6221 return p;
6222 }
6223
6224 // Build recent scan list (capped at 3 visible entries)
6225 var list = document.getElementById('recent-list');
6226 var noNote = document.getElementById('no-recent-note');
6227 var hasAny = false;
6228 var MAX_RECENT = 3;
6229 if (Array.isArray(recentScans)) {
6230 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
6231 var shown = 0;
6232 validEntries.forEach(function (entry) {
6233 if (shown >= MAX_RECENT) return;
6234 shown++;
6235 hasAny = true;
6236 var item = document.createElement('div');
6237 item.className = 'recent-item';
6238 item.title = 'Restore all settings and open wizard';
6239 item.innerHTML =
6240 '<div class="recent-item-info">' +
6241 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
6242 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' · ' + escHtml(entry.timestamp || '') + '</div>' +
6243 '</div>' +
6244 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
6245 item.addEventListener('click', function () {
6246 var params = configToParams(entry.config);
6247 window.location.href = '/scan?' + params.toString();
6248 });
6249 list.appendChild(item);
6250 });
6251 if (validEntries.length > MAX_RECENT) {
6252 var moreEl = document.createElement('div');
6253 moreEl.className = 'recent-more-link';
6254 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
6255 list.appendChild(moreEl);
6256 }
6257 }
6258 if (hasAny && noNote) noNote.style.display = 'none';
6259
6260 // Config file loader
6261 var fileInput = document.getElementById('config-file-input');
6262 var fileName = document.getElementById('config-file-name');
6263 if (fileInput) {
6264 fileInput.addEventListener('change', function () {
6265 var file = fileInput.files && fileInput.files[0];
6266 if (!file) return;
6267 if (fileName) fileName.textContent = '✓ ' + file.name;
6268 var reader = new FileReader();
6269 reader.onload = function (e) {
6270 try {
6271 var cfg = JSON.parse(e.target.result);
6272 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
6273 var params = configToParams(cfg);
6274 window.location.href = '/scan?' + params.toString();
6275 } catch (err) {
6276 alert('Could not parse config file: ' + err.message);
6277 }
6278 };
6279 reader.readAsText(file);
6280 });
6281 }
6282
6283 function escHtml(s) {
6284 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
6285 }
6286 })();
6287 </script>
6288</body>
6289</html>
6290"##,
6291 ext = "html"
6292)]
6293struct ScanSetupTemplate {
6294 recent_scans_json: String,
6295}
6296
6297#[derive(Template)]
6298#[template(
6299 source = r##"
6300<!doctype html>
6301<html lang="en">
6302<head>
6303 <meta charset="utf-8">
6304 <meta name="viewport" content="width=device-width, initial-scale=1">
6305 <title>OxideSLOC | {{ report_title }} | Report</title>
6306 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
6307 <style>
6308 :root {
6309 --radius: 18px;
6310 --bg: #f5efe8;
6311 --surface: rgba(255,255,255,0.82);
6312 --surface-2: #fbf7f2;
6313 --surface-3: #efe6dc;
6314 --line: #e6d0bf;
6315 --line-strong: #dcb89f;
6316 --text: #43342d;
6317 --muted: #7b675b;
6318 --muted-2: #a08777;
6319 --nav: #b85d33;
6320 --nav-2: #7a371b;
6321 --accent: #6f9bff;
6322 --accent-2: #4a78ee;
6323 --oxide: #d37a4c;
6324 --oxide-2: #b35428;
6325 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
6326 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
6327 --success-bg: #e8f5ed;
6328 --success-text: #1a8f47;
6329 --info-bg: #eef3ff;
6330 --info-text: #4467d8;
6331 }
6332
6333 body.dark-theme {
6334 --bg: #1b1511;
6335 --surface: #261c17;
6336 --surface-2: #2d221d;
6337 --surface-3: #372922;
6338 --line: #524238;
6339 --line-strong: #6c5649;
6340 --text: #f5ece6;
6341 --muted: #c7b7aa;
6342 --muted-2: #aa9485;
6343 --nav: #b85d33;
6344 --nav-2: #7a371b;
6345 --accent: #6f9bff;
6346 --accent-2: #4a78ee;
6347 --oxide: #d37a4c;
6348 --oxide-2: #b35428;
6349 --shadow: 0 18px 42px rgba(0,0,0,0.28);
6350 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
6351 --success-bg: #163927;
6352 --success-text: #8fe2a8;
6353 --info-bg: #1c2847;
6354 --info-text: #a9c1ff;
6355 }
6356
6357 * { box-sizing: border-box; }
6358 html, body { margin: 0; min-height: 100vh; font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); }
6359 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
6360 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
6361 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
6362 .top-nav, .page { position: relative; z-index: 2; }
6363 .top-nav { position: sticky; top: 0; z-index: 30; background: linear-gradient(180deg, var(--nav), var(--nav-2)); border-bottom: 1px solid rgba(255,255,255,0.12); box-shadow: 0 4px 14px rgba(0,0,0,0.18); }
6364 .top-nav-inner { max-width: 1720px; margin: 0 auto; padding: 4px 24px; min-height: 56px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 18px; }
6365 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
6366 .brand-logo { width: 42px; height: 46px; object-fit: contain; flex: 0 0 auto; filter: drop-shadow(0 4px 10px rgba(0,0,0,0.22)); }
6367 .brand-mark { width: 42px; height: 42px; border-radius: 14px; background: radial-gradient(circle at 35% 35%, #f2a578, var(--oxide) 58%, var(--oxide-2)); box-shadow: inset 0 1px 0 rgba(255,255,255,0.22), 0 8px 18px rgba(0,0,0,0.22); flex: 0 0 auto; }
6368 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
6369 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
6370 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
6371 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
6372 .nav-project-pill { width: 100%; max-width: 260px; display:inline-flex; align-items:center; justify-content:center; gap: 10px; min-height: 38px; padding: 0 14px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.18); color: #fff; background: rgba(255,255,255,0.10); font-size: 12px; font-weight: 700; box-shadow: inset 0 1px 0 rgba(255,255,255,0.08); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
6373 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
6374 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; }
6375 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
6376 .nav-pill, .theme-toggle { display: inline-flex; align-items: center; gap: 8px; min-height: 38px; padding: 0 14px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.18); color: #fff; background: rgba(255,255,255,0.08); font-size: 12px; font-weight: 700; box-shadow: inset 0 1px 0 rgba(255,255,255,0.08); }
6377 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
6378 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
6379 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
6380 .theme-toggle .icon-sun { display:none; }
6381 body.dark-theme .theme-toggle .icon-sun { display:block; }
6382 body.dark-theme .theme-toggle .icon-moon { display:none; }
6383 .status-dot { width: 8px; height: 8px; border-radius: 999px; background: #26d768; box-shadow: 0 0 0 4px rgba(38,215,104,0.14); flex:0 0 auto; }
6384 .server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}.server-status-tip::before{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{display:block;}
6385 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
6386 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
6387 .hero, .panel { padding: 22px; }
6388 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
6389 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
6390 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
6391 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
6392 .compare-banner { margin-top: 18px; background: var(--info-bg, #eef3ff); border: 1px solid rgba(100,130,220,0.25); border-radius: 14px; padding: 14px 18px; }
6393 .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
6394 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
6395 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
6396 .delta-chip.pos { background:#e6f4ea; color:#1e7e34; }
6397 .delta-chip.neg { background:#fde8e8; color:#b91c1c; }
6398 .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; }
6399 .delta-card-inline { background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:6px 12px; text-align:center; min-width:80px; }
6400 .delta-card-val { font-size:16px; font-weight:800; }
6401 .delta-card-val.pos { color:#1e7e34; }
6402 .delta-card-val.neg { color:#b91c1c; }
6403 .delta-card-val.mod { color:#b35428; }
6404 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
6405 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
6406 .compare-ts { font-size:13px; color:var(--muted); }
6407 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
6408 .compare-arrow { color: var(--muted); }
6409 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-top: 18px; }
6410 .action-card { padding: 16px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
6411 .action-card h3 { margin:0 0 10px; font-size: 16px; }
6412 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; }
6413 .button, .copy-button {
6414 display: inline-flex; align-items: center; justify-content: center; border-radius: 14px; border: 1px solid rgba(111, 144, 255, 0.30); padding: 11px 14px; text-decoration: none; color: white; background: linear-gradient(135deg, var(--accent), var(--accent-2)); font-weight: 800; font-size: 14px; box-shadow: 0 12px 24px rgba(73, 106, 255, 0.22); cursor: pointer;
6415 }
6416 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
6417 .path-list { display: grid; grid-template-columns: 1fr 0.6fr 1.4fr; gap: 10px; margin-top: 18px; }
6418 .path-item { padding: 10px 14px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: space-between; }
6419 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
6420 .path-item strong { display: block; margin-bottom: 6px; }
6421 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
6422 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
6423 .path-subitem { flex: 1; }
6424 .path-item-scan-badge { display:inline-flex; align-items:center; padding: 2px 8px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); font-size: 11px; font-weight: 700; color: var(--muted); }
6425 code { display: inline-block; max-width: 100%; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: var(--surface-3); border: 1px solid var(--line); padding: 2px 6px; border-radius: 8px; color: var(--text); }
6426 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
6427 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
6428 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
6429 th:first-child, td:first-child { width: 28%; }
6430 th { color: var(--muted); font-weight: 700; }
6431 tr:last-child td { border-bottom: none; }
6432 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
6433 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
6434 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
6435 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
6436 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
6437 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
6438 .soft-chip { display:inline-flex; align-items:center; min-height: 32px; padding: 0 12px; border-radius: 999px; border:1px solid var(--line); background: var(--surface-2); color: var(--text); font-size: 13px; font-weight: 700; }
6439 .soft-chip.success { background: var(--success-bg); color: var(--success-text); }
6440 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
6441 .muted { color: var(--muted); }
6442 .site-footer { position: relative; z-index: 2; margin-top: 24px; padding: 20px 24px; border-top: 1px solid var(--line); background: rgba(0,0,0,0.04); text-align: center; color: var(--muted); font-size: 13px; line-height: 1.7; }
6443 .site-footer a { color: var(--muted-2); font-weight: 700; text-decoration: none; }
6444 .site-footer a:hover { color: var(--text); text-decoration: underline; }
6445 .open-path-btn { display:inline-flex; align-items:center; justify-content:center; border-radius: 14px; border: 1px solid var(--line-strong); padding: 11px 14px; color: var(--text); background: var(--surface-3); font-weight: 800; font-size: 14px; cursor: pointer; text-decoration: none; }
6446 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
6447 .empty-card-note { padding: 18px; color: var(--muted); font-size: 14px; line-height: 1.65; border-radius: 12px; border: 1px dashed var(--line-strong); background: var(--surface-2); margin-top: 8px; }
6448 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
6449 /* Submodule panel */
6450 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
6451 /* Metrics tables stack */
6452 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
6453 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
6454 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
6455 .metrics-table-title { padding: 10px 16px 6px; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); border-bottom: 1px solid var(--line); background: linear-gradient(180deg, var(--surface-2), var(--surface-3)); }
6456 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
6457 /* Metrics table */
6458 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
6459 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
6460 .metrics-table thead th { padding: 10px 16px; background: linear-gradient(180deg, var(--surface-2), var(--surface-3)); font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted-2); border-bottom: 2px solid var(--line-strong); text-align: left; }
6461 .metrics-table thead th:not(:first-child) { text-align: right; }
6462 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
6463 .metrics-table tbody tr:last-child td { border-bottom: none; }
6464 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
6465 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
6466 .metrics-table tbody tr:hover td { background: var(--surface-2); }
6467 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
6468 .metrics-section-header td { background: linear-gradient(180deg, rgba(184,93,51,0.04), transparent); font-size: 11px !important; font-weight: 900 !important; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted-2) !important; padding: 8px 16px !important; border-bottom: 1px solid var(--line) !important; }
6469 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
6470 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
6471 .mt-val-pos { color: #1e7e34; font-weight: 700; }
6472 .mt-val-neg { color: #b91c1c; font-weight: 700; }
6473 .mt-val-zero { color: var(--muted); }
6474 .mt-val-mod { color: var(--oxide-2); }
6475 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
6476 @media (max-width: 1180px) {
6477 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
6478 .nav-project-slot, .nav-status { justify-content:flex-start; }
6479 .hero-top { flex-direction: column; }
6480 }
6481 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
6482 @keyframes floatCode{0%{opacity:0;transform:translateY(0) rotate(var(--rot));}10%{opacity:var(--op);}85%{opacity:var(--op);}100%{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}
6483 </style>
6484</head>
6485<body>
6486 <div class="background-watermarks" aria-hidden="true">
6487 <img src="/images/logo/logo-text.png" alt="" />
6488 <img src="/images/logo/logo-text.png" alt="" />
6489 <img src="/images/logo/logo-text.png" alt="" />
6490 <img src="/images/logo/logo-text.png" alt="" />
6491 <img src="/images/logo/logo-text.png" alt="" />
6492 <img src="/images/logo/logo-text.png" alt="" />
6493 <img src="/images/logo/logo-text.png" alt="" />
6494 <img src="/images/logo/logo-text.png" alt="" />
6495 <img src="/images/logo/logo-text.png" alt="" />
6496 <img src="/images/logo/logo-text.png" alt="" />
6497 <img src="/images/logo/logo-text.png" alt="" />
6498 <img src="/images/logo/logo-text.png" alt="" />
6499 <img src="/images/logo/logo-text.png" alt="" />
6500 <img src="/images/logo/logo-text.png" alt="" />
6501 </div>
6502 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
6503 <div class="top-nav">
6504 <div class="top-nav-inner">
6505 <a class="brand" href="/">
6506 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
6507 <div class="brand-copy">
6508 <div class="brand-title">OxideSLOC</div>
6509 <div class="brand-subtitle">Local analysis workbench</div>
6510 </div>
6511 </a>
6512 <div class="nav-project-slot">
6513 <div class="nav-project-pill"><span class="nav-project-label">Project</span><span class="nav-project-value">{{ report_title }}</span></div>
6514 </div>
6515 <div class="nav-status">
6516 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
6517 <a class="nav-pill" href="/view-reports" style="text-decoration:none;">View Reports</a>
6518 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
6519 <div class="server-status-wrap">
6520 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
6521 <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
6522 </div>
6523 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
6524 <svg class="icon-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
6525 <svg class="icon-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
6526 </button>
6527 </div>
6528 </div>
6529 </div>
6530
6531 <div class="page">
6532 <section class="hero">
6533 <div class="hero-top">
6534 <div>
6535 <div class="soft-chip success">Run finished successfully</div>
6536 <h1 class="hero-title">{{ report_title }}</h1>
6537 <p class="hero-subtitle">Your HTML, PDF, and JSON artifacts are now saved. Use the quick actions below to view, download, or copy the saved paths for sharing outside the local workbench.</p>
6538 </div>
6539 <div class="hero-quick-actions">
6540 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
6541 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
6542 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
6543 </div>
6544 </div>
6545
6546 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
6547 <div class="compare-banner">
6548 <div class="compare-banner-body">
6549 <div class="compare-banner-meta">
6550 <span class="compare-label">Previous scan</span>
6551 <span class="compare-ts">{{ prev_ts }}</span>
6552 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
6553 {% if let Some(prev_code) = prev_run_code_lines %}
6554 <div class="compare-banner-stats" style="margin-top:4px;">
6555 <span>Code before: <strong>{{ prev_code }}</strong></span>
6556 <span class="compare-arrow">→</span>
6557 <span>Code now: <strong>{{ code_lines }}</strong></span>
6558 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
6559 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−{{ removed }} removed</span>{% endif %}
6560 </div>
6561 {% endif %}
6562 </div>
6563 {% if delta_lines_added.is_some() %}
6564 <div class="delta-cards-inline">
6565 <div class="delta-card-inline">
6566 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
6567 <div class="delta-card-lbl">lines added</div>
6568 </div>
6569 <div class="delta-card-inline">
6570 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v }}{% else %}—{% endif %}</div>
6571 <div class="delta-card-lbl">lines removed</div>
6572 </div>
6573 <div class="delta-card-inline">
6574 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
6575 <div class="delta-card-lbl">unmodified lines</div>
6576 </div>
6577 <div class="delta-card-inline">
6578 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
6579 <div class="delta-card-lbl">files modified</div>
6580 </div>
6581 <div class="delta-card-inline">
6582 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
6583 <div class="delta-card-lbl">files added</div>
6584 </div>
6585 <div class="delta-card-inline">
6586 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
6587 <div class="delta-card-lbl">files removed</div>
6588 </div>
6589 <div class="delta-card-inline">
6590 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
6591 <div class="delta-card-lbl">files unchanged</div>
6592 </div>
6593 </div>
6594 {% else %}
6595 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
6596 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
6597 </p>
6598 {% endif %}
6599 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
6600 </div>
6601 </div>
6602 {% endif %}{% endif %}
6603
6604 <div class="action-grid">
6605 <div class="action-card">
6606 <h3>HTML report</h3>
6607 <div class="action-buttons">
6608 {% match html_url %}
6609 {% when Some with (url) %}
6610 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
6611 {% when None %}{% endmatch %}
6612 {% match html_download_url %}
6613 {% when Some with (url) %}
6614 <a class="button secondary" href="{{ url }}">Download HTML</a>
6615 {% when None %}{% endmatch %}
6616 {% match html_path %}
6617 {% when Some with (_path) %}{% when None %}{% endmatch %}
6618 </div>
6619 </div>
6620 <div class="action-card">
6621 <h3>PDF report</h3>
6622 <div class="action-buttons">
6623 {% match pdf_url %}
6624 {% when Some with (url) %}
6625 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open PDF</a>
6626 {% when None %}{% endmatch %}
6627 {% match pdf_download_url %}
6628 {% when Some with (url) %}
6629 <a class="button secondary" href="{{ url }}">Download PDF</a>
6630 {% when None %}{% endmatch %}
6631 {% match pdf_path %}
6632 {% when Some with (_path) %}{% when None %}{% endmatch %}
6633 </div>
6634 </div>
6635 <div class="action-card">
6636 <h3>JSON result</h3>
6637 <div class="action-buttons">
6638 {% match json_url %}
6639 {% when Some with (url) %}
6640 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
6641 {% when None %}{% endmatch %}
6642 {% match json_download_url %}
6643 {% when Some with (url) %}
6644 <a class="button secondary" href="{{ url }}">Download JSON</a>
6645 {% when None %}{% endmatch %}
6646 {% match json_path %}
6647 {% when Some with (_path) %}{% when None %}
6648 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
6649 {% endmatch %}
6650 </div>
6651 </div>
6652 <div class="action-card">
6653 <h3>Scan config</h3>
6654 <div class="action-buttons">
6655 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
6656 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
6657 </div>
6658 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
6659 </div>
6660 </div>
6661 {% if !submodule_rows.is_empty() %}
6662 <div class="submodule-panel">
6663 <div class="toolbar-row">
6664 <div>
6665 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
6666 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
6667 </div>
6668 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
6669 </div>
6670 <div style="overflow:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
6671 <table style="width:100%;border-collapse:collapse;font-size:14px;">
6672 <thead>
6673 <tr>
6674 <th style="padding:9px 14px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:left;">Submodule</th>
6675 <th style="padding:9px 14px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:left;">Path</th>
6676 <th style="padding:9px 14px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;">Files</th>
6677 <th style="padding:9px 14px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;">Physical</th>
6678 <th style="padding:9px 14px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;">Code</th>
6679 <th style="padding:9px 14px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;">Comments</th>
6680 <th style="padding:9px 14px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;">Blank</th>
6681 <th style="padding:9px 14px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:center;">Report</th>
6682 </tr>
6683 </thead>
6684 <tbody>
6685 {% for row in submodule_rows %}
6686 <tr>
6687 <td style="padding:10px 14px;border-bottom:1px solid var(--line);font-weight:700;"><strong>{{ row.name }}</strong></td>
6688 <td style="padding:10px 14px;border-bottom:1px solid var(--line);"><code style="font-size:12px;">{{ row.relative_path }}</code></td>
6689 <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.files_analyzed }}</td>
6690 <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.total_physical_lines }}</td>
6691 <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.code_lines }}</td>
6692 <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.comment_lines }}</td>
6693 <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.blank_lines }}</td>
6694 <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:center;">{% if let Some(url) = row.html_url %}<a class="button" href="{{ url }}" target="_blank" rel="noopener" style="font-size:12px;padding:6px 12px;min-height:0;">View</a>{% else %}<span style="color:var(--muted);font-size:12px;">—</span>{% endif %}</td>
6695 </tr>
6696 {% endfor %}
6697 </tbody>
6698 </table>
6699 </div>
6700 </div>
6701 {% endif %}
6702
6703 <div class="metrics-tables-stack">
6704
6705 <div class="metrics-table-wrap">
6706 <div class="metrics-table-title">Files</div>
6707 <table class="metrics-table">
6708 <thead>
6709 <tr>
6710 <th>Metric</th>
6711 <th>This Run</th>
6712 <th>Previous</th>
6713 <th>Change</th>
6714 </tr>
6715 </thead>
6716 <tbody>
6717 <tr>
6718 <td>Files analyzed</td>
6719 <td class="mt-val-large">{{ files_analyzed }}</td>
6720 <td>{{ prev_fa_str }}</td>
6721 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
6722 </tr>
6723 <tr>
6724 <td>Files skipped</td>
6725 <td>{{ files_skipped }}</td>
6726 <td>{{ prev_fs_str }}</td>
6727 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
6728 </tr>
6729 <tr>
6730 <td>Files modified</td>
6731 <td class="mt-val-na">—</td>
6732 <td class="mt-val-na">—</td>
6733 <td>{% if let Some(v) = delta_files_modified %}<span class="mt-val-mod">{{ v }} modified</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
6734 </tr>
6735 <tr>
6736 <td>Files unchanged</td>
6737 <td class="mt-val-na">—</td>
6738 <td class="mt-val-na">—</td>
6739 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
6740 </tr>
6741 </tbody>
6742 </table>
6743 </div>
6744
6745 <div class="metrics-table-wrap">
6746 <div class="metrics-table-title">Line Counts</div>
6747 <table class="metrics-table">
6748 <thead>
6749 <tr>
6750 <th>Metric</th>
6751 <th>This Run</th>
6752 <th>Previous</th>
6753 <th>Change</th>
6754 </tr>
6755 </thead>
6756 <tbody>
6757 <tr>
6758 <td>Physical lines</td>
6759 <td class="mt-val-large">{{ physical_lines }}</td>
6760 <td>{{ prev_pl_str }}</td>
6761 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
6762 </tr>
6763 <tr>
6764 <td>Code lines</td>
6765 <td class="mt-val-large">{{ code_lines }}</td>
6766 <td>{{ prev_cl_str }}</td>
6767 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
6768 </tr>
6769 <tr>
6770 <td>Comment lines</td>
6771 <td>{{ comment_lines }}</td>
6772 <td>{{ prev_cml_str }}</td>
6773 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
6774 </tr>
6775 <tr>
6776 <td>Blank lines</td>
6777 <td>{{ blank_lines }}</td>
6778 <td>{{ prev_bl_str }}</td>
6779 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
6780 </tr>
6781 <tr>
6782 <td>Mixed (separate)</td>
6783 <td>{{ mixed_lines }}</td>
6784 <td class="mt-val-na">—</td>
6785 <td class="mt-val-na">—</td>
6786 </tr>
6787 </tbody>
6788 </table>
6789 </div>
6790
6791 <div class="metrics-tables-lower">
6792 <div class="metrics-table-wrap">
6793 <div class="metrics-table-title">Code Structure</div>
6794 <table class="metrics-table">
6795 <thead>
6796 <tr>
6797 <th>Metric</th>
6798 <th>This Run</th>
6799 </tr>
6800 </thead>
6801 <tbody>
6802 <tr>
6803 <td>Functions</td>
6804 <td>{{ functions }}</td>
6805 </tr>
6806 <tr>
6807 <td>Classes / Types</td>
6808 <td>{{ classes }}</td>
6809 </tr>
6810 <tr>
6811 <td>Variables</td>
6812 <td>{{ variables }}</td>
6813 </tr>
6814 <tr>
6815 <td>Imports</td>
6816 <td>{{ imports }}</td>
6817 </tr>
6818 </tbody>
6819 </table>
6820 </div>
6821
6822 <div class="metrics-table-wrap">
6823 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
6824 <table class="metrics-table">
6825 <thead>
6826 <tr>
6827 <th>Metric</th>
6828 <th>Change</th>
6829 </tr>
6830 </thead>
6831 <tbody>
6832 <tr>
6833 <td>Lines added</td>
6834 <td>{% if let Some(v) = delta_lines_added %}<span class="mt-val-pos">+{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
6835 </tr>
6836 <tr>
6837 <td>Lines removed</td>
6838 <td>{% if let Some(v) = delta_lines_removed %}<span class="mt-val-neg">−{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
6839 </tr>
6840 <tr>
6841 <td>Lines modified (net)</td>
6842 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
6843 </tr>
6844 <tr>
6845 <td>Lines unmodified</td>
6846 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
6847 </tr>
6848 </tbody>
6849 </table>
6850 </div>
6851 </div>
6852
6853 </div>
6854
6855 <div class="path-list">
6856 <div class="path-item">
6857 <div class="path-item-label">Project path</div>
6858 <code>{{ project_path }}</code>
6859 </div>
6860 <div class="path-item">
6861 <div class="path-item-label">Git branch</div>
6862 {% if let Some(branch) = git_branch %}
6863 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
6864 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
6865 {% else %}
6866 <code style="color:var(--muted)">—</code>
6867 {% endif %}
6868 </div>
6869 <div class="path-item path-item-split">
6870 <div class="path-subitem">
6871 <div class="path-item-label">Output folder</div>
6872 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
6873 </div>
6874 <div class="path-subitem" style="border-top:1px solid var(--line);padding-top:8px;margin-top:8px;">
6875 <div class="path-item-label">Run ID</div>
6876 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
6877 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
6878 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
6879 </div>
6880 </div>
6881 </div>
6882 </div>
6883 </section>
6884
6885 <section class="panel" style="margin-bottom: 18px;">
6886 <div class="toolbar-row">
6887 <div>
6888 <h2>Language breakdown</h2>
6889 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
6890 </div>
6891 </div>
6892 <table>
6893 <thead>
6894 <tr>
6895 <th>Language</th>
6896 <th>Files</th>
6897 <th>Physical</th>
6898 <th>Code</th>
6899 <th>Comments</th>
6900 <th>Blank</th>
6901 <th>Mixed</th>
6902 <th>Functions</th>
6903 <th>Classes</th>
6904 <th>Variables</th>
6905 <th>Imports</th>
6906 </tr>
6907 </thead>
6908 <tbody>
6909 {% for row in language_rows %}
6910 <tr>
6911 <td>{{ row.language }}</td>
6912 <td>{{ row.files }}</td>
6913 <td>{{ row.physical }}</td>
6914 <td>{{ row.code }}</td>
6915 <td>{{ row.comments }}</td>
6916 <td>{{ row.blank }}</td>
6917 <td>{{ row.mixed }}</td>
6918 <td>{{ row.functions }}</td>
6919 <td>{{ row.classes }}</td>
6920 <td>{{ row.variables }}</td>
6921 <td>{{ row.imports }}</td>
6922 </tr>
6923 {% endfor %}
6924 </tbody>
6925 </table>
6926 </section>
6927
6928 </div>
6929
6930 <script>
6931 (function () {
6932 var body = document.body;
6933 var themeToggle = document.getElementById('theme-toggle');
6934 var storageKey = 'oxide-sloc-theme';
6935
6936 function applyTheme(theme) {
6937 body.classList.toggle('dark-theme', theme === 'dark');
6938 }
6939
6940 function loadSavedTheme() {
6941 try {
6942 var saved = localStorage.getItem(storageKey);
6943 if (saved === 'dark' || saved === 'light') {
6944 applyTheme(saved);
6945 }
6946 } catch (e) {}
6947 }
6948
6949 if (themeToggle) {
6950 themeToggle.addEventListener('click', function () {
6951 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
6952 applyTheme(nextTheme);
6953 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
6954 });
6955 }
6956
6957 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
6958 button.addEventListener('click', function () {
6959 var value = button.getAttribute('data-copy-value') || '';
6960 if (!value) return;
6961 if (navigator.clipboard && navigator.clipboard.writeText) {
6962 navigator.clipboard.writeText(value).catch(function () {});
6963 }
6964 });
6965 });
6966
6967 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
6968 btn.addEventListener('click', function () {
6969 var folder = btn.getAttribute('data-folder') || '';
6970 if (!folder) return;
6971 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
6972 });
6973 });
6974
6975 loadSavedTheme();
6976
6977 (function randomizeWatermarks() {
6978 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
6979 if (!wms.length) return;
6980 var placed = [];
6981 function tooClose(top, left) {
6982 for (var i = 0; i < placed.length; i++) {
6983 var dt = Math.abs(placed[i][0] - top);
6984 var dl = Math.abs(placed[i][1] - left);
6985 if (dt < 20 && dl < 18) return true;
6986 }
6987 return false;
6988 }
6989 function pick(leftBand) {
6990 for (var attempt = 0; attempt < 50; attempt++) {
6991 var top = Math.random() * 85 + 5;
6992 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
6993 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
6994 }
6995 var top = Math.random() * 85 + 5;
6996 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
6997 placed.push([top, left]);
6998 return [top, left];
6999 }
7000 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
7001 var half = Math.floor(wms.length / 2);
7002 wms.forEach(function (img, i) {
7003 var pos = pick(i < half);
7004 var size = Math.floor(Math.random() * 100 + 160);
7005 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
7006 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
7007 img.style.cssText = "width:" + size + "px;top:" + pos[0].toFixed(1) + "%;left:" + pos[1].toFixed(1) + "%;transform:rotate(" + rot.toFixed(1) + "deg);opacity:" + op + ";";
7008 });
7009 })();
7010
7011 (function spawnCodeParticles() {
7012 var container = document.getElementById('code-particles');
7013 if (!container) return;
7014 var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
7015 for (var i = 0; i < 38; i++) {
7016 (function(idx) {
7017 var el = document.createElement('span');
7018 el.className = 'code-particle';
7019 el.textContent = snippets[idx % snippets.length];
7020 var left = Math.random() * 94 + 2;
7021 var top = Math.random() * 88 + 6;
7022 var dur = (Math.random() * 10 + 9).toFixed(1);
7023 var delay = (Math.random() * 18).toFixed(1);
7024 var rot = (Math.random() * 26 - 13).toFixed(1);
7025 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7026 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
7027 container.appendChild(el);
7028 })(i);
7029 }
7030 })();
7031 })();
7032 </script>
7033 <footer class="site-footer">
7034 oxide-sloc — local source line analysis workbench ·
7035 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7036 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7037 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7038 </footer>
7039</body>
7040</html>
7041"##,
7042 ext = "html"
7043)]
7044struct ResultTemplate {
7045 report_title: String,
7046 project_path: String,
7047 output_dir: String,
7048 run_id: String,
7049 files_analyzed: u64,
7050 files_skipped: u64,
7051 physical_lines: u64,
7052 code_lines: u64,
7053 comment_lines: u64,
7054 blank_lines: u64,
7055 mixed_lines: u64,
7056 functions: u64,
7057 classes: u64,
7058 variables: u64,
7059 imports: u64,
7060 html_url: Option<String>,
7061 pdf_url: Option<String>,
7062 json_url: Option<String>,
7063 html_download_url: Option<String>,
7064 pdf_download_url: Option<String>,
7065 json_download_url: Option<String>,
7066 html_path: Option<String>,
7067 pdf_path: Option<String>,
7068 json_path: Option<String>,
7069 language_rows: Vec<LanguageSummaryRow>,
7070 prev_run_id: Option<String>,
7071 prev_run_timestamp: Option<String>,
7072 prev_run_code_lines: Option<u64>,
7073 prev_fa_str: String,
7075 prev_fs_str: String,
7076 prev_pl_str: String,
7077 prev_cl_str: String,
7078 prev_cml_str: String,
7079 prev_bl_str: String,
7080 delta_fa_str: String,
7082 delta_fa_class: String,
7083 delta_fs_str: String,
7084 delta_fs_class: String,
7085 delta_pl_str: String,
7086 delta_pl_class: String,
7087 delta_cl_str: String,
7088 delta_cl_class: String,
7089 delta_cml_str: String,
7090 delta_cml_class: String,
7091 delta_bl_str: String,
7092 delta_bl_class: String,
7093 delta_lines_added: Option<i64>,
7095 delta_lines_removed: Option<i64>,
7096 delta_lines_net_str: String,
7097 delta_lines_net_class: String,
7098 delta_files_added: Option<usize>,
7099 delta_files_removed: Option<usize>,
7100 delta_files_modified: Option<usize>,
7101 delta_files_unchanged: Option<usize>,
7102 delta_unmodified_lines: Option<u64>,
7103 git_branch: Option<String>,
7105 git_commit: Option<String>,
7106 git_author: Option<String>,
7107 prev_scan_count: usize,
7109 current_scan_number: usize,
7110 submodule_rows: Vec<SubmoduleRow>,
7112 scan_config_url: String,
7113}
7114
7115#[derive(Template)]
7116#[template(
7117 source = r##"
7118<!doctype html>
7119<html lang="en">
7120<head>
7121 <meta charset="utf-8">
7122 <meta name="viewport" content="width=device-width, initial-scale=1">
7123 <title>OxideSLOC | Error</title>
7124 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7125 <style>
7126 :root {
7127 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
7128 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7129 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#4a78ee;
7130 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7131 }
7132 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
7133 *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}
7134 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7135 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
7136 .top-nav{position:sticky;top:0;z-index:30;background:linear-gradient(180deg,var(--nav),var(--nav-2));border-bottom:1px solid rgba(255,255,255,0.12);box-shadow:0 4px 14px rgba(0,0,0,0.18);}
7137 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
7138 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;} .brand-logo{width:42px;height:46px;object-fit:contain;flex:0 0 auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}
7139 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
7140 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;} .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;}
7141 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
7142 .nav-pill,.theme-toggle{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;transition:background .15s ease,transform .15s ease;}
7143 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
7144 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
7145 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
7146 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
7147 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
7148 .page{max-width:1720px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
7149 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
7150 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
7151 .error-box{border-radius:16px;border:1px solid var(--line);background:var(--surface-2);padding:16px 18px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.55;font-size:13px;}
7152 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
7153 .btn-primary{display:inline-flex;align-items:center;justify-content:center;min-height:42px;padding:0 18px;border-radius:14px;border:1px solid rgba(111,144,255,0.30);text-decoration:none;color:white;background:linear-gradient(135deg,var(--accent),var(--accent-2));font-weight:800;font-size:14px;box-shadow:0 10px 22px rgba(73,106,255,0.22);}
7154 .btn-secondary{display:inline-flex;align-items:center;justify-content:center;min-height:42px;padding:0 18px;border-radius:14px;border:1px solid var(--line-strong);text-decoration:none;color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}
7155 .btn-secondary:hover{background:var(--line);}
7156 .status-dot{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}
7157 .server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}.server-status-tip::before{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{display:block;}
7158 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
7159 @keyframes floatCode{0%{opacity:0;transform:translateY(0) rotate(var(--rot));}10%{opacity:var(--op);}85%{opacity:var(--op);}100%{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}
7160 </style>
7161</head>
7162<body>
7163 <div class="background-watermarks" aria-hidden="true">
7164 <img src="/images/logo/logo-text.png" alt="" style="width:320px;top:-40px;left:-60px;transform:rotate(-12deg);" />
7165 <img src="/images/logo/logo-text.png" alt="" style="width:280px;top:120px;right:-50px;transform:rotate(8deg);" />
7166 <img src="/images/logo/logo-text.png" alt="" style="width:260px;bottom:60px;left:30px;transform:rotate(15deg);" />
7167 <img src="/images/logo/logo-text.png" alt="" style="width:300px;bottom:-20px;right:80px;transform:rotate(-6deg);" />
7168 <img src="/images/logo/logo-text.png" alt="" style="width:240px;top:50%;left:45%;transform:rotate(22deg);" />
7169 <img src="/images/logo/logo-text.png" alt="" style="width:270px;top:10%;left:35%;transform:rotate(-18deg);" />
7170 </div>
7171 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7172 <div class="top-nav">
7173 <div class="top-nav-inner">
7174 <a class="brand" href="/">
7175 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
7176 <div class="brand-copy">
7177 <div class="brand-title">OxideSLOC</div>
7178 <div class="brand-subtitle">Local analysis workbench</div>
7179 </div>
7180 </a>
7181 <div class="nav-right">
7182 <a class="nav-pill" href="/">Home</a>
7183 <a class="nav-pill" href="/view-reports">View Reports</a>
7184 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7185 <div class="server-status-wrap">
7186 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
7187 <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
7188 </div>
7189 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7190 <svg class="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
7191 <svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
7192 </button>
7193 </div>
7194 </div>
7195 </div>
7196
7197 <div class="page">
7198 <div class="panel">
7199 <h1>Analysis failed</h1>
7200 <div class="error-box">{{ message }}</div>
7201 <div class="actions">
7202 <a class="btn-primary" href="/scan">Back to setup</a>
7203 {% if let Some(report_url) = last_report_url %}
7204 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
7205 {% endif %}
7206 <a class="btn-secondary" href="/view-reports">View Reports</a>
7207 </div>
7208 </div>
7209 </div>
7210 <script>
7211 (function(){var k="oxide-theme",b=document.body,s=localStorage.getItem(k);if(s==="dark")b.classList.add("dark-theme");document.getElementById("theme-toggle").addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});})();
7212 (function spawnCodeParticles() {
7213 var container = document.getElementById('code-particles');
7214 if (!container) return;
7215 var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
7216 for (var i = 0; i < 38; i++) {
7217 (function(idx) {
7218 var el = document.createElement('span');
7219 el.className = 'code-particle';
7220 el.textContent = snippets[idx % snippets.length];
7221 var left = Math.random() * 94 + 2;
7222 var top = Math.random() * 88 + 6;
7223 var dur = (Math.random() * 10 + 9).toFixed(1);
7224 var delay = (Math.random() * 18).toFixed(1);
7225 var rot = (Math.random() * 26 - 13).toFixed(1);
7226 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7227 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
7228 container.appendChild(el);
7229 })(i);
7230 }
7231 })();
7232 </script>
7233</body>
7234</html>
7235"##,
7236 ext = "html"
7237)]
7238struct ErrorTemplate {
7239 message: String,
7240 last_report_url: Option<String>,
7242 last_report_label: Option<String>,
7244}
7245
7246#[derive(Template)]
7249#[template(
7250 source = r##"
7251<!doctype html>
7252<html lang="en">
7253<head>
7254 <meta charset="utf-8">
7255 <meta name="viewport" content="width=device-width, initial-scale=1">
7256 <title>OxideSLOC | View Reports</title>
7257 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7258 <style>
7259 :root {
7260 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7261 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7262 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
7263 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7264 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fdeaea;
7265 }
7266 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
7267 *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}
7268 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7269 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
7270 .top-nav{position:sticky;top:0;z-index:30;background:linear-gradient(180deg,var(--nav),var(--nav-2));border-bottom:1px solid rgba(255,255,255,0.12);box-shadow:0 4px 14px rgba(0,0,0,0.18);}
7271 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
7272 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;} .brand-logo{width:42px;height:46px;object-fit:contain;flex:0 0 auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}
7273 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
7274 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;} .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;}
7275 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
7276 .nav-pill,.theme-toggle{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;transition:background .15s ease,transform .15s ease;}
7277 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
7278 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
7279 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
7280 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
7281 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
7282 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
7283 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
7284 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
7285 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
7286 .panel-meta{font-size:13px;color:var(--muted);}
7287 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
7288 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
7289 .per-page-label{font-size:13px;color:var(--muted);}
7290 select.per-page,.filter-input,.filter-select{border:1px solid var(--line-strong);border-radius:8px;background:var(--surface-2);color:var(--text);padding:5px 10px;font-size:13px;cursor:pointer;}
7291 .filter-input{min-width:180px;cursor:text;}
7292 .table-wrap{width:100%;overflow-x:auto;}
7293 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
7294 th{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);padding:8px 12px;border-bottom:2px solid var(--line);white-space:nowrap;position:relative;user-select:none;}
7295 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
7296 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
7297 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
7298 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
7299 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
7300 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
7301 tr:last-child td{border-bottom:none;}
7302 tr:hover td{background:var(--surface-2);}
7303 .run-id-chip{font-family:ui-monospace,monospace;font-size:11px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:2px 7px;color:var(--muted);}
7304 .git-chip{font-family:ui-monospace,monospace;font-size:11px;background:rgba(100,130,220,0.08);border:1px solid rgba(100,130,220,0.20);border-radius:6px;padding:2px 7px;color:var(--accent-2);}
7305 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
7306 .metric-num{font-weight:700;color:var(--text);}
7307 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
7308 .btn{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s ease;white-space:nowrap;}
7309 .btn:hover{background:var(--line);}
7310 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
7311 .btn.primary:hover{opacity:.9;}
7312 .btn-back{display:inline-flex;align-items:center;gap:7px;padding:7px 14px;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s ease;}
7313 .btn-back:hover{background:var(--line);}
7314 .export-btn{display:inline-flex;align-items:center;gap:5px;padding:5px 11px;border-radius:7px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);text-decoration:none;white-space:nowrap;transition:background .12s ease;}
7315 .export-btn:hover{background:var(--line);}
7316 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
7317 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
7318 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
7319 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
7320 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
7321 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
7322 .pagination-info{font-size:13px;color:var(--muted);}
7323 .pagination-btns{display:flex;gap:6px;}
7324 .pg-btn{min-width:34px;min-height:34px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:700;cursor:pointer;transition:background .12s ease;}
7325 .pg-btn:hover:not(:disabled){background:var(--line);}
7326 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
7327 .pg-btn:disabled{opacity:.35;cursor:default;}
7328 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
7329 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
7330 .stat-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .2s ease,box-shadow .2s ease;}
7331 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
7332 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
7333 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
7334 .stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.4;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .2s ease;z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}
7335 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
7336 .stat-chip:hover .stat-chip-tip{opacity:1;}
7337 .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
7338 .site-footer a{color:var(--muted);}
7339 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
7340 .locate-bar{display:inline-flex;align-items:center;gap:10px;margin-bottom:14px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex-wrap:wrap;max-width:100%;}
7341 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
7342 .toast-success{display:flex;align-items:center;gap:10px;background:#e8f5ed;border:1px solid #a3d9b1;border-radius:10px;padding:10px 16px;margin-bottom:14px;font-size:13px;color:#1a5c35;font-weight:600;}
7343 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
7344 .status-dot{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}
7345 .server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}.server-status-tip::before{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{display:block;}
7346 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
7347 @keyframes floatCode{0%{opacity:0;transform:translateY(0) rotate(var(--rot));}10%{opacity:var(--op);}85%{opacity:var(--op);}100%{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}
7348 </style>
7349</head>
7350<body>
7351 <div class="background-watermarks" aria-hidden="true">
7352 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7353 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7354 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7355 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7356 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7357 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7358 </div>
7359 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7360 <div class="top-nav">
7361 <div class="top-nav-inner">
7362 <a class="brand" href="/">
7363 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7364 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
7365 </a>
7366 <div class="nav-right">
7367 <a class="nav-pill" href="/">Home</a>
7368 <a class="nav-pill" href="/view-reports">View Reports</a>
7369 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7370 <div class="server-status-wrap">
7371 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
7372 <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
7373 </div>
7374 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7375 <svg class="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
7376 <svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
7377 </button>
7378 </div>
7379 </div>
7380 </div>
7381
7382 <div class="page">
7383 {% if linked %}
7384 <div class="toast-success">
7385 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><polyline points="20 6 9 17 4 12"></polyline></svg>
7386 Report linked successfully — it now appears in the list below.
7387 </div>
7388 {% endif %}
7389 {% if total_scans > 0 %}
7390 <div class="summary-strip">
7391 <div class="stat-chip"><div class="stat-chip-tip">Total scan runs recorded in this workspace</div><div class="stat-chip-val">{{ total_scans }}</div><div class="stat-chip-label">Total scans</div></div>
7392 <div class="stat-chip"><div class="stat-chip-tip">Source lines of code in the most recent scan — excludes comments and blank lines</div><div class="stat-chip-val" id="agg-code">—</div><div class="stat-chip-label">Latest code lines</div></div>
7393 <div class="stat-chip"><div class="stat-chip-tip">Number of source files analyzed in the most recent scan</div><div class="stat-chip-val" id="agg-files">—</div><div class="stat-chip-label">Latest files</div></div>
7394 <div class="stat-chip"><div class="stat-chip-tip">Files excluded by policy rules (vendor, generated, binary, lockfiles, etc.) in the most recent scan</div><div class="stat-chip-val" id="agg-skipped">—</div><div class="stat-chip-label">Latest files skipped</div></div>
7395 </div>
7396 {% endif %}
7397
7398 <section class="panel">
7399 <div class="panel-header">
7400 <div>
7401 <h1>View Reports</h1>
7402 <p class="panel-meta">{{ total_scans }} report(s) available. Click any row to open it.</p>
7403 </div>
7404 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
7405 <div class="export-group">
7406 <button type="button" class="export-btn" onclick="exportHistoryCsv()">
7407 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
7408 Export CSV
7409 </button>
7410 <button type="button" class="export-btn" onclick="exportHistoryXls()">
7411 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
7412 Export Excel
7413 </button>
7414 </div>
7415 <a class="btn-back" href="/">
7416 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="15 18 9 12 15 6"></polyline></svg>
7417 Home
7418 </a>
7419 </div>
7420 </div>
7421
7422 <div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;flex-wrap:wrap;">
7423 <span class="locate-label" style="white-space:nowrap;">Have a saved report on disk? Browse to link it here.</span>
7424 {% if !entries.is_empty() %}
7425 <div style="margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
7426 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…" oninput="applyFilters()">
7427 <select class="filter-select" id="branch-filter" onchange="applyFilters()"><option value="">All branches</option></select>
7428 <button type="button" class="btn" onclick="resetView()">↻ Reset view</button>
7429 </div>
7430 {% endif %}
7431 </div>
7432 <div style="margin-bottom:14px;">
7433 <button type="button" class="btn" onclick="browseReport()">
7434 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
7435 Browse for Report…
7436 </button>
7437 </div>
7438
7439 {% if entries.is_empty() %}
7440 <div class="empty-state">
7441 <strong>No reports with viewable HTML yet</strong>
7442 Run a new analysis from the <a href="/scan">scan page</a>, or use the browse button above to link an existing report.
7443 </div>
7444 {% else %}
7445 <div class="table-wrap">
7446 <table id="history-table">
7447 <colgroup>
7448 <col style="width:155px">
7449 <col style="width:160px">
7450 <col style="width:115px">
7451 <col style="width:88px">
7452 <col style="width:88px">
7453 <col style="width:88px">
7454 <col style="width:72px">
7455 <col style="width:80px">
7456 <col style="width:76px">
7457 <col style="width:80px">
7458 <col style="width:72px">
7459 <col style="width:92px">
7460 <col style="width:92px">
7461 <col style="width:160px">
7462 </colgroup>
7463 <thead>
7464 <tr id="history-thead">
7465 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7466 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7467 <th>Run ID<div class="col-resize-handle"></div></th>
7468 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7469 <th class="sortable" data-sort-col="code" data-sort-type="num">Code lines<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7470 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7471 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7472 <th>Functions<div class="col-resize-handle"></div></th>
7473 <th>Classes<div class="col-resize-handle"></div></th>
7474 <th>Variables<div class="col-resize-handle"></div></th>
7475 <th>Imports<div class="col-resize-handle"></div></th>
7476 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7477 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7478 <th>Report<div class="col-resize-handle"></div></th>
7479 </tr>
7480 </thead>
7481 <tbody id="history-tbody">
7482 {% for entry in entries %}
7483 <tr class="history-row" data-run="{{ entry.run_id }}"
7484 data-timestamp="{{ entry.timestamp }}"
7485 data-project="{{ entry.project_label }}"
7486 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
7487 data-skipped="{{ entry.files_skipped }}"
7488 data-comments="{{ entry.comment_lines }}"
7489 data-blank="{{ entry.blank_lines }}"
7490 data-branch="{{ entry.git_branch }}"
7491 data-commit="{{ entry.git_commit }}"
7492 style="cursor:pointer;"
7493 onclick="window.open('/runs/{{ entry.run_id }}/html', '_blank')">
7494 <td>{{ entry.timestamp }}</td>
7495 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
7496 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
7497 <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
7498 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
7499 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
7500 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
7501 <td><span class="metric-num">{{ entry.functions }}</span></td>
7502 <td><span class="metric-num">{{ entry.classes }}</span></td>
7503 <td><span class="metric-num">{{ entry.variables }}</span></td>
7504 <td><span class="metric-num">{{ entry.imports }}</span></td>
7505 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
7506 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip" title="{{ entry.git_commit }}">{{ entry.git_commit }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
7507 <td style="overflow:visible;white-space:normal;">
7508 <div class="actions-cell">
7509 <a class="btn primary" href="/runs/{{ entry.run_id }}/html" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="View HTML report">View</a>
7510 {% if entry.has_pdf %}<a class="btn" href="/runs/{{ entry.run_id }}/pdf" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="View PDF report" style="background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;">PDF</a>{% endif %}
7511 </div>
7512 </td>
7513 </tr>
7514 {% endfor %}
7515 </tbody>
7516 </table>
7517 </div>
7518 <div class="pagination">
7519 <span class="pagination-info" id="pagination-info"></span>
7520 <div class="pagination-btns" id="pagination-btns"></div>
7521 <div style="display:flex;align-items:center;gap:8px;">
7522 <span class="per-page-label">Show</span>
7523 <select class="per-page" id="per-page-sel" onchange="setPerPage(this.value)">
7524 <option value="10">10 per page</option>
7525 <option value="25" selected>25 per page</option>
7526 <option value="50">50 per page</option>
7527 <option value="100">100 per page</option>
7528 </select>
7529 <span class="per-page-label" id="page-range-label"></span>
7530 </div>
7531 </div>
7532 {% endif %}
7533 </section>
7534 </div>
7535
7536 <footer class="site-footer">
7537 oxide-sloc — local source line analysis workbench ·
7538 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7539 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7540 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7541 </footer>
7542
7543 <script>
7544 (function () {
7545 // ── Theme ──────────────────────────────────────────────────────────────
7546 var storageKey = 'oxide-sloc-theme';
7547 var body = document.body;
7548 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
7549 var toggle = document.getElementById('theme-toggle');
7550 if (toggle) toggle.addEventListener('click', function () {
7551 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
7552 body.classList.toggle('dark-theme', next === 'dark');
7553 try { localStorage.setItem(storageKey, next); } catch(e) {}
7554 });
7555
7556 // ── State ─────────────────────────────────────────────────────────────
7557 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
7558 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
7559 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
7560
7561 // Aggregate stats from first (most recent) row
7562 if (allRows.length) {
7563 var first = allRows[0];
7564 var ce = document.getElementById('agg-code'); if (ce) ce.textContent = Number(first.dataset.code).toLocaleString();
7565 var fe = document.getElementById('agg-files'); if (fe) fe.textContent = first.dataset.files;
7566 var se = document.getElementById('agg-skipped'); if (se) se.textContent = first.dataset.skipped;
7567 }
7568
7569 // ── Branch filter population ──────────────────────────────────────────
7570 (function() {
7571 var branches = {};
7572 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
7573 var sel = document.getElementById('branch-filter');
7574 if (sel) Object.keys(branches).sort().forEach(function(b) {
7575 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
7576 });
7577 })();
7578
7579 // ── Filter ────────────────────────────────────────────────────────────
7580 function getFilteredRows() {
7581 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
7582 var branch = ((document.getElementById('branch-filter') || {}).value || '');
7583 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
7584 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
7585 if (branch && (r.dataset.branch || '') !== branch) return false;
7586 return true;
7587 });
7588 }
7589
7590 // ── Pagination ────────────────────────────────────────────────────────
7591 function renderPage() {
7592 var filtered = getFilteredRows();
7593 var total = filtered.length;
7594 var totalPages = Math.max(1, Math.ceil(total / perPage));
7595 currentPage = Math.min(currentPage, totalPages);
7596 var start = (currentPage - 1) * perPage;
7597 var end = Math.min(start + perPage, total);
7598 var shown = {};
7599 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
7600 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
7601 r.style.display = shown[r.dataset.run] ? '' : 'none';
7602 });
7603 var rl = document.getElementById('page-range-label');
7604 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
7605 var info = document.getElementById('pagination-info');
7606 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
7607 var btns = document.getElementById('pagination-btns');
7608 if (!btns) return;
7609 btns.innerHTML = '';
7610 function makeBtn(lbl, pg, active, disabled) {
7611 var b = document.createElement('button');
7612 b.className = 'pg-btn' + (active ? ' active' : '');
7613 b.textContent = lbl; b.disabled = disabled;
7614 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
7615 return b;
7616 }
7617 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
7618 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
7619 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
7620 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
7621 }
7622
7623 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
7624 window.applyFilters = function() { currentPage = 1; renderPage(); };
7625
7626 // ── Sorting ───────────────────────────────────────────────────────────
7627 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
7628 function doSort(col, type, order) {
7629 var tbody = document.getElementById('history-tbody');
7630 if (!tbody) return;
7631 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
7632 rows.sort(function(a, b) {
7633 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
7634 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
7635 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
7636 return va < vb ? 1 : va > vb ? -1 : 0;
7637 });
7638 rows.forEach(function(r) { tbody.appendChild(r); });
7639 currentPage = 1; renderPage();
7640 }
7641 sortHeaders.forEach(function(th) {
7642 th.addEventListener('click', function(e) {
7643 if (e.target.classList.contains('col-resize-handle')) return;
7644 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
7645 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
7646 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
7647 th.classList.add('sort-' + sortOrder);
7648 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
7649 doSort(col, type, sortOrder);
7650 });
7651 });
7652
7653 // ── Column resize ─────────────────────────────────────────────────────
7654 (function() {
7655 var table = document.getElementById('history-table');
7656 if (!table) return;
7657 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
7658 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
7659 ths.forEach(function(th, i) {
7660 var handle = th.querySelector('.col-resize-handle');
7661 if (!handle || !cols[i]) return;
7662 var startX, startW;
7663 handle.addEventListener('mousedown', function(e) {
7664 e.stopPropagation(); e.preventDefault();
7665 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
7666 handle.classList.add('dragging');
7667 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
7668 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
7669 document.addEventListener('mousemove', onMove);
7670 document.addEventListener('mouseup', onUp);
7671 });
7672 });
7673 })();
7674
7675 // ── Reset view ────────────────────────────────────────────────────────
7676 window.resetView = function() {
7677 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
7678 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
7679 sortCol = null; sortOrder = 'asc';
7680 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
7681 var tbody = document.getElementById('history-tbody');
7682 if (tbody) {
7683 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
7684 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
7685 rows.forEach(function(r) { tbody.appendChild(r); });
7686 }
7687 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
7688 var table = document.getElementById('history-table');
7689 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
7690 currentPage = 1; renderPage();
7691 };
7692
7693 renderPage();
7694
7695 (function randomizeWatermarks() {
7696 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7697 if (!wms.length) return;
7698 var placed = [];
7699 function tooClose(t,l){for(var i=0;i<placed.length;i++){if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}return false;}
7700 function pick(lb){for(var a=0;a<50;a++){var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){placed.push([t,l]);return[t,l];}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}
7701 var half=Math.floor(wms.length/2);
7702 wms.forEach(function(img,i){var pos=pick(i<half),sz=Math.floor(Math.random()*80+110),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.07+0.10).toFixed(2);img.style.cssText='width:'+sz+'px;top:'+pos[0].toFixed(1)+'%;left:'+pos[1].toFixed(1)+'%;transform:rotate('+rot+'deg);opacity:'+op+';';});
7703 })();
7704
7705 (function spawnCodeParticles() {
7706 var container = document.getElementById('code-particles');
7707 if (!container) return;
7708 var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
7709 for (var i = 0; i < 38; i++) {
7710 (function(idx) {
7711 var el = document.createElement('span');
7712 el.className = 'code-particle';
7713 el.textContent = snippets[idx % snippets.length];
7714 var left = Math.random() * 94 + 2;
7715 var top = Math.random() * 88 + 6;
7716 var dur = (Math.random() * 10 + 9).toFixed(1);
7717 var delay = (Math.random() * 18).toFixed(1);
7718 var rot = (Math.random() * 26 - 13).toFixed(1);
7719 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7720 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
7721 container.appendChild(el);
7722 })(i);
7723 }
7724 })();
7725 })();
7726
7727 function rowClick(runId, hasHtml) {
7728 if (hasHtml) window.open('/runs/' + runId + '/html', '_blank');
7729 }
7730
7731 function browseReport() {
7732 fetch('/pick-file?kind=report')
7733 .then(function(r) { return r.json(); })
7734 .then(function(data) {
7735 if (!data.cancelled && data.selected_path) {
7736 var form = document.createElement('form');
7737 form.method = 'POST';
7738 form.action = '/locate-report';
7739 var input = document.createElement('input');
7740 input.type = 'hidden';
7741 input.name = 'file_path';
7742 input.value = data.selected_path;
7743 form.appendChild(input);
7744 document.body.appendChild(form);
7745 form.submit();
7746 }
7747 })
7748 .catch(function(e) { alert('Could not open file picker: ' + e); });
7749 }
7750
7751 // ── Export helpers ────────────────────────────────────────────────────────
7752 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
7753 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
7754 function slocDownload(data,name,mime){var b=new Blob([data],{type:mime});var u=URL.createObjectURL(b);var a=document.createElement('a');a.href=u;a.download=name;document.body.appendChild(a);a.click();document.body.removeChild(a);setTimeout(function(){URL.revokeObjectURL(u);},200);}
7755 function slocCsv(fname,hdrs,rows){slocDownload([hdrs.map(slocEscCsv).join(',')].concat(rows.map(function(r){return r.map(slocEscCsv).join(',');})).join('\r\n'),fname,'text/csv;charset=utf-8;');}
7756 function slocXls(fname,sheet,hdrs,rows){var x='<?xml version="1.0"?><Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"><Worksheet ss:Name="'+slocEscXml(sheet)+'"><Table><Row>'+hdrs.map(function(h){return '<Cell><Data ss:Type="String">'+slocEscXml(h)+'</Data></Cell>';}).join('')+'</Row>';rows.forEach(function(r){x+='<Row>'+r.map(function(c,i){var t=(i>0&&c!==''&&!isNaN(String(c).replace(/^[+\-]/,'')))?'Number':'String';return '<Cell><Data ss:Type="'+t+'">'+slocEscXml(c)+'</Data></Cell>';}).join('')+'</Row>';});x+='</Table></Worksheet></Workbook>';slocDownload(x,fname,'application/vnd.ms-excel');}
7757
7758 var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
7759 function getHistoryRows(){var r=[];document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){r.push([tr.getAttribute('data-timestamp')||'',tr.getAttribute('data-project')||'',tr.getAttribute('data-run')||'',tr.getAttribute('data-files')||'',tr.getAttribute('data-skipped')||'',tr.getAttribute('data-code')||'',tr.getAttribute('data-comments')||'',tr.getAttribute('data-blank')||'',tr.getAttribute('data-branch')||'',tr.getAttribute('data-commit')||'']);});return r;}
7760 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
7761 window.exportHistoryXls = function(){slocXls('scan-history.xls','Scan History',_hh,getHistoryRows());};
7762 </script>
7763</body>
7764</html>
7765"##,
7766 ext = "html"
7767)]
7768struct HistoryTemplate {
7769 entries: Vec<HistoryEntryRow>,
7770 total_scans: usize,
7771 linked: bool,
7772}
7773
7774#[derive(Template)]
7777#[template(
7778 source = r##"
7779<!doctype html>
7780<html lang="en">
7781<head>
7782 <meta charset="utf-8">
7783 <meta name="viewport" content="width=device-width, initial-scale=1">
7784 <title>OxideSLOC | Compare Scans</title>
7785 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7786 <style>
7787 :root {
7788 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7789 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7790 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
7791 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7792 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
7793 }
7794 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
7795 *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}
7796 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7797 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
7798 .top-nav{position:sticky;top:0;z-index:30;background:linear-gradient(180deg,var(--nav),var(--nav-2));border-bottom:1px solid rgba(255,255,255,0.12);box-shadow:0 4px 14px rgba(0,0,0,0.18);}
7799 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
7800 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;} .brand-logo{width:42px;height:46px;object-fit:contain;flex:0 0 auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}
7801 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
7802 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;} .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;}
7803 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
7804 .nav-pill,.theme-toggle{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;transition:background .15s ease,transform .15s ease;}
7805 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
7806 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
7807 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
7808 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
7809 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
7810 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
7811 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
7812 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
7813 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
7814 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
7815 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
7816 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
7817 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
7818 .per-page-label{font-size:13px;color:var(--muted);}
7819 select.per-page,.filter-input,.filter-select{border:1px solid var(--line-strong);border-radius:8px;background:var(--surface-2);color:var(--text);padding:5px 10px;font-size:13px;cursor:pointer;}
7820 .filter-input{min-width:180px;cursor:text;}
7821 .table-wrap{width:100%;overflow-x:auto;}
7822 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
7823 th{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);padding:8px 12px;border-bottom:2px solid var(--line);white-space:nowrap;position:relative;user-select:none;}
7824 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--accent-2);}
7825 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
7826 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--accent-2);}
7827 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
7828 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(111,155,255,0.3);}
7829 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
7830 tr:last-child td{border-bottom:none;}
7831 tr.selected td{background:var(--sel-bg);}
7832 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
7833 tr:hover:not(.selected) td{background:var(--surface-2);}
7834 tr{cursor:pointer;}
7835 .run-id-chip{font-family:ui-monospace,monospace;font-size:11px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:2px 7px;color:var(--muted);}
7836 .git-chip{font-family:ui-monospace,monospace;font-size:11px;background:rgba(100,130,220,0.08);border:1px solid rgba(100,130,220,0.20);border-radius:6px;padding:2px 7px;color:var(--accent-2);}
7837 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
7838 .metric-num{font-weight:700;}
7839 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
7840 .sel-badge{display:block;width:22px;height:22px;margin:0 auto;border-radius:6px;border:1.5px solid var(--line-strong);background:var(--surface-2);line-height:20px;text-align:center;font-size:11px;font-weight:900;color:var(--muted-2);transition:background .12s,border-color .12s;}
7841 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
7842 .btn{display:inline-flex;align-items:center;gap:6px;padding:8px 18px;border-radius:8px;font-size:13px;font-weight:700;cursor:pointer;border:1px solid var(--line);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s ease;white-space:nowrap;}
7843 .btn:hover{background:var(--line);}
7844 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
7845 .btn.primary:hover{opacity:.9;}
7846 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
7847 .btn-back{display:inline-flex;align-items:center;gap:7px;padding:7px 14px;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s ease;}
7848 .btn-back:hover{background:var(--line);}
7849 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
7850 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
7851 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
7852 .pagination-info{font-size:13px;color:var(--muted);}
7853 .pagination-btns{display:flex;gap:6px;}
7854 .pg-btn{min-width:34px;min-height:34px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:700;cursor:pointer;transition:background .12s ease;}
7855 .pg-btn:hover:not(:disabled){background:var(--line);}
7856 .pg-btn.active{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
7857 .pg-btn:disabled{opacity:.35;cursor:default;}
7858 .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
7859 .site-footer a{color:var(--muted);}
7860 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
7861 .status-dot{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}
7862 .server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}.server-status-tip::before{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{display:block;}
7863 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
7864 @keyframes floatCode{0%{opacity:0;transform:translateY(0) rotate(var(--rot));}10%{opacity:var(--op);}85%{opacity:var(--op);}100%{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}
7865 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
7866 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
7867 .stat-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .2s ease,box-shadow .2s ease;}
7868 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
7869 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
7870 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
7871 .stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.4;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .2s ease;z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}
7872 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
7873 .stat-chip:hover .stat-chip-tip{opacity:1;}
7874 .sel-count{font-size:11px;background:rgba(255,255,255,0.22);border-radius:999px;padding:1px 8px;font-weight:800;letter-spacing:.02em;margin-left:2px;}
7875 .instruction-bar{background:rgba(111,155,255,0.08);border:1px solid rgba(111,155,255,0.22);border-radius:10px;padding:8px 14px;font-size:13px;color:var(--accent-2);display:inline-flex;align-items:center;gap:8px;margin-bottom:14px;width:fit-content;max-width:100%;}
7876 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
7877 </style>
7878</head>
7879<body>
7880 <div class="background-watermarks" aria-hidden="true">
7881 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7882 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7883 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7884 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7885 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7886 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7887 </div>
7888 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7889 <div class="top-nav">
7890 <div class="top-nav-inner">
7891 <a class="brand" href="/">
7892 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7893 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
7894 </a>
7895 <div class="nav-right">
7896 <a class="nav-pill" href="/">Home</a>
7897 <a class="nav-pill" href="/view-reports">View Reports</a>
7898 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7899 <div class="server-status-wrap">
7900 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
7901 <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
7902 </div>
7903 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7904 <svg class="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
7905 <svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
7906 </button>
7907 </div>
7908 </div>
7909 </div>
7910
7911 <div class="page">
7912 {% if total_scans > 0 %}
7913 <div class="summary-strip">
7914 <div class="stat-chip"><div class="stat-chip-tip">Total scan runs available for comparison</div><div class="stat-chip-val">{{ total_scans }}</div><div class="stat-chip-label">Total scans</div></div>
7915 <div class="stat-chip"><div class="stat-chip-tip">Source lines of code in the most recent scan — excludes comments and blank lines</div><div class="stat-chip-val" id="agg-code">—</div><div class="stat-chip-label">Latest code lines</div></div>
7916 <div class="stat-chip"><div class="stat-chip-tip">Number of source files analyzed in the most recent scan</div><div class="stat-chip-val" id="agg-files">—</div><div class="stat-chip-label">Latest files</div></div>
7917 <div class="stat-chip"><div class="stat-chip-tip">Number of distinct projects tracked across all scans in this workspace</div><div class="stat-chip-val" id="agg-projects">—</div><div class="stat-chip-label">Projects tracked</div></div>
7918 </div>
7919 {% endif %}
7920 <section class="panel">
7921 <div class="panel-header">
7922 <div>
7923 <h1>Compare Scans</h1>
7924 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
7925 </div>
7926 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
7927 <button class="btn primary" id="compare-btn" onclick="doCompare()" disabled>
7928 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="18" y1="20" x2="18" y2="10"></line><line x1="12" y1="20" x2="12" y2="4"></line><line x1="6" y1="20" x2="6" y2="14"></line></svg>
7929 Compare <span class="sel-count" id="sel-count">0/2</span>
7930 </button>
7931 <a class="btn-back" href="/">
7932 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="15 18 9 12 15 6"></polyline></svg>
7933 Home
7934 </a>
7935 </div>
7936 </div>
7937
7938 {% if entries.is_empty() %}
7939 <div class="empty-state">
7940 <strong>No scans yet</strong>
7941 Run your first analysis from the <a href="/scan">scan page</a>.
7942 </div>
7943 {% else %}
7944 <div style="display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;">
7945 <div class="instruction-bar" style="margin-bottom:0;flex-shrink:0;">
7946 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
7947 Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
7948 </div>
7949 <div style="margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
7950 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…" oninput="applyFilters()">
7951 <select class="filter-select" id="branch-filter" onchange="applyFilters()"><option value="">All branches</option></select>
7952 <button type="button" class="btn" onclick="resetView()">↻ Reset view</button>
7953 </div>
7954 </div>
7955 <div class="table-wrap">
7956 <table id="compare-table">
7957 <colgroup>
7958 <col style="width:44px">
7959 <col style="width:165px">
7960 <col style="width:180px">
7961 <col style="width:110px">
7962 <col style="width:100px">
7963 <col style="width:80px">
7964 <col style="width:100px">
7965 <col style="width:90px">
7966 <col style="width:100px">
7967 </colgroup>
7968 <thead>
7969 <tr id="compare-thead">
7970 <th style="text-align:center;padding-left:8px;padding-right:8px;"><div class="col-resize-handle"></div></th>
7971 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7972 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7973 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
7974 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7975 <th class="sortable" data-sort-col="code" data-sort-type="num">Code<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7976 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7977 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7978 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7979 </tr>
7980 </thead>
7981 <tbody id="compare-tbody">
7982 {% for entry in entries %}
7983 <tr class="compare-row" data-run="{{ entry.run_id }}"
7984 data-timestamp="{{ entry.timestamp }}"
7985 data-project="{{ entry.project_label }}"
7986 data-files="{{ entry.files_analyzed }}"
7987 data-code="{{ entry.code_lines }}"
7988 data-comments="{{ entry.comment_lines }}"
7989 data-branch="{{ entry.git_branch }}"
7990 data-commit="{{ entry.git_commit }}"
7991 onclick="toggleRow(this, '{{ entry.run_id }}')">
7992 <td style="text-align:center;padding-left:8px;padding-right:8px;"><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
7993 <td>{{ entry.timestamp }}</td>
7994 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
7995 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
7996 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
7997 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
7998 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
7999 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
8000 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
8001 </tr>
8002 {% endfor %}
8003 </tbody>
8004 </table>
8005 </div>
8006 <div class="pagination">
8007 <span class="pagination-info" id="pagination-info"></span>
8008 <div class="pagination-btns" id="pagination-btns"></div>
8009 <div style="display:flex;align-items:center;gap:8px;">
8010 <span class="per-page-label">Show</span>
8011 <select class="per-page" id="per-page-sel" onchange="setPerPage(this.value)">
8012 <option value="10">10 per page</option>
8013 <option value="25" selected>25 per page</option>
8014 <option value="50">50 per page</option>
8015 <option value="100">100 per page</option>
8016 </select>
8017 <span class="per-page-label" id="page-range-label"></span>
8018 </div>
8019 </div>
8020 {% endif %}
8021 </section>
8022 </div>
8023
8024 <footer class="site-footer">
8025 oxide-sloc — local source line analysis workbench ·
8026 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8027 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8028 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
8029 </footer>
8030
8031 <script>
8032 (function () {
8033 // ── Theme ──────────────────────────────────────────────────────────────
8034 var storageKey = 'oxide-sloc-theme';
8035 var body = document.body;
8036 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
8037 var toggle = document.getElementById('theme-toggle');
8038 if (toggle) toggle.addEventListener('click', function () {
8039 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
8040 body.classList.toggle('dark-theme', next === 'dark');
8041 try { localStorage.setItem(storageKey, next); } catch(e) {}
8042 });
8043
8044 // ── State ─────────────────────────────────────────────────────────────
8045 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
8046 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
8047 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
8048
8049 // ── Stat chips ────────────────────────────────────────────────────────
8050 (function() {
8051 var projects = {}, latestTs = '', latestRow = null;
8052 allRows.forEach(function(r) {
8053 var p = r.dataset.project || ''; if (p) projects[p] = true;
8054 var ts = r.dataset.timestamp || '';
8055 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
8056 });
8057 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
8058 if (latestRow) {
8059 var ce = document.getElementById('agg-code'); if (ce) ce.textContent = Number(latestRow.dataset.code).toLocaleString();
8060 var fe = document.getElementById('agg-files'); if (fe) fe.textContent = latestRow.dataset.files;
8061 }
8062 })();
8063
8064 // ── Branch filter population ──────────────────────────────────────────
8065 (function() {
8066 var branches = {};
8067 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
8068 var sel = document.getElementById('branch-filter');
8069 if (sel) Object.keys(branches).sort().forEach(function(b) {
8070 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
8071 });
8072 })();
8073
8074 // ── Filter ────────────────────────────────────────────────────────────
8075 function getFilteredRows() {
8076 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
8077 var branch = ((document.getElementById('branch-filter') || {}).value || '');
8078 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
8079 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
8080 if (branch && (r.dataset.branch || '') !== branch) return false;
8081 return true;
8082 });
8083 }
8084
8085 // ── Pagination ────────────────────────────────────────────────────────
8086 function renderPage() {
8087 var filtered = getFilteredRows();
8088 var total = filtered.length;
8089 var totalPages = Math.max(1, Math.ceil(total / perPage));
8090 currentPage = Math.min(currentPage, totalPages);
8091 var start = (currentPage - 1) * perPage;
8092 var end = Math.min(start + perPage, total);
8093 var shown = {};
8094 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
8095 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
8096 r.style.display = shown[r.dataset.run] ? '' : 'none';
8097 });
8098 var rl = document.getElementById('page-range-label');
8099 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
8100 var info = document.getElementById('pagination-info');
8101 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
8102 var btns = document.getElementById('pagination-btns');
8103 if (!btns) return;
8104 btns.innerHTML = '';
8105 function makeBtn(lbl, pg, active, disabled) {
8106 var b = document.createElement('button');
8107 b.className = 'pg-btn' + (active ? ' active' : '');
8108 b.textContent = lbl; b.disabled = disabled;
8109 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
8110 return b;
8111 }
8112 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
8113 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
8114 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
8115 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
8116 }
8117
8118 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
8119 window.applyFilters = function() { currentPage = 1; renderPage(); };
8120
8121 // ── Sorting ───────────────────────────────────────────────────────────
8122 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
8123 function doSort(col, type, order) {
8124 var tbody = document.getElementById('compare-tbody');
8125 if (!tbody) return;
8126 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
8127 rows.sort(function(a, b) {
8128 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
8129 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
8130 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
8131 return va < vb ? 1 : va > vb ? -1 : 0;
8132 });
8133 rows.forEach(function(r) { tbody.appendChild(r); });
8134 currentPage = 1; renderPage();
8135 }
8136 sortHeaders.forEach(function(th) {
8137 th.addEventListener('click', function(e) {
8138 if (e.target.classList.contains('col-resize-handle')) return;
8139 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
8140 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
8141 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8142 th.classList.add('sort-' + sortOrder);
8143 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
8144 doSort(col, type, sortOrder);
8145 });
8146 });
8147
8148 // ── Column resize ─────────────────────────────────────────────────────
8149 (function() {
8150 var table = document.getElementById('compare-table');
8151 if (!table) return;
8152 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
8153 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
8154 ths.forEach(function(th, i) {
8155 var handle = th.querySelector('.col-resize-handle');
8156 if (!handle || !cols[i]) return;
8157 var startX, startW;
8158 handle.addEventListener('mousedown', function(e) {
8159 e.stopPropagation(); e.preventDefault();
8160 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
8161 handle.classList.add('dragging');
8162 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
8163 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
8164 document.addEventListener('mousemove', onMove);
8165 document.addEventListener('mouseup', onUp);
8166 });
8167 });
8168 })();
8169
8170 // ── Reset view ────────────────────────────────────────────────────────
8171 window.resetView = function() {
8172 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
8173 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
8174 sortCol = null; sortOrder = 'asc';
8175 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8176 var tbody = document.getElementById('compare-tbody');
8177 if (tbody) {
8178 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
8179 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
8180 rows.forEach(function(r) { tbody.appendChild(r); });
8181 }
8182 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
8183 var table = document.getElementById('compare-table');
8184 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
8185 currentPage = 1; renderPage();
8186 };
8187
8188 renderPage();
8189
8190 (function randomizeWatermarks() {
8191 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8192 if (!wms.length) return;
8193 var placed = [];
8194 function tooClose(t,l){for(var i=0;i<placed.length;i++){if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}return false;}
8195 function pick(lb){for(var a=0;a<50;a++){var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){placed.push([t,l]);return[t,l];}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}
8196 var half=Math.floor(wms.length/2);
8197 wms.forEach(function(img,i){var pos=pick(i<half),sz=Math.floor(Math.random()*80+110),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.07+0.10).toFixed(2);img.style.cssText='width:'+sz+'px;top:'+pos[0].toFixed(1)+'%;left:'+pos[1].toFixed(1)+'%;transform:rotate('+rot+'deg);opacity:'+op+';';});
8198 })();
8199
8200 (function spawnCodeParticles() {
8201 var container = document.getElementById('code-particles');
8202 if (!container) return;
8203 var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
8204 for (var i = 0; i < 38; i++) {
8205 (function(idx) {
8206 var el = document.createElement('span');
8207 el.className = 'code-particle';
8208 el.textContent = snippets[idx % snippets.length];
8209 var left = Math.random() * 94 + 2;
8210 var top = Math.random() * 88 + 6;
8211 var dur = (Math.random() * 10 + 9).toFixed(1);
8212 var delay = (Math.random() * 18).toFixed(1);
8213 var rot = (Math.random() * 26 - 13).toFixed(1);
8214 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
8215 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
8216 container.appendChild(el);
8217 })(i);
8218 }
8219 })();
8220 })();
8221
8222 var selected = [];
8223 function updateCompareBtn() {
8224 var btn = document.getElementById('compare-btn');
8225 var cnt = document.getElementById('sel-count');
8226 if (!btn) return;
8227 btn.disabled = selected.length !== 2;
8228 if (cnt) cnt.textContent = selected.length + '/2';
8229 }
8230
8231 function toggleRow(row, runId) {
8232 var idx = selected.indexOf(runId);
8233 if (idx >= 0) {
8234 selected.splice(idx, 1);
8235 row.classList.remove('selected');
8236 var b = document.getElementById('badge-' + runId);
8237 if (b) b.textContent = '';
8238 } else {
8239 if (selected.length >= 2) return;
8240 selected.push(runId);
8241 row.classList.add('selected');
8242 var b = document.getElementById('badge-' + runId);
8243 if (b) b.textContent = selected.length;
8244 }
8245 selected.forEach(function(id, i) {
8246 var b = document.getElementById('badge-' + id);
8247 if (b) b.textContent = i + 1;
8248 });
8249 updateCompareBtn();
8250 }
8251
8252 function doCompare() {
8253 if (selected.length !== 2) return;
8254 window.location.href = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
8255 }
8256 </script>
8257</body>
8258</html>
8259"##,
8260 ext = "html"
8261)]
8262struct CompareSelectTemplate {
8263 entries: Vec<HistoryEntryRow>,
8264 total_scans: usize,
8265}
8266
8267#[derive(Template)]
8270#[template(
8271 source = r##"
8272<!doctype html>
8273<html lang="en">
8274<head>
8275 <meta charset="utf-8">
8276 <meta name="viewport" content="width=device-width, initial-scale=1">
8277 <title>OxideSLOC | Scan Delta</title>
8278 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8279 <style>
8280 :root {
8281 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
8282 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
8283 --nav:#b85d33; --nav-2:#7a371b;
8284 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
8285 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fdeaea; --zero-bg:transparent;
8286 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
8287 }
8288 body.dark-theme {
8289 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
8290 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#f5a3a3; --neg-bg:#3d1c1c;
8291 }
8292 *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}
8293 .top-nav{position:sticky;top:0;z-index:30;background:linear-gradient(180deg,var(--nav),var(--nav-2));border-bottom:1px solid rgba(255,255,255,0.12);box-shadow:0 4px 14px rgba(0,0,0,0.18);}
8294 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;flex-wrap:wrap;}
8295 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;} .brand-logo{width:42px;height:46px;object-fit:contain;flex:0 0 auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}
8296 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
8297 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;} .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;}
8298 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
8299 .nav-pill,.theme-toggle{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}
8300 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
8301 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
8302 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
8303 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
8304 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
8305 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
8306 .hero{background:linear-gradient(180deg,rgba(255,255,255,0.20),transparent),var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px 28px 28px;margin-bottom:18px;}
8307 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
8308 .hero-body{display:block;}
8309 .btn-back{display:inline-flex;align-items:center;gap:7px;padding:7px 14px;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s ease;white-space:nowrap;}
8310 .btn-back:hover{background:var(--line);}
8311 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;}
8312 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
8313 .muted{color:var(--muted);font-size:14px;}
8314 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
8315 .vpill{display:inline-flex;flex-direction:column;gap:2px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:8px 14px;font-size:13px;}
8316 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
8317 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
8318 .vpill-arrow{font-size:20px;color:var(--muted);}
8319 .delta-strip{display:grid;grid-template-columns:minmax(130px,1fr) minmax(130px,1fr) minmax(110px,0.75fr) minmax(110px,0.75fr) minmax(110px,0.75fr) minmax(180px,1.4fr);gap:12px;width:100%;}
8320 .delta-card{background:var(--surface-2);border:1px solid var(--line);border-radius:14px;padding:14px 16px;display:flex;flex-direction:column;justify-content:center;min-height:116px;position:relative;cursor:default;}
8321 .delta-card.delta-card-wide{padding:14px 18px;}
8322 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);}
8323 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
8324 .delta-card-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:4px;}
8325 .delta-card-from{font-size:12px;color:var(--muted);}
8326 .delta-card-to{font-size:20px;font-weight:800;margin:2px 0;}
8327 .dc-tip{display:none;position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);z-index:200;background:rgba(20,12,8,0.96);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:11.5px;font-weight:500;line-height:1.55;width:230px;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);text-transform:none;letter-spacing:0;}
8328 .dc-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.96);}
8329 .delta-card:hover .dc-tip{display:block;}
8330 .export-btn{display:inline-flex;align-items:center;gap:5px;padding:5px 11px;border-radius:7px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);text-decoration:none;white-space:nowrap;transition:background .12s ease;}
8331 .export-btn:hover{background:var(--line);}
8332 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
8333 .delta-card-change{font-size:13px;font-weight:700;border-radius:6px;padding:1px 7px;display:inline-block;margin-top:2px;}
8334 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
8335 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
8336 .delta-card-change.zero{color:var(--muted);background:transparent;}
8337 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
8338 .fc-row{display:flex;align-items:center;gap:8px;}
8339 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
8340 .fc-label{color:var(--muted);}
8341 .fc-modified .fc-count{color:#926000;}
8342 .fc-added .fc-count{color:var(--pos);}
8343 .fc-removed .fc-count{color:var(--neg);}
8344 .fc-unchanged .fc-count{color:var(--muted);}
8345 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
8346 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
8347 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
8348 .chip.modified{background:#fff2d8;color:#926000;}
8349 .chip.added{background:#e8f5ed;color:#1a8f47;}
8350 .chip.removed{background:#fdeaea;color:#b33b3b;}
8351 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
8352 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
8353 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
8354 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
8355 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
8356 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
8357 .tab-btn{padding:6px 16px;border-radius:8px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:600;cursor:pointer;transition:background .12s ease;}
8358 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
8359 .tab-btn:hover:not(.active){background:var(--line);}
8360 .btn-reset{padding:6px 14px;border-radius:8px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:700;cursor:pointer;transition:background .12s ease;white-space:nowrap;}
8361 .btn-reset:hover{background:var(--line);}
8362 .table-wrap{width:100%;overflow-x:auto;}
8363 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
8364 th{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);padding:8px 10px;border-bottom:2px solid var(--line);white-space:nowrap;position:relative;user-select:none;}
8365 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
8366 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
8367 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
8368 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
8369 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
8370 td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
8371 tr:last-child td{border-bottom:none;}
8372 tr.row-added td{background:rgba(26,143,71,0.06);}
8373 tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
8374 tr.row-modified td{background:rgba(146,96,0,0.05);}
8375 tr.row-unchanged td{opacity:.6;}
8376 .file-path{font-family:ui-monospace,monospace;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
8377 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
8378 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
8379 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
8380 .status-badge.modified{background:#fff2d8;color:#926000;}
8381 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
8382 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
8383 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
8384 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
8385 .delta-val{font-weight:700;}
8386 .delta-val.pos{color:var(--pos);}
8387 .delta-val.neg{color:var(--neg);}
8388 .delta-val.zero{color:var(--muted);}
8389 .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
8390 .from-to strong{color:var(--text);}
8391 .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
8392 .site-footer a{color:var(--muted);}
8393 @media(max-width:1400px){.delta-strip{grid-template-columns:repeat(3,1fr);}}
8394 @media(max-width:900px){.delta-strip{grid-template-columns:repeat(2,1fr);}}
8395 @media(max-width:600px){.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
8396 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
8397 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
8398 .status-dot{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}
8399 .server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}.server-status-tip::before{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{display:block;}
8400 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
8401 @keyframes floatCode{0%{opacity:0;transform:translateY(0) rotate(var(--rot));}10%{opacity:var(--op);}85%{opacity:var(--op);}100%{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}
8402 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
8403 .path-link:hover{color:var(--oxide-2);}
8404 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
8405 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
8406 a.vpill-id:hover{color:var(--oxide);}
8407 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
8408 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
8409 .pagination-info{font-size:13px;color:var(--muted);}
8410 .pagination-btns{display:flex;gap:6px;}
8411 .pg-btn{min-width:34px;min-height:34px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:700;cursor:pointer;transition:background .12s ease;}
8412 .pg-btn:hover:not(:disabled){background:var(--line);}
8413 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
8414 .pg-btn:disabled{opacity:.35;cursor:default;}
8415 .per-page-label{font-size:13px;color:var(--muted);}
8416 select.per-page{border:1px solid var(--line-strong);border-radius:8px;background:var(--surface-2);color:var(--text);padding:5px 10px;font-size:13px;cursor:pointer;}
8417 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
8418 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
8419 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
8420 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
8421 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
8422 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
8423 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
8424 .tab-btn.tab-unchanged{color:var(--muted);}
8425 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
8426 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
8427 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
8428 </style>
8429</head>
8430<body>
8431 <div class="background-watermarks" aria-hidden="true">
8432 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8433 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8434 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8435 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8436 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8437 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8438 </div>
8439 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8440 <div class="top-nav">
8441 <div class="top-nav-inner">
8442 <a class="brand" href="/">
8443 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
8444 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
8445 </a>
8446 <div class="nav-right">
8447 <a class="nav-pill" href="/">Home</a>
8448 <a class="nav-pill" href="/view-reports">View Reports</a>
8449 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
8450 <div class="server-status-wrap">
8451 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
8452 <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
8453 </div>
8454 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
8455 <svg class="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
8456 <svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
8457 </button>
8458 </div>
8459 </div>
8460 </div>
8461
8462 <div class="page">
8463 <section class="hero">
8464 <div class="hero-header">
8465 <div>
8466 <h1 style="margin:0 0 6px;">Scan Delta</h1>
8467 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
8468 <span class="muted" style="font-size:13px;">Comparing two scans of</span>
8469 <a class="path-link" data-folder="{{ project_path }}" href="#" onclick="fetch('/open-path?path='+encodeURIComponent(this.dataset.folder));return false;" style="font-size:13px;font-weight:700;">{{ project_path }}</a>
8470 </div>
8471 <div style="display:flex;align-items:center;gap:8px;margin-top:10px;flex-wrap:wrap;">
8472 <span style="font-size:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:4px 10px;color:var(--muted);">
8473 <span style="color:var(--text);font-weight:700;">Baseline</span> {{ baseline_timestamp }}
8474 </span>
8475 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--muted);flex:0 0 auto;"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
8476 <span style="font-size:12px;background:var(--surface-2);border:1px solid var(--oxide);border-radius:8px;padding:4px 10px;color:var(--muted);">
8477 <span style="color:var(--oxide);font-weight:700;">Current</span> {{ current_timestamp }}
8478 </span>
8479 </div>
8480 </div>
8481 <a class="btn-back" href="/compare-scans">
8482 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="15 18 9 12 15 6"></polyline></svg>
8483 Compare Scans
8484 </a>
8485 </div>
8486 <div class="hero-body">
8487 <div class="delta-strip">
8488 <div class="delta-card delta-card-meta">
8489 <div class="delta-card-label">Baseline</div>
8490 <div class="delta-card-to" style="font-size:15px;line-height:1.2;">{{ baseline_timestamp }}</div>
8491 <a class="vpill-id" href="/runs/{{ baseline_run_id }}/html" target="_blank">{{ baseline_run_id_short }}</a>
8492 {% if !baseline_git_branch.is_empty() %}<span class="vpill-meta">Branch: {{ baseline_git_branch }}</span>{% endif %}
8493 {% if let Some(author) = baseline_git_author %}<span class="vpill-meta">Last commit by: {{ author }}</span>{% endif %}
8494 {% if let Some(tags) = baseline_git_tags %}<span class="vpill-meta">Tags: {{ tags }}</span>{% endif %}
8495 </div>
8496 <div class="delta-card delta-card-meta">
8497 <div class="delta-card-label">Current</div>
8498 <div class="delta-card-to" style="font-size:15px;line-height:1.2;">{{ current_timestamp }}</div>
8499 <a class="vpill-id" href="/runs/{{ current_run_id }}/html" target="_blank">{{ current_run_id_short }}</a>
8500 {% if !current_git_branch.is_empty() %}<span class="vpill-meta">Branch: {{ current_git_branch }}</span>{% endif %}
8501 {% if let Some(author) = current_git_author %}<span class="vpill-meta">Last commit by: {{ author }}</span>{% endif %}
8502 {% if let Some(tags) = current_git_tags %}<span class="vpill-meta">Tags: {{ tags }}</span>{% endif %}
8503 </div>
8504 <div class="delta-card">
8505 <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
8506 <div class="delta-card-label">Code lines</div>
8507 <div class="delta-card-from">Before: {{ baseline_code }}</div>
8508 <div class="delta-card-to">{{ current_code }}</div>
8509 {% if code_lines_delta_class == "pos" %}<span class="delta-card-change pos">{{ code_lines_delta_str }}</span>
8510 {% else if code_lines_delta_class == "neg" %}<span class="delta-card-change neg">{{ code_lines_delta_str }}</span>
8511 {% endif %}
8512 </div>
8513 <div class="delta-card">
8514 <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
8515 <div class="delta-card-label">Files analyzed</div>
8516 <div class="delta-card-from">Before: {{ baseline_files }}</div>
8517 <div class="delta-card-to">{{ current_files }}</div>
8518 {% if files_analyzed_delta_class == "pos" %}<span class="delta-card-change pos">{{ files_analyzed_delta_str }}</span>
8519 {% else if files_analyzed_delta_class == "neg" %}<span class="delta-card-change neg">{{ files_analyzed_delta_str }}</span>
8520 {% endif %}
8521 </div>
8522 <div class="delta-card">
8523 <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
8524 <div class="delta-card-label">Comment lines</div>
8525 <div class="delta-card-from">Before: {{ baseline_comments }}</div>
8526 <div class="delta-card-to">{{ current_comments }}</div>
8527 {% if comment_lines_delta_class == "pos" %}<span class="delta-card-change pos">{{ comment_lines_delta_str }}</span>
8528 {% else if comment_lines_delta_class == "neg" %}<span class="delta-card-change neg">{{ comment_lines_delta_str }}</span>
8529 {% endif %}
8530 </div>
8531 <div class="delta-card delta-card-wide">
8532 <div class="dc-tip">Per-file breakdown. Modified = at least one count changed. Unchanged = identical counts in both scans. Added/Removed = only in one scan.</div>
8533 <div class="delta-card-label">File changes</div>
8534 <div class="file-changes-grid">
8535 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
8536 <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
8537 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
8538 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
8539 </div>
8540 </div>
8541 </div>
8542 </div>
8543 </section>
8544
8545 <section class="panel">
8546 <h2>File-level delta</h2>
8547 <div class="filter-tabs-row">
8548 <div class="filter-tabs">
8549 <button class="tab-btn tab-all active" onclick="filterRows('all', this)">All</button>
8550 <button class="tab-btn tab-modified" onclick="filterRows('modified', this)">Modified ({{ files_modified }})</button>
8551 <button class="tab-btn tab-added" onclick="filterRows('added', this)">Added ({{ files_added }})</button>
8552 <button class="tab-btn tab-removed" onclick="filterRows('removed', this)">Removed ({{ files_removed }})</button>
8553 <button class="tab-btn tab-unchanged" onclick="filterRows('unchanged', this)">Unchanged ({{ files_unchanged }})</button>
8554 </div>
8555 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
8556 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
8557 <div class="export-group">
8558 <button type="button" class="btn-reset" onclick="resetDeltaTable()">↻ Reset</button>
8559 <button type="button" class="export-btn" onclick="exportDeltaCsv()">
8560 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
8561 CSV
8562 </button>
8563 <button type="button" class="export-btn" onclick="exportDeltaXls()">
8564 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
8565 Excel
8566 </button>
8567 <button type="button" class="export-btn" onclick="exportDeltaCharts()">
8568 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="2" y1="20" x2="22" y2="20"/><rect x="3" y="13" width="4" height="7" rx="1"/><rect x="10" y="7" width="4" height="13" rx="1"/><rect x="17" y="2" width="4" height="18" rx="1"/></svg>
8569 Charts
8570 </button>
8571 </div>
8572 </div>
8573 </div>
8574
8575 <div class="table-wrap">
8576 <table id="delta-table">
8577 <colgroup>
8578 <col style="width:34%">
8579 <col style="width:10%">
8580 <col style="width:9%">
8581 <col style="width:15%">
8582 <col style="width:8%">
8583 <col style="width:8%">
8584 <col style="width:8%">
8585 </colgroup>
8586 <thead>
8587 <tr id="delta-thead">
8588 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
8589 <th class="sortable hide-sm" data-sort-col="language" data-sort-type="str">Language<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
8590 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
8591 <th class="sortable" data-sort-col="baseline_code" data-sort-type="num">Code before → after<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
8592 <th class="sortable" data-sort-col="code_delta" data-sort-type="num">Code Δ<sup>*</sup><span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
8593 <th class="sortable hide-sm" data-sort-col="comment_delta" data-sort-type="num">Comment Δ<sup>*</sup><span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
8594 <th class="sortable" data-sort-col="total_delta" data-sort-type="num">Total Δ<sup>*</sup><span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
8595 </tr>
8596 </thead>
8597 <tbody id="delta-tbody">
8598 {% for row in file_rows %}
8599 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
8600 data-path="{{ row.relative_path }}"
8601 data-language="{{ row.language }}"
8602 data-baseline-code="{{ row.baseline_code }}"
8603 data-current-code="{{ row.current_code }}"
8604 data-code-delta="{{ row.code_delta_str }}"
8605 data-comment-delta="{{ row.comment_delta_str }}"
8606 data-total-delta="{{ row.total_delta_str }}"
8607 data-orig-idx="">
8608 <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
8609 <td class="hide-sm">{{ row.language }}</td>
8610 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
8611 <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
8612 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
8613 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
8614 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
8615 </tr>
8616 {% endfor %}
8617 </tbody>
8618 </table>
8619 </div>
8620 <div class="pagination">
8621 <span class="pagination-info" id="pg-info"></span>
8622 <div class="pagination-btns" id="pg-btns"></div>
8623 <div style="display:flex;align-items:center;gap:8px;">
8624 <span class="per-page-label">Show</span>
8625 <select class="per-page" id="per-page-sel" onchange="setDeltaPerPage(this.value)">
8626 <option value="10">10 per page</option>
8627 <option value="25" selected>25 per page</option>
8628 <option value="50">50 per page</option>
8629 <option value="100">100 per page</option>
8630 </select>
8631 <span class="per-page-label" id="pg-range-label"></span>
8632 </div>
8633 </div>
8634 </section>
8635 </div>
8636
8637 <footer class="site-footer">
8638 oxide-sloc — local source line analysis workbench ·
8639 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8640 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8641 </footer>
8642
8643 <script>
8644 (function () {
8645 var storageKey = 'oxide-sloc-theme';
8646 var body = document.body;
8647 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
8648 var toggle = document.getElementById('theme-toggle');
8649 if (toggle) toggle.addEventListener('click', function () {
8650 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
8651 body.classList.toggle('dark-theme', next === 'dark');
8652 try { localStorage.setItem(storageKey, next); } catch(e) {}
8653 });
8654
8655 (function randomizeWatermarks() {
8656 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8657 if (!wms.length) return;
8658 var placed = [];
8659 function tooClose(t,l){for(var i=0;i<placed.length;i++){if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}return false;}
8660 function pick(lb){for(var a=0;a<50;a++){var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){placed.push([t,l]);return[t,l];}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}
8661 var half=Math.floor(wms.length/2);
8662 wms.forEach(function(img,i){var pos=pick(i<half),sz=Math.floor(Math.random()*80+110),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.07+0.10).toFixed(2);img.style.cssText='width:'+sz+'px;top:'+pos[0].toFixed(1)+'%;left:'+pos[1].toFixed(1)+'%;transform:rotate('+rot+'deg);opacity:'+op+';';});
8663 })();
8664
8665 (function spawnCodeParticles() {
8666 var container = document.getElementById('code-particles');
8667 if (!container) return;
8668 var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
8669 for (var i = 0; i < 38; i++) {
8670 (function(idx) {
8671 var el = document.createElement('span');
8672 el.className = 'code-particle';
8673 el.textContent = snippets[idx % snippets.length];
8674 var left = Math.random() * 94 + 2;
8675 var top = Math.random() * 88 + 6;
8676 var dur = (Math.random() * 10 + 9).toFixed(1);
8677 var delay = (Math.random() * 18).toFixed(1);
8678 var rot = (Math.random() * 26 - 13).toFixed(1);
8679 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
8680 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
8681 container.appendChild(el);
8682 })(i);
8683 }
8684 })();
8685 })();
8686
8687 var activeStatusFilter = 'all';
8688 var deltaPerPage = 25, deltaCurrPage = 1;
8689
8690 function openFolder(path) {
8691 fetch('/open-path?path=' + encodeURIComponent(path)).catch(function(){});
8692 }
8693
8694 function getDeltaFilteredRows() {
8695 return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
8696 return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
8697 });
8698 }
8699
8700 function renderDeltaPage() {
8701 var filtered = getDeltaFilteredRows();
8702 var total = filtered.length;
8703 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
8704 deltaCurrPage = Math.min(deltaCurrPage, totalPages);
8705 var start = (deltaCurrPage - 1) * deltaPerPage;
8706 var end = Math.min(start + deltaPerPage, total);
8707 var shownSet = {};
8708 filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
8709 Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
8710 r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
8711 });
8712 var rl = document.getElementById('pg-range-label');
8713 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
8714 var info = document.getElementById('pg-info');
8715 if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
8716 var btns = document.getElementById('pg-btns');
8717 if (!btns) return;
8718 btns.innerHTML = '';
8719 if (totalPages <= 1) return;
8720 function makeBtn(lbl, pg, active, disabled) {
8721 var b = document.createElement('button');
8722 b.className = 'pg-btn' + (active ? ' active' : '');
8723 b.textContent = lbl; b.disabled = disabled;
8724 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
8725 return b;
8726 }
8727 btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
8728 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
8729 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
8730 btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
8731 }
8732
8733 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
8734
8735 function filterRows(status, btn) {
8736 activeStatusFilter = status;
8737 deltaCurrPage = 1;
8738 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
8739 b.classList.remove('active');
8740 });
8741 if (btn) btn.classList.add('active');
8742 renderDeltaPage();
8743 }
8744
8745 // ── Sorting ──────────────────────────────────────────────────────────────
8746 var sortCol = null, sortOrder = 'asc';
8747 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
8748 (function() {
8749 var tbody = document.getElementById('delta-tbody');
8750 if (!tbody) return;
8751 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
8752 rows.forEach(function(r, i) { r.dataset.origIdx = i; });
8753 })();
8754
8755 function parseDeltaNum(str) {
8756 if (!str || str === '—') return 0;
8757 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
8758 }
8759
8760 sortHeaders.forEach(function(th) {
8761 th.addEventListener('click', function(e) {
8762 if (e.target.classList.contains('col-resize-handle')) return;
8763 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
8764 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
8765 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8766 th.classList.add('sort-' + sortOrder);
8767 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
8768 var tbody = document.getElementById('delta-tbody');
8769 if (!tbody) return;
8770 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
8771 rows.sort(function(a, b) {
8772 var va, vb;
8773 if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
8774 else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
8775 else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
8776 else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
8777 else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
8778 else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
8779 else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
8780 else { va = ''; vb = ''; }
8781 if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
8782 return va < vb ? 1 : va > vb ? -1 : 0;
8783 });
8784 rows.forEach(function(r) { tbody.appendChild(r); });
8785 deltaCurrPage = 1;
8786 renderDeltaPage();
8787 var activeBtn = document.querySelector('.tab-btn.active');
8788 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
8789 if (activeBtn) activeBtn.classList.add('active');
8790 });
8791 });
8792
8793 // ── Column resize ─────────────────────────────────────────────────────────
8794 (function() {
8795 var table = document.getElementById('delta-table');
8796 if (!table) return;
8797 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
8798 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
8799 ths.forEach(function(th, i) {
8800 var handle = th.querySelector('.col-resize-handle');
8801 if (!handle || !cols[i]) return;
8802 var startX, startW;
8803 handle.addEventListener('mousedown', function(e) {
8804 e.stopPropagation(); e.preventDefault();
8805 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
8806 handle.classList.add('dragging');
8807 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
8808 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
8809 document.addEventListener('mousemove', onMove);
8810 document.addEventListener('mouseup', onUp);
8811 });
8812 });
8813 })();
8814
8815 // ── Reset ─────────────────────────────────────────────────────────────────
8816 window.resetDeltaTable = function() {
8817 sortCol = null; sortOrder = 'asc';
8818 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8819 var tbody = document.getElementById('delta-tbody');
8820 if (tbody) {
8821 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
8822 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
8823 rows.forEach(function(r) { tbody.appendChild(r); });
8824 }
8825 var table = document.getElementById('delta-table');
8826 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
8827 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
8828 activeStatusFilter = 'all';
8829 deltaCurrPage = 1;
8830 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
8831 var allBtn = document.querySelector('.tab-btn');
8832 if (allBtn) allBtn.classList.add('active');
8833 renderDeltaPage();
8834 };
8835
8836 renderDeltaPage();
8837
8838 // ── Export helpers ────────────────────────────────────────────────────────
8839 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
8840 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
8841 function slocDownload(data,name,mime){var b=new Blob([data],{type:mime});var u=URL.createObjectURL(b);var a=document.createElement('a');a.href=u;a.download=name;document.body.appendChild(a);a.click();document.body.removeChild(a);setTimeout(function(){URL.revokeObjectURL(u);},200);}
8842 function slocMakeXlsx(fname,sd,dr){
8843 var enc=new TextEncoder();
8844 // CRC-32 table
8845 var CT=[];for(var _n=0;_n<256;_n++){var _c=_n;for(var _k=0;_k<8;_k++)_c=_c&1?0xEDB88320^(_c>>>1):_c>>>1;CT[_n]=_c;}
8846 function crc32(d){var v=0xFFFFFFFF;for(var i=0;i<d.length;i++)v=CT[(v^d[i])&0xFF]^(v>>>8);return(v^0xFFFFFFFF)>>>0;}
8847 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
8848 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
8849 // Shared string table
8850 var ss=[],si={};
8851 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
8852 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
8853 // Worksheet builder — each WS() call gets its own row counter R
8854 function WS(){
8855 var R=0,buf=[];
8856 function cl(c){return String.fromCharCode(65+c);}
8857 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
8858 '<v>'+S(v)+'</v></c>';}
8859 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
8860 (st?' s="'+st+'"':'')+'>'+
8861 '<v>'+(+v)+'</v></c>';}
8862 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
8863 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
8864 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
8865 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
8866 '<sheetFormatPr defaultRowHeight="15"/>'+
8867 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
8868 return{sc:sc,nc:nc,row:row,xml:xml};
8869 }
8870 // Language breakdown
8871 var lm={};
8872 dr.forEach(function(r){var l=r[1]||'Unknown',d=parseInt(r[5])||0;if(!lm[l])lm[l]={f:0,d:0};lm[l].f++;lm[l].d+=d;});
8873 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
8874 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
8875 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
8876 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
8877 // Summary sheet
8878 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
8879 r1(s1(0,'OxideSLOC — Scan Delta Report',1));
8880 r1(s1(0,proj,2));
8881 r1(s1(0,sd.bts+' → '+sd.cts,2));
8882 r1('');
8883 r1(s1(0,'Metric',3)+s1(1,'Baseline',3)+s1(2,'Current',3)+s1(3,'Delta',3));
8884 r1(s1(0,'Code Lines')+n1(1,sd.bc,4)+n1(2,sd.cc,4)+s1(3,sd.cd,dstyle(sd.cd)));
8885 r1(s1(0,'Files Analyzed')+n1(1,sd.bf,4)+n1(2,sd.cf,4)+s1(3,sd.fd,dstyle(sd.fd)));
8886 r1(s1(0,'Comment Lines')+n1(1,sd.bcm,4)+n1(2,sd.ccm,4)+s1(3,sd.cmd,dstyle(sd.cmd)));
8887 r1('');
8888 r1(s1(0,'FILE CHANGES',8));
8889 r1(s1(0,'Category',3)+s1(3,'Count',3));
8890 r1(s1(0,'Modified')+n1(3,sd.fm,4));
8891 r1(s1(0,'Added')+n1(3,sd.fa,4));
8892 r1(s1(0,'Removed')+n1(3,sd.fr,4));
8893 r1(s1(0,'Unchanged')+n1(3,sd.fu,4));
8894 if(langs.length){
8895 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
8896 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
8897 langs.forEach(function(l){var e=lm[l],dv=e.d>=0?'+'+e.d:String(e.d);r1(s1(0,l)+n1(1,e.f,4)+s1(2,dv,dstyle(dv)));});
8898 }
8899 r1('');r1(s1(0,'SCAN METADATA',8));
8900 r1(s1(1,'Baseline')+s1(2,'Current'));
8901 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
8902 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
8903 var sh1=W1.xml('<col min="1" max="1" width="24" customWidth="1"/><col min="2" max="4" width="14" customWidth="1"/>');
8904 // File Delta sheet
8905 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
8906 r2(s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3)+s2(3,'Code Before',3)+s2(4,'Code After',3)+s2(5,'Code Delta',3)+s2(6,'Comment Delta',3)+s2(7,'Total Delta',3));
8907 dr.forEach(function(r){r2(s2(0,r[0])+s2(1,r[1])+s2(2,r[2])+n2(3,r[3],4)+n2(4,r[4],4)+s2(5,r[5],dstyle(r[5]))+s2(6,r[6],dstyle(r[6]))+s2(7,r[7],dstyle(r[7])));});
8908 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="8" width="13" customWidth="1"/>');
8909 // Shared strings XML
8910 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
8911 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
8912 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
8913 // XLSX file map
8914 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
8915 var F={'[Content_Types].xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="'+pns+'content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>',
8916 '_rels/.rels':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships"><Relationship Id="rId1" Type="'+ons+'relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>',
8917 'xl/_rels/workbook.xml.rels':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships"><Relationship Id="rId1" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet2.xml"/><Relationship Id="rId3" Type="'+ons+'relationships/styles" Target="styles.xml"/><Relationship Id="rId4" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/></Relationships>',
8918 'xl/workbook.xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><bookViews><workbookView xWindow="0" yWindow="0" windowWidth="16384" windowHeight="8192"/></bookViews><sheets><sheet name="Summary" sheetId="1" r:id="rId1"/><sheet name="File Delta" sheetId="2" r:id="rId2"/></sheets></workbook>',
8919 'xl/styles.xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'"><fonts count="8"><font><sz val="11"/><name val="Calibri"/></font><font><sz val="14"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font><font><sz val="10"/><color rgb="FF888888"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FF155724"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FF721C24"/><name val="Calibri"/></font><font><sz val="11"/><color rgb="FF888888"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font></fonts><fills count="5"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFD4EDDA"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFF8D7DA"/></patternFill></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="9"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="3" fillId="2" borderId="0" xfId="0" applyFill="1"><alignment horizontal="left"/></xf><xf numFmtId="3" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="4" fillId="3" borderId="0" xfId="0" applyFill="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="5" fillId="4" borderId="0" xfId="0" applyFill="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="6" fillId="0" borderId="0" xfId="0"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="7" fillId="0" borderId="0" xfId="0"/></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>',
8920 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
8921 // ZIP packer — STORED (no compression), compatible with all XLSX readers
8922 var zparts=[],zcds=[],zoff=0,znf=0;
8923 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
8924 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
8925 ].forEach(function(name){
8926 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
8927 var lha=[0x50,0x4B,0x03,0x04,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0]);
8928 var entry=new Uint8Array(lha.length+nb.length+sz);
8929 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
8930 zparts.push(entry);
8931 var cda=[0x50,0x4B,0x01,0x02,0x14,0,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0,0,0,0,0,0,0,0,0,0,0]).concat(u4(zoff));
8932 var cde=new Uint8Array(cda.length+nb.length);
8933 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
8934 zcds.push(cde);zoff+=entry.length;znf++;
8935 });
8936 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
8937 var ea=[0x50,0x4B,0x05,0x06,0,0,0,0].concat(u2(znf)).concat(u2(znf)).concat(u4(cdSz)).concat(u4(zoff)).concat([0,0]);
8938 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
8939 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
8940 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
8941 zout.set(new Uint8Array(ea),zpos);
8942 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
8943 var xurl=URL.createObjectURL(xblob);
8944 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
8945 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
8946 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
8947 }
8948 function slocCsvMulti(fname,sections){var parts=[];sections.forEach(function(sec,idx){if(idx>0){parts.push('');parts.push('');}parts.push(sec.hdrs.map(slocEscCsv).join(','));sec.rows.forEach(function(r){parts.push(r.map(slocEscCsv).join(','));});});slocDownload(parts.join('\r\n'),fname,'text/csv;charset=utf-8;');}
8949 function getExportFilename(ext){var el=document.querySelector('[data-folder]');var path=el?el.getAttribute('data-folder'):'project';var slug=(path.replace(/\\/g,'/').split('/').filter(Boolean).pop()||'project').replace(/[^a-zA-Z0-9_-]/g,'-').toLowerCase();return slug+'_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}.'+ext;}
8950
8951 var _summaryHdrs = ['Metric','Baseline','Current','Delta'];
8952 var _sd = {bc:{{ baseline_code }},cc:{{ current_code }},cd:'{{ code_lines_delta_str }}',bf:{{ baseline_files }},cf:{{ current_files }},fd:'{{ files_analyzed_delta_str }}',bcm:{{ baseline_comments }},ccm:{{ current_comments }},cmd:'{{ comment_lines_delta_str }}',fm:{{ files_modified }},fa:{{ files_added }},fr:{{ files_removed }},fu:{{ files_unchanged }},bts:'{{ baseline_timestamp }}',cts:'{{ current_timestamp }}',bid:'{{ baseline_run_id_short }}',cid:'{{ current_run_id_short }}'};
8953 function getSummaryExportRows(){return[['Code Lines',String(_sd.bc),String(_sd.cc),_sd.cd],['Files Analyzed',String(_sd.bf),String(_sd.cf),_sd.fd],['Comment Lines',String(_sd.bcm),String(_sd.ccm),_sd.cmd],['Modified Files','','',String(_sd.fm)],['Added Files','','',String(_sd.fa)],['Removed Files','','',String(_sd.fr)],['Unchanged Files','','',String(_sd.fu)]];}
8954 var _dh = ['File','Language','Status','Code Before','Code After','Code Delta','Comment Delta','Total Delta'];
8955 function getDeltaExportRows(){var r=[];document.querySelectorAll('#delta-tbody .delta-row').forEach(function(tr){r.push([tr.getAttribute('data-path')||'',tr.getAttribute('data-language')||'',tr.getAttribute('data-status')||'',tr.getAttribute('data-baseline-code')||'',tr.getAttribute('data-current-code')||'',tr.getAttribute('data-code-delta')||'',tr.getAttribute('data-comment-delta')||'',tr.getAttribute('data-total-delta')||'']);});return r;}
8956 window.exportDeltaCsv = function(){slocCsvMulti(getExportFilename('csv'),[{hdrs:_summaryHdrs,rows:getSummaryExportRows()},{hdrs:_dh,rows:getDeltaExportRows()}]);};
8957 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
8958
8959 // ── Chart HTML report ─────────────────────────────────────────────────────
8960 function slocChartReport(fname, sd, dr) {
8961 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
8962 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
8963 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
8964 function fmt(n){return Number(n).toLocaleString();}
8965 function px(n){return Math.round(n);}
8966 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
8967 // Language map
8968 var lm={};
8969 dr.forEach(function(r){var l=r[1]||'Unknown',d=parseInt(r[5])||0;if(!lm[l])lm[l]={f:0,d:0};lm[l].f++;lm[l].d+=d;});
8970 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
8971
8972 // Builds onmouse* attrs for interactive tooltip on each SVG element
8973 function barTT(label,val){
8974 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
8975 }
8976
8977 // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
8978 var c1mets=[{l:'Code Lines',b:sd.bc,c:sd.cc},{l:'Files Analyzed',b:sd.bf,c:sd.cf},{l:'Comments',b:sd.bcm,c:sd.ccm}];
8979 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
8980 var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
8981 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
8982 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
8983 for(var gi=1;gi<=4;gi++){var gy=c1mt+c1ph*(1-gi/4);c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';}
8984 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
8985 c1mets.forEach(function(m,i){
8986 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
8987 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
8988 c1+='<text x="'+cx+'" y="14" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="#444">'+esc(m.l)+'</text>';
8989 c1+='<rect class="cb" x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+GY+'" rx="3"'+barTT(m.l,'Baseline: '+fmt(m.b))+'/>';
8990 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-3)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="#666">'+fmt(m.b)+'</text>';
8991 c1+='<rect class="cb" x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+OX+'" rx="3"'+barTT(m.l,'Current: '+fmt(m.c))+'/>';
8992 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-3)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+OX+'">'+fmt(m.c)+'</text>';
8993 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="#999">Before</text>';
8994 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+OX+'">After</text>';
8995 });
8996 c1+='</svg>';
8997
8998 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
8999 var mets=[{l:'Code Lines',v:sd.cc-sd.bc},{l:'Files Analyzed',v:sd.cf-sd.bf},{l:'Comment Lines',v:sd.ccm-sd.bcm}];
9000 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
9001 var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
9002 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
9003 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9004 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9005 mets.forEach(function(m,i){
9006 var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
9007 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
9008 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
9009 c2+='<text x="'+(c2LW-8)+'" y="'+(y+21)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="12" fill="#444">'+esc(m.l)+'</text>';
9010 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
9011 if(bw>=52){
9012 c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+25)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';
9013 }else{
9014 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
9015 c2+='<text x="'+vx2+'" y="'+(y+25)+'" text-anchor="'+anc2+'" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
9016 }
9017 });
9018 c2+='</svg>';
9019
9020 // ── Chart 3: Language Code Delta ─────────────────────────────────────
9021 var c3='';
9022 if(langs.length){
9023 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
9024 var C3W=550,c3LW=124,c3FW=52;
9025 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
9026 var L3rH=30,C3H=langs.length*L3rH+20;
9027 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9028 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9029 langs.forEach(function(l,i){
9030 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
9031 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
9032 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
9033 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
9034 c3+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="20" fill="'+col+'" rx="3"'+barTT(l,'Delta: '+vStr+' code lines • '+e.f+' file'+(e.f!==1?'s':''))+'/>';
9035 if(bw>=48){
9036 c3+='<text x="'+px(bx+bw/2)+'" y="'+(y+19)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';
9037 }else{
9038 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
9039 c3+='<text x="'+vx3+'" y="'+(y+19)+'" text-anchor="'+anc3+'" font-family="Inter,Calibri,Arial" font-size="10" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
9040 }
9041 c3+='<text x="'+(C3W-5)+'" y="'+(y+19)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="9" fill="#AAA">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
9042 });
9043 c3+='</svg>';
9044 }
9045
9046 // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
9047 var segs=[{l:'Modified',v:sd.fm,c:OX},{l:'Added',v:sd.fa,c:GN},{l:'Removed',v:sd.fr,c:RD},{l:'Unchanged',v:sd.fu,c:'#CCCCCC'}].filter(function(s){return s.v>0;});
9048 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
9049 var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
9050 var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9051 var ang=-Math.PI/2;
9052 segs.forEach(function(s){
9053 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
9054 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
9055 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
9056 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
9057 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
9058 c4+='<path class="cb" d="M'+px(x1)+','+px(y1)+' A'+Ro+','+Ro+' 0 '+(sw>Math.PI?1:0)+',1 '+px(x2)+','+px(y2)+' L'+px(xi1)+','+px(yi1)+' A'+Ri+','+Ri+' 0 '+(sw>Math.PI?1:0)+',0 '+px(xi2)+','+px(yi2)+' Z" fill="'+s.c+'" stroke="white" stroke-width="2.5"'+barTT(s.l,fmt(s.v)+' files • '+px(s.v/tot*100)+'%')+'/>';
9059 ang+=sw;
9060 });
9061 c4+='<text x="'+cx4+'" y="'+(cy4-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="22" font-weight="bold" fill="#333">'+fmt(tot)+'</text>';
9062 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
9063 segs.forEach(function(s,i){c4+='<rect x="234" y="'+(16+i*44)+'" width="14" height="14" fill="'+s.c+'" rx="2"/><text x="252" y="'+(27+i*44)+'" font-family="Inter,Calibri,Arial" font-size="12" fill="#333">'+esc(s.l)+': '+fmt(s.v)+'</text>';});
9064 c4+='</svg>';
9065
9066 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
9067 var ttJs='var tt=document.getElementById("ox-tt");'+
9068 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
9069 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
9070 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
9071 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
9072 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
9073 'function oxHT(){tt.style.display="none";}';
9074
9075 // body max-width keeps charts from inflating beyond design dimensions on
9076 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
9077 // each chart's height blows up proportionally, breaking the one-page layout.
9078 var css='*{box-sizing:border-box;}body{font-family:Inter,Calibri,Arial,sans-serif;margin:0 auto;padding:20px 30px 24px;max-width:1460px;background:#F7F3EE;color:#333;}'+
9079 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
9080 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
9081 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
9082 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
9083 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
9084 'svg{display:block;}'+
9085 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
9086 '#ox-tt{display:none;position:fixed;background:rgba(15,10,6,.95);color:#fff;border-radius:8px;padding:7px 11px;font-size:12px;line-height:1.5;pointer-events:none;z-index:9999;box-shadow:0 4px 16px rgba(0,0,0,.28);border:1px solid rgba(255,255,255,.08);max-width:240px;white-space:nowrap;}'+
9087 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
9088 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
9089 '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
9090 '<div id="ox-tt"><\/div>'+
9091 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
9092 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
9093 '<div class="two-col">'+
9094 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
9095 '<div class="leg"><span><span class="dot" style="background:#AAAAAA"><\/span>Baseline<\/span>'+
9096 '<span><span class="dot" style="background:#C45C10"><\/span>Current<\/span><\/div>'+c1+'<\/div>'+
9097 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
9098 '<\/div>'+
9099 '<div class="two-col">'+
9100 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
9101 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
9102 '<\/div>'+
9103 '<script>'+ttJs+'<\/script>'+
9104 '<\/body><\/html>';
9105 slocDownload(html, fname, 'text/html;charset=utf-8;');
9106 }
9107 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
9108 </script>
9109</body>
9110</html>
9111"##,
9112 ext = "html"
9113)]
9114struct CompareTemplate {
9115 baseline_run_id: String,
9116 current_run_id: String,
9117 baseline_run_id_short: String,
9118 current_run_id_short: String,
9119 baseline_timestamp: String,
9120 current_timestamp: String,
9121 project_path: String,
9122 baseline_code: u64,
9123 current_code: u64,
9124 code_lines_delta_str: String,
9125 code_lines_delta_class: String,
9126 baseline_files: u64,
9127 current_files: u64,
9128 files_analyzed_delta_str: String,
9129 files_analyzed_delta_class: String,
9130 baseline_comments: u64,
9131 current_comments: u64,
9132 comment_lines_delta_str: String,
9133 comment_lines_delta_class: String,
9134 files_added: usize,
9135 files_removed: usize,
9136 files_modified: usize,
9137 files_unchanged: usize,
9138 file_rows: Vec<CompareFileDeltaRow>,
9139 baseline_git_author: Option<String>,
9140 current_git_author: Option<String>,
9141 baseline_git_branch: String,
9142 current_git_branch: String,
9143 baseline_git_tags: Option<String>,
9144 current_git_tags: Option<String>,
9145}