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 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
3586 .top-nav, .page, .loading { position: relative; z-index: 2; }
3587 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
3588 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
3589 .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); }
3590 .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; }
3591 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
3592 .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)); }
3593 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
3594 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
3595 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
3596 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
3597 .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; }
3598 .nav-project-pill.visible { display:inline-flex; }
3599 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
3600 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; }
3601 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: wrap; }
3602 .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; }
3603 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
3604 .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; }
3605 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
3606 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
3607 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
3608 .theme-toggle .icon-sun { display:none; }
3609 body.dark-theme .theme-toggle .icon-sun { display:block; }
3610 body.dark-theme .theme-toggle .icon-moon { display:none; }
3611 .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; }
3612 .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;}
3613 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
3614 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
3615 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
3616 .workbench-box { border: 1px solid var(--line-strong); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
3617 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
3618 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; }
3619 .wb-stats-header { padding: 10px 24px 0; }
3620 .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
3621 .ws-left { display:flex; align-items:center; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
3622 .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); }
3623 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
3624 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
3625 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
3626 .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; }
3627 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
3628 .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; }
3629 .ws-badge:hover .ws-lang-tooltip { display:block; }
3630 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:9px; }
3631 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
3632 .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; }
3633 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
3634 .ws-divider { display: none; }
3635 .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%; }
3636 .ws-path-link:hover { color:var(--oxide); }
3637 body.dark-theme .ws-path-link { color:var(--oxide); }
3638 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
3639 .ws-stat-output .ws-value { overflow:hidden; display:block; }
3640 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
3641 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
3642 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
3643 .ws-mini-box-lg { flex:2 1 0; }
3644 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
3645 .ws-mini-box-br { flex:1.5 1 0; }
3646 .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); }
3647 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
3648 .path-scope-grid { display:grid; grid-template-columns: 1fr 1px auto; gap:0; align-items:stretch; }
3649 .path-scope-grid .input-group { width:100%; align-self:start; }
3650 .path-scope-sep { background:var(--line); margin:4px 14px; }
3651 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
3652 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
3653 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
3654 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
3655 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
3656 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
3657 .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; }
3658 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
3659 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
3660 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
3661 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
3662 .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; }
3663 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
3664 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
3665 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
3666 .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; }
3667 .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); }
3668 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
3669 .side-info-card { padding: 18px; }
3670 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
3671 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
3672 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
3673 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
3674 .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); }
3675 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
3676 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
3677 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
3678 .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; }
3679 .layout { display:grid; grid-template-columns: 218px minmax(0, 1fr); gap: 18px; align-items:stretch; min-height: calc(100vh - 57px); }
3680 .side-stack { display:grid; gap: 16px; align-items:start; align-self: stretch; }
3681 .step-nav { padding: 20px 16px; position: sticky; top: 57px; z-index: 25; }
3682 .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); }
3683 .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; }
3684 .step-button:hover { background: var(--surface-2); }
3685 .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); }
3686 .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; }
3687 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
3688 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
3689 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
3690 .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); }
3691 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
3692 .step-nav-sum-row:last-child { border-bottom:none; }
3693 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
3694 .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; }
3695 .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
3696 .quick-scan-section { padding: 10px 4px 14px; }
3697 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
3698 .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; }
3699 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
3700 .quick-scan-btn:active { transform:translateY(0); }
3701 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
3702 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
3703 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
3704 @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);} }
3705 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
3706 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
3707 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
3708 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
3709 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
3710 .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; }
3711 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
3712 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
3713 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
3714 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
3715 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
3716 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
3717 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
3718 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
3719 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
3720 .card-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
3721 .card-body { padding: 22px; }
3722 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
3723 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
3724 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
3725 .section { margin-bottom: 22px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
3726 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
3727 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
3728 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
3729 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
3730 .field { min-width:0; }
3731 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
3732 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; }
3733 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); }
3734 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
3735 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); }
3736 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
3737 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
3738 .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; }
3739 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
3740 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
3741 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
3742 .input-group.compact { grid-template-columns: 1fr auto auto; }
3743 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
3744 .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)); }
3745 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
3746 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
3747 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
3748 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
3749 .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; }
3750 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
3751 .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; }
3752 .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); }
3753 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
3754 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
3755 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
3756 button.secondary { background: var(--surface); }
3757 .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); }
3758 .section + .wizard-actions { border-top: none; padding-top: 0; }
3759 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
3760 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
3761 .field-help-grid.coupled-help { margin-top: 12px; }
3762 .field-help-grid.preset-grid { align-items: start; }
3763 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:stretch; margin-bottom: 16px; }
3764 .preset-inline-row .field { margin: 0; }
3765 .preset-inline-row .explainer-card { margin: 0; }
3766 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
3767 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
3768 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
3769 .output-field-row .field { margin: 0; }
3770 .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; }
3771 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
3772 .step3-subtitle { margin-bottom: 28px; }
3773 .counting-intro { margin-bottom: 22px; max-width: none; }
3774 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
3775 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
3776 .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; }
3777 .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; }
3778 .section-spacer-top { margin-top: 28px; }
3779 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
3780 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
3781 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
3782 .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); }
3783 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
3784 .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; }
3785 .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; }
3786 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
3787 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
3788 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
3789 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
3790 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
3791 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
3792 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
3793 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; }
3794 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
3795 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
3796 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
3797 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
3798 .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); }
3799 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
3800 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
3801 .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; }
3802 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
3803 .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; }
3804 .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; }
3805 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
3806 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
3807 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
3808 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
3809 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
3810 .advanced-rule-description strong { color: var(--text); }
3811 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
3812 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
3813 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
3814 .review-link:hover { text-decoration: underline; }
3815 .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; }
3816 .artifact-card { position:relative; padding: 16px; cursor:pointer; }
3817 .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
3818 .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; }
3819 .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
3820 .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; }
3821 .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
3822 .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
3823 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
3824 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
3825 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
3826 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
3827 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
3828 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
3829 .review-card ul { padding-left: 18px; margin: 0; }
3830 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
3831 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
3832 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
3833 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
3834 .review-card { min-height: 200px; }
3835 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
3836 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
3837 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
3838 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
3839 .lang-overflow-chip { position:relative; cursor:default; }
3840 .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; }
3841 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
3842 .git-inline-row { align-items:start; }
3843 .mixed-line-card { display:flex; flex-direction:column; }
3844 .preset-inline-row .toggle-card { justify-content: center; }
3845 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
3846 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
3847 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
3848 .explorer-title { font-size: 18px; font-weight: 850; }
3849 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
3850 .explorer-subtitle.wide { max-width: none; }
3851 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
3852 .better-spacing { align-items:flex-start; justify-content:flex-end; }
3853 .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; }
3854 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
3855 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
3856 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
3857 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
3858 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
3859 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
3860 .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; }
3861 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
3862 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
3863 .scope-stat-button.supported { background: var(--success-bg); }
3864 .scope-stat-button.skipped { background: var(--warn-bg); }
3865 .scope-stat-button.unsupported { background: var(--danger-bg); }
3866 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
3867 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
3868 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
3869 [data-tooltip] { position: relative; }
3870 [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); }
3871 [data-tooltip]:hover::after { display: block; }
3872 .scope-stat-button[data-tooltip] { cursor: pointer; }
3873 .badge[data-tooltip] { cursor: help; }
3874 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
3875 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
3876 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
3877 .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; }
3878 .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; }
3879 code { display:inline-block; margin-top:0; padding:2px 7px; }
3880 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
3881 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
3882 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
3883 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
3884 .language-pill.muted-pill { color: var(--muted); }
3885 button.language-pill { appearance:none; cursor:pointer; }
3886 .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); }
3887 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
3888 .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; }
3889 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
3890 .file-explorer-search-row { margin-left: auto; }
3891 .explorer-filter-select { min-width: 170px; width: 170px; }
3892 .explorer-search { min-width: 300px; width: 300px; }
3893 .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); }
3894 .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; }
3895 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
3896 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
3897 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
3898 .file-explorer-tree { max-height: 560px; overflow:auto; }
3899 .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); }
3900 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
3901 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
3902 .tree-row.hidden-by-filter { display:none !important; }
3903 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 9px 0; }
3904 .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; }
3905 .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; }
3906 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
3907 .tree-bullet { color: var(--muted-2); width: 28px; text-align:center; flex: 0 0 28px; font-size: 14px; }
3908 .tree-node { display:inline-flex; align-items:center; min-width:0; }
3909 .tree-node-dir { color: var(--text); font-weight: 800; }
3910 .tree-node-supported { color: var(--success-text); }
3911 .tree-node-skipped { color: var(--warn-text); }
3912 .tree-node-unsupported { color: var(--danger-text); }
3913 .tree-node-more { color: var(--muted-2); font-style: italic; }
3914 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 13px; }
3915 .tree-status-cell { display:flex; justify-content:flex-start; }
3916 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
3917 .loading { position: fixed; inset: 0; display:none; align-items:center; justify-content:center; background: rgba(17,24,39,0.28); z-index: 100; }
3918 .loading.active { display:flex; }
3919 .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; }
3920 .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; }
3921 @keyframes spin { to { transform: rotate(360deg);} }
3922 .progress-bar { width:100%; height:8px; margin-top:14px; background: var(--surface-3); border-radius:999px; overflow:hidden; }
3923 .progress-bar span { display:block; width:42%; height:100%; background: linear-gradient(90deg, var(--accent), #6b8cff); animation: pulseBar 1.4s ease-in-out infinite; }
3924 @keyframes pulseBar { 0% { transform: translateX(-35%); width:25%; } 50% { transform: translateX(130%); width:44%; } 100% { transform: translateX(250%); width:25%; } }
3925 .hidden { display:none !important; }
3926 .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; }
3927 .site-footer a { color: var(--muted-2); font-weight: 700; text-decoration: none; }
3928 .site-footer a:hover { color: var(--text); text-decoration: underline; }
3929 @media (max-width: 1280px) { .layout { grid-template-columns: 200px 1fr; } .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
3930 @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; } }
3931 .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;}
3932 @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));}}
3933 </style>
3934</head>
3935<body>
3936 <div class="background-watermarks" aria-hidden="true">
3937 <img src="/images/logo/logo-text.png" alt="" />
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 </div>
3952 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
3953 <div class="top-nav">
3954 <div class="top-nav-inner">
3955 <a class="brand" href="/">
3956 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
3957 <div class="brand-copy">
3958 <div class="brand-title">OxideSLOC</div>
3959 <div class="brand-subtitle">Local analysis workbench</div>
3960 </div>
3961 </a>
3962 <div class="nav-project-slot">
3963 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
3964 <span class="nav-project-label">Project</span>
3965 <span class="nav-project-value" id="nav-project-title">samples/basic</span>
3966 </div>
3967 </div>
3968 <div class="nav-status">
3969 <a class="nav-pill" href="/">Home</a>
3970 <a class="nav-pill" href="/view-reports">View Reports</a>
3971 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
3972 <div class="server-status-wrap">
3973 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
3974 <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>
3975 </div>
3976 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
3977 <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>
3978 <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>
3979 </button>
3980 </div>
3981 </div>
3982 </div>
3983
3984 <div class="loading" id="loading">
3985 <div class="loading-card">
3986 <div class="spinner"></div>
3987 <h2>Scanning project...</h2>
3988 <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>
3989 <div class="progress-bar"><span></span></div>
3990 </div>
3991 </div>
3992
3993 <div class="page">
3994 <div class="workbench-strip">
3995 <div class="workbench-box wb-stats">
3996 <div class="wb-stats-header">
3997 <span class="wb-stats-title">Analysis session</span>
3998 </div>
3999 <div class="ws-left">
4000 <div class="ws-stat">
4001 <span class="ws-label">Analyzers</span>
4002 <span class="ws-value">
4003 <span class="ws-badge">41 languages
4004 <div class="ws-lang-tooltip">
4005 <div class="ws-lang-tooltip-hdr">41 supported languages</div>
4006 <div class="ws-lang-grid">
4007 <span class="ws-lang-item">Assembly</span>
4008 <span class="ws-lang-item">C</span>
4009 <span class="ws-lang-item">C++</span>
4010 <span class="ws-lang-item">C#</span>
4011 <span class="ws-lang-item">Clojure</span>
4012 <span class="ws-lang-item">CSS</span>
4013 <span class="ws-lang-item">Dart</span>
4014 <span class="ws-lang-item">Dockerfile</span>
4015 <span class="ws-lang-item">Elixir</span>
4016 <span class="ws-lang-item">Erlang</span>
4017 <span class="ws-lang-item">F#</span>
4018 <span class="ws-lang-item">Go</span>
4019 <span class="ws-lang-item">Groovy</span>
4020 <span class="ws-lang-item">Haskell</span>
4021 <span class="ws-lang-item">HTML</span>
4022 <span class="ws-lang-item">Java</span>
4023 <span class="ws-lang-item">JavaScript</span>
4024 <span class="ws-lang-item">Julia</span>
4025 <span class="ws-lang-item">Kotlin</span>
4026 <span class="ws-lang-item">Lua</span>
4027 <span class="ws-lang-item">Makefile</span>
4028 <span class="ws-lang-item">Nim</span>
4029 <span class="ws-lang-item">Obj-C</span>
4030 <span class="ws-lang-item">OCaml</span>
4031 <span class="ws-lang-item">Perl</span>
4032 <span class="ws-lang-item">PHP</span>
4033 <span class="ws-lang-item">PowerShell</span>
4034 <span class="ws-lang-item">Python</span>
4035 <span class="ws-lang-item">R</span>
4036 <span class="ws-lang-item">Ruby</span>
4037 <span class="ws-lang-item">Rust</span>
4038 <span class="ws-lang-item">Scala</span>
4039 <span class="ws-lang-item">SCSS</span>
4040 <span class="ws-lang-item">Shell</span>
4041 <span class="ws-lang-item">SQL</span>
4042 <span class="ws-lang-item">Svelte</span>
4043 <span class="ws-lang-item">Swift</span>
4044 <span class="ws-lang-item">TypeScript</span>
4045 <span class="ws-lang-item">Vue</span>
4046 <span class="ws-lang-item">XML</span>
4047 <span class="ws-lang-item">Zig</span>
4048 </div>
4049 </div>
4050 </span>
4051 </span>
4052 </div>
4053 <div class="ws-divider"></div>
4054 <div class="ws-stat"><span class="ws-label">Mode</span><span class="ws-value">Localhost workbench</span></div>
4055 <div class="ws-divider"></div>
4056 <div class="ws-stat"><span class="ws-label">Active project</span><span class="ws-value" id="live-report-title">—</span></div>
4057 <div class="ws-divider"></div>
4058 <div class="ws-stat ws-stat-output">
4059 <span class="ws-label">Output</span>
4060 <span class="ws-value">
4061 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
4062 <span id="ws-output-root">project/sloc</span>
4063 </button>
4064 </span>
4065 </div>
4066 </div>
4067 </div>
4068 <div class="workbench-box ws-history-group">
4069 <div class="ws-history-label">Scan history</div>
4070 <div class="ws-history-inner">
4071 <div class="ws-mini-box ws-mini-box-sm">
4072 <div class="ws-mini-label">Scans</div>
4073 <div class="ws-mini-value" id="ws-scan-count">—</div>
4074 </div>
4075 <div class="ws-mini-box ws-mini-box-lg">
4076 <div class="ws-mini-label">Last Scan</div>
4077 <div class="ws-mini-value" id="ws-last-scan">—</div>
4078 </div>
4079 <div class="ws-mini-box ws-mini-box-br">
4080 <div class="ws-mini-label">Branch</div>
4081 <div class="ws-mini-value" id="ws-branch">—</div>
4082 </div>
4083 </div>
4084 </div>
4085 </div>
4086
4087 <div class="layout">
4088 <aside class="side-stack">
4089 <section class="step-nav">
4090 <h3>Guided scan setup</h3>
4091 <button type="button" class="step-button active" data-step-target="1"><span class="step-num">1</span><span>Select project</span></button>
4092 <button type="button" class="step-button" data-step-target="2"><span class="step-num">2</span><span>Counting rules</span></button>
4093 <button type="button" class="step-button" data-step-target="3"><span class="step-num">3</span><span>Outputs and reports</span></button>
4094 <button type="button" class="step-button" data-step-target="4"><span class="step-num">4</span><span>Review and run</span></button>
4095
4096 <div class="step-nav-info" id="step-nav-info">
4097 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
4098 <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>
4099 </div>
4100
4101 <div class="step-nav-summary" id="step-nav-summary" style="display:none;">
4102 <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>
4103 <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>
4104 <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>
4105 </div>
4106
4107 <div class="quick-scan-divider"></div>
4108 <div class="quick-scan-section">
4109 <div class="quick-scan-label">No customization needed?</div>
4110 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
4111 <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>
4112 Quick Scan
4113 </button>
4114 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
4115 </div>
4116 </section>
4117
4118 </aside>
4119
4120 <section class="card">
4121 <div class="card-header">
4122 <div class="card-title-row">
4123 <div>
4124 <h1 class="card-title">Guided scan configuration</h1>
4125 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
4126 </div>
4127 <div class="wizard-progress" aria-label="Scan setup progress">
4128 <div class="wizard-progress-top">
4129 <span class="wizard-progress-label">Setup progress</span>
4130 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
4131 </div>
4132 <div class="wizard-progress-track">
4133 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
4134 </div>
4135 </div>
4136 </div>
4137 </div>
4138 <div class="card-body">
4139 <form method="post" action="/analyze" id="analyze-form">
4140 <div class="wizard-step active" data-step="1">
4141 <div class="section">
4142 <div class="section-kicker">Step 1</div>
4143 <h2>Select project and preview scope</h2>
4144 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
4145 <div class="field" style="margin:10px 0 0;">
4146 <label for="path">Project path</label>
4147 <div class="path-scope-grid">
4148 <div class="input-group">
4149 <input id="path" name="path" type="text" value="samples/basic" placeholder="/path/to/repository" required />
4150 <button type="button" class="mini-button oxide" id="browse-path">Browse</button>
4151 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
4152 </div>
4153 <div class="path-scope-sep"></div>
4154 <div class="scope-legend-row">
4155 <span class="scope-legend-label">Scope legend:</span>
4156 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
4157 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
4158 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
4159 </div>
4160 </div>
4161 <div class="hint">Browse opens the native folder picker through the Rust backend, so you do not need to type local paths manually.</div>
4162 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
4163 </div>
4164
4165 <div style="height:1px;background:var(--line);margin:28px 0;"></div>
4166
4167 <div id="preview-panel" style="margin-top:0;">
4168 <div class="preview-error">Loading preview...</div>
4169 </div>
4170 </div>
4171
4172 <div class="section">
4173 <div class="field-grid">
4174 <div class="field">
4175 <label for="include_globs">Include globs</label>
4176 <textarea id="include_globs" name="include_globs" placeholder="examples: src/**/*.py scripts/*.sh"></textarea>
4177 <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>
4178 </div>
4179 <div class="field">
4180 <label for="exclude_globs">Exclude globs</label>
4181 <textarea id="exclude_globs" name="exclude_globs" placeholder="examples: vendor/** **/*.min.js"></textarea>
4182 <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>
4183 </div>
4184 </div>
4185 <div class="glob-guidance-grid">
4186 <div class="glob-guidance-card">
4187 <strong>How to read them</strong>
4188 <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>
4189 </div>
4190 <div class="glob-guidance-card">
4191 <strong>Common include examples</strong>
4192 <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
4193 </div>
4194 <div class="glob-guidance-card">
4195 <strong>Common exclude examples</strong>
4196 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
4197 </div>
4198 </div>
4199 </div>
4200
4201 <div class="section" style="margin-top:14px;">
4202 <div class="preset-inline-row git-inline-row">
4203 <div class="toggle-card" style="margin:0;">
4204 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
4205 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
4206 <label class="checkbox">
4207 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
4208 <div>
4209 <span>Detect and separate git submodules</span>
4210 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
4211 </div>
4212 </label>
4213 </div>
4214 <div class="explainer-card prominent" style="margin:0;">
4215 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
4216 <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>
4217 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
4218 path = libs/core
4219 url = https://github.com/org/core.git
4220
4221[submodule "libs/ui"]
4222 path = libs/ui
4223 url = https://github.com/org/ui.git</div>
4224 </div>
4225 </div>
4226 </div>
4227
4228 <div class="wizard-actions">
4229 <div class="left"></div>
4230 <div class="right">
4231 <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
4232 </div>
4233 </div>
4234 </div>
4235
4236 <div class="wizard-step" data-step="2">
4237 <div class="section">
4238 <div class="section-kicker">Step 2</div>
4239 <h2>Choose counting behavior</h2>
4240 <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>
4241 <div class="subsection-bar">Primary line classification</div>
4242 <div class="preset-inline-row" style="align-items:start;">
4243 <div class="toggle-card mixed-line-card" style="margin:0;">
4244 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
4245 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
4246 <select id="mixed_line_policy" name="mixed_line_policy">
4247 <option value="code_only">Code only</option>
4248 <option value="code_and_comment">Code and comment</option>
4249 <option value="comment_only">Comment only</option>
4250 <option value="separate_mixed_category">Separate mixed category</option>
4251 </select>
4252 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
4253 </div>
4254 <div class="explainer-card prominent" style="margin:0;">
4255 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
4256 <div class="explainer-body" id="mixed-policy-description"></div>
4257 <div class="code-sample" id="mixed-policy-example"></div>
4258 </div>
4259 </div>
4260 </div>
4261
4262 <div class="subsection-bar">Additional scan rules</div>
4263 <div class="scan-rules-grid">
4264 <div class="preset-inline-row">
4265 <div class="toggle-card" style="margin:0;">
4266 <div class="field-help-title">Generated files</div>
4267 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
4268 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
4269 </div>
4270 <div class="explainer-card prominent" style="margin:0;">
4271 <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>
4272 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
4273# Files matching codegen patterns are excluded:
4274# *.generated.cs *.pb.go *.g.dart</div>
4275 </div>
4276 </div>
4277 <div class="preset-inline-row">
4278 <div class="toggle-card" style="margin:0;">
4279 <div class="field-help-title">Minified files</div>
4280 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
4281 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
4282 </div>
4283 <div class="explainer-card prominent" style="margin:0;">
4284 <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>
4285 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
4286# Heuristic: very long lines + low whitespace ratio
4287# jquery.min.js bundle.min.css → skipped</div>
4288 </div>
4289 </div>
4290 <div class="preset-inline-row">
4291 <div class="toggle-card" style="margin:0;">
4292 <div class="field-help-title">Vendor directories</div>
4293 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
4294 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
4295 </div>
4296 <div class="explainer-card prominent" style="margin:0;">
4297 <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>
4298 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
4299# Directories named vendor/ node_modules/ third_party/
4300# → entire subtree is excluded from totals</div>
4301 </div>
4302 </div>
4303 <div class="preset-inline-row">
4304 <div class="toggle-card" style="margin:0;">
4305 <div class="field-help-title">Lockfiles and manifests</div>
4306 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
4307 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
4308 </div>
4309 <div class="explainer-card prominent" style="margin:0;">
4310 <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>
4311 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
4312# Files like package-lock.json Cargo.lock yarn.lock
4313# → skipped unless this is enabled</div>
4314 </div>
4315 </div>
4316 <div class="preset-inline-row">
4317 <div class="toggle-card" style="margin:0;">
4318 <div class="field-help-title">Binary handling</div>
4319 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
4320 <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>
4321 </div>
4322 <div class="explainer-card prominent" style="margin:0;">
4323 <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>
4324 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
4325# Detected via long lines + low whitespace heuristic
4326# .png .exe .so → skipped silently</div>
4327 </div>
4328 </div>
4329 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
4330 <div class="toggle-card" style="margin:0;">
4331 <div class="field-help-title">Python docstrings</div>
4332 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
4333 <label class="checkbox">
4334 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
4335 <span>Count as comment-style lines</span>
4336 </label>
4337 </div>
4338 <div class="explainer-card prominent" style="margin:0;">
4339 <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>
4340 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
4341 </div>
4342 </div>
4343 </div>
4344 <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px;">
4345 <div class="always-tracked-tip">
4346 <div class="always-tracked-tip-icon">ℹ</div>
4347 <div class="always-tracked-tip-body">
4348 <div class="field-help-title">Always tracked — not configurable</div>
4349 <h4>Comment and blank-line basics</h4>
4350 <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>
4351 </div>
4352 </div>
4353 <div class="always-tracked-tip">
4354 <div class="always-tracked-tip-icon">→</div>
4355 <div class="always-tracked-tip-body">
4356 <div class="field-help-title">What these settings change</div>
4357 <h4>Lines on the boundary</h4>
4358 <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>
4359 </div>
4360 </div>
4361 </div>
4362
4363 <div class="wizard-actions">
4364 <div class="left">
4365 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
4366 </div>
4367 <div class="right">
4368 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
4369 </div>
4370 </div>
4371 </div>
4372
4373 <div class="wizard-step" data-step="3">
4374 <div class="section">
4375 <div class="section-kicker">Step 3</div>
4376 <h2>Output and report identity</h2>
4377 <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>
4378 <div class="preset-inline-row" style="align-items:start;">
4379 <div class="toggle-card" style="margin:0;">
4380 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
4381 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
4382 <select id="scan_preset">
4383 <option value="balanced">Balanced local scan</option>
4384 <option value="code_focused">Code focused</option>
4385 <option value="comment_audit">Comment audit</option>
4386 <option value="deep_review">Deep review</option>
4387 </select>
4388 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
4389 </div>
4390 <div class="explainer-card">
4391 <div class="field-help-title">Selected scan preset</div>
4392 <div class="explainer-body" id="scan-preset-description"></div>
4393 <div class="preset-summary-row" id="scan-preset-summary"></div>
4394 <div class="code-sample" id="scan-preset-example"></div>
4395 <div class="preset-note" id="scan-preset-note"></div>
4396 </div>
4397 </div>
4398 <hr class="step3-separator" />
4399 <div class="preset-inline-row" style="align-items:start;">
4400 <div class="toggle-card" style="margin:0;">
4401 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
4402 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
4403 <select id="artifact_preset">
4404 <option value="review">Review bundle</option>
4405 <option value="full">Full bundle</option>
4406 <option value="html_only">HTML only</option>
4407 <option value="machine">Machine bundle</option>
4408 </select>
4409 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
4410 </div>
4411 <div class="explainer-card">
4412 <div class="field-help-title">Selected artifact preset</div>
4413 <div class="explainer-body" id="artifact-preset-description"></div>
4414 <div class="preset-summary-row" id="artifact-preset-summary"></div>
4415 <div class="code-sample" id="artifact-preset-example"></div>
4416 </div>
4417 </div>
4418 </div>
4419
4420 <div class="section section-spacer-top">
4421 <div class="output-field-row">
4422 <div class="field">
4423 <label for="output_dir">Output directory</label>
4424 <div class="input-group compact">
4425 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
4426 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
4427 <button type="button" class="mini-button" id="use-default-output">Use default</button>
4428 </div>
4429 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
4430 </div>
4431 <div class="output-field-aside">
4432 <strong>Where reports land</strong>
4433 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.
4434 </div>
4435 </div>
4436 </div>
4437
4438 <div class="section section-spacer-top">
4439 <div class="output-field-row">
4440 <div class="field">
4441 <label for="report_title">Report title</label>
4442 <input id="report_title" name="report_title" type="text" value="samples/basic" placeholder="Project report title" />
4443 <div class="hint">Appears in HTML and PDF output headers.</div>
4444 </div>
4445 <div class="output-field-aside">
4446 <strong>Shown in exported artifacts</strong>
4447 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.
4448 </div>
4449 </div>
4450 </div>
4451
4452 <div class="section">
4453 <div class="section-kicker">Artifacts</div>
4454 <div class="artifact-grid">
4455 <div class="artifact-card selected" data-artifact="html">
4456 <div class="marker">✓</div>
4457 <div class="artifact-icon">H</div>
4458 <h4>HTML report</h4>
4459 <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
4460 <div class="artifact-tags">
4461 <span class="soft-chip">Best for visual review</span>
4462 <span class="soft-chip">Embeddable preview</span>
4463 </div>
4464 <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
4465 </div>
4466 <div class="artifact-card selected" data-artifact="pdf">
4467 <div class="marker">✓</div>
4468 <div class="artifact-icon">P</div>
4469 <h4>PDF export</h4>
4470 <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
4471 <div class="artifact-tags">
4472 <span class="soft-chip">Portable snapshot</span>
4473 <span class="soft-chip">Good for handoff</span>
4474 </div>
4475 <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
4476 </div>
4477 <div class="artifact-card selected" data-artifact="json" style="opacity:0.75;pointer-events:none;">
4478 <div class="marker" style="background:var(--oxide);border-color:var(--oxide);color:#fff;">✓</div>
4479 <div class="artifact-icon">J</div>
4480 <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--oxide-2);">Always on</span></h4>
4481 <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
4482 <div class="artifact-tags">
4483 <span class="soft-chip">Required for compare</span>
4484 <span class="soft-chip">Auto-enabled</span>
4485 </div>
4486 <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
4487 </div>
4488 </div>
4489 <div class="hint" style="margin-top:16px;">Artifact cards are selectable. Presets above can also toggle them for common workflows.</div>
4490 </div>
4491
4492 <div class="wizard-actions">
4493 <div class="left">
4494 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
4495 </div>
4496 <div class="right">
4497 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
4498 </div>
4499 </div>
4500 </div>
4501
4502 <div class="wizard-step" data-step="4">
4503 <div class="section">
4504 <div class="section-kicker">Step 4</div>
4505 <h2>Review selections and run</h2>
4506 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
4507 <div class="review-grid">
4508 <div class="review-card highlight">
4509 <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>
4510 <ul id="review-scan-summary"></ul>
4511 </div>
4512 <div class="review-card highlight">
4513 <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>
4514 <ul id="review-count-summary"></ul>
4515 </div>
4516 <div class="review-card">
4517 <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>
4518 <ul id="review-artifact-summary"></ul>
4519 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
4520 </div>
4521 <div class="review-card">
4522 <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>
4523 <ul id="review-preview-summary"></ul>
4524 </div>
4525 </div>
4526 </div>
4527
4528 <div class="wizard-actions">
4529 <div class="left">
4530 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
4531 </div>
4532 <div class="right">
4533 <button type="submit" id="submit-button" class="primary">Run analysis</button>
4534 </div>
4535 </div>
4536 </div></form>
4537 </div>
4538 </section>
4539 </div>
4540 </div>
4541
4542 <script>
4543 (function () {
4544 var form = document.getElementById("analyze-form");
4545 var loading = document.getElementById("loading");
4546 var submitButton = document.getElementById("submit-button");
4547 var pathInput = document.getElementById("path");
4548 var outputDirInput = document.getElementById("output_dir");
4549 var reportTitleInput = document.getElementById("report_title");
4550 var previewPanel = document.getElementById("preview-panel");
4551 var refreshButton = document.getElementById("refresh-preview");
4552 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
4553 var useSamplePath = document.getElementById("use-sample-path");
4554 var useDefaultOutput = document.getElementById("use-default-output");
4555 var browsePath = document.getElementById("browse-path");
4556 var browseOutputDir = document.getElementById("browse-output-dir");
4557 var themeToggle = document.getElementById("theme-toggle");
4558 var mixedLinePolicy = document.getElementById("mixed_line_policy");
4559 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
4560 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
4561 var scanPreset = document.getElementById("scan_preset");
4562 var artifactPreset = document.getElementById("artifact_preset");
4563 var includeGlobsInput = document.getElementById("include_globs");
4564 var excludeGlobsInput = document.getElementById("exclude_globs");
4565 var liveReportTitle = document.getElementById("live-report-title");
4566 var navProjectPill = document.getElementById("nav-project-pill");
4567 var navProjectTitle = document.getElementById("nav-project-title");
4568 var reportTitlePreview = null;
4569 var wizardProgressFill = document.getElementById("wizard-progress-fill");
4570 var wizardProgressValue = document.getElementById("wizard-progress-value");
4571 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
4572 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
4573 var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
4574 var reportTitleTouched = false;
4575 var currentStep = 1;
4576 var previewTimer = null;
4577 var quickScanBtn = document.getElementById("quick-scan-btn");
4578
4579 if (quickScanBtn) {
4580 quickScanBtn.addEventListener("click", function () {
4581 var pathVal = pathInput ? pathInput.value.trim() : "";
4582 if (!pathVal) {
4583 alert("Please enter or browse to a project path first.");
4584 return;
4585 }
4586 quickScanBtn.disabled = true;
4587 quickScanBtn.textContent = "Scanning...";
4588 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
4589 if (loading) loading.classList.add("active");
4590 if (form) form.submit();
4591 });
4592 }
4593
4594 var mixedPolicyInfo = {
4595 code_only: {
4596 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.",
4597 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'
4598 },
4599 code_and_comment: {
4600 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.",
4601 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'
4602 },
4603 comment_only: {
4604 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.",
4605 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'
4606 },
4607 separate_mixed_category: {
4608 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.",
4609 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'
4610 }
4611 };
4612
4613 var scanPresetInfo = {
4614 balanced: {
4615 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.",
4616 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
4617 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
4618 note: "Best when you want a stable local overview before making deeper adjustments.",
4619 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
4620 },
4621 code_focused: {
4622 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
4623 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
4624 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
4625 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
4626 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
4627 },
4628 comment_audit: {
4629 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
4630 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
4631 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
4632 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
4633 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
4634 },
4635 deep_review: {
4636 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
4637 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
4638 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
4639 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
4640 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
4641 }
4642 };
4643
4644 var artifactPresetInfo = {
4645 review: {
4646 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.",
4647 chips: ["HTML", "PDF"],
4648 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
4649 },
4650 full: {
4651 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.",
4652 chips: ["HTML", "PDF", "JSON"],
4653 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
4654 },
4655 html_only: {
4656 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.",
4657 chips: ["HTML only", "Fast local review"],
4658 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
4659 },
4660 machine: {
4661 description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
4662 chips: ["HTML", "JSON"],
4663 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
4664 }
4665 };
4666
4667 function applyTheme(theme) {
4668 if (theme === "dark") document.body.classList.add("dark-theme");
4669 else document.body.classList.remove("dark-theme");
4670 }
4671
4672 function loadSavedTheme() {
4673 var saved = null;
4674 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
4675 applyTheme(saved === "dark" ? "dark" : "light");
4676 }
4677
4678 function updateScrollProgress() {
4679 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
4680 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
4681 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
4682 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
4683 var step = Math.min(Math.max(currentStep, 1), 4);
4684 var base = stepBase[step];
4685 var end = stepEnd[step];
4686
4687 var scrollFrac = 0;
4688 var activePanel = document.querySelector(".wizard-step.active");
4689 if (activePanel) {
4690 var scrollTop = window.scrollY || window.pageYOffset || 0;
4691 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
4692 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
4693 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
4694 var scrolled = scrollTop + viewH - panelTop;
4695 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
4696 }
4697
4698 var percent = Math.round(base + (end - base) * scrollFrac);
4699 percent = Math.min(end, Math.max(base, percent));
4700 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
4701 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
4702 }
4703
4704 function updateWizardProgress() {
4705 updateScrollProgress();
4706 }
4707
4708 var stepDescriptions = [
4709 "Choose a project folder, apply scope filters, and preview which files will be counted.",
4710 "Configure how mixed code-plus-comment lines and docstrings are classified.",
4711 "Pick your output formats, scan preset, and where reports are saved.",
4712 "Review all settings and launch the analysis."
4713 ];
4714
4715 function updateStepNav(step) {
4716 var infoLabel = document.getElementById("step-nav-info-label");
4717 var infoDesc = document.getElementById("step-nav-info-desc");
4718 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
4719 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
4720
4721 var summary = document.getElementById("step-nav-summary");
4722 if (summary) summary.style.display = step > 1 ? "" : "none";
4723
4724 var snavPath = document.getElementById("snav-path");
4725 var snavOutput = document.getElementById("snav-output");
4726 var snavTitle = document.getElementById("snav-title");
4727 var pv = pathInput ? pathInput.value.trim() : "";
4728 var ov = outputDirInput ? outputDirInput.value.trim() : "";
4729 var tv = reportTitleInput ? reportTitleInput.value.trim() : "";
4730 if (snavPath) snavPath.textContent = pv || "—";
4731 if (snavOutput) snavOutput.textContent = ov || "auto";
4732 if (snavTitle) snavTitle.textContent = tv || "—";
4733 }
4734
4735 function setStep(step, pushHistory) {
4736 currentStep = step;
4737 stepPanels.forEach(function (panel) {
4738 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
4739 });
4740 stepButtons.forEach(function (button) {
4741 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
4742 });
4743 updateWizardProgress();
4744 updateStepNav(step);
4745
4746 if (pushHistory !== false) {
4747 try {
4748 history.pushState({ wizardStep: step }, "", "#step" + step);
4749 } catch (e) {}
4750 }
4751
4752 var wizardTop =
4753 document.querySelector(".page-shell") ||
4754 document.querySelector(".page") ||
4755 document.querySelector(".card") ||
4756 document.body;
4757
4758 var top = 0;
4759 try {
4760 top = Math.max(0, wizardTop.getBoundingClientRect().top + window.scrollY - 16);
4761 } catch (e) {
4762 top = 0;
4763 }
4764
4765 window.scrollTo({ top: top, behavior: "smooth" });
4766 }
4767
4768 window.addEventListener("popstate", function (e) {
4769 if (e.state && e.state.wizardStep) {
4770 setStep(e.state.wizardStep, false);
4771 } else {
4772 var hashMatch = location.hash.match(/^#step([1-4])$/);
4773 if (hashMatch) setStep(Number(hashMatch[1]), false);
4774 }
4775 });
4776
4777 function inferTitleFromPath(value) {
4778 if (!value) return "project";
4779 var cleaned = value.replace(/[\/\\]+$/, "");
4780 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
4781 return parts.length ? parts[parts.length - 1] : value;
4782 }
4783
4784 function updateReportTitleFromPath() {
4785 var inferred = inferTitleFromPath(pathInput.value || "samples/basic");
4786 if (!reportTitleTouched) {
4787 reportTitleInput.value = inferred;
4788 }
4789 var title = reportTitleInput.value || inferred;
4790 if (liveReportTitle) liveReportTitle.textContent = title;
4791 if (reportTitlePreview) reportTitlePreview.textContent = title;
4792 document.title = "OxideSLOC | " + title;
4793
4794 var projectPath = (pathInput.value || "").trim();
4795 if (navProjectPill && navProjectTitle) {
4796 if (projectPath.length > 0) {
4797 navProjectTitle.textContent = inferred;
4798 navProjectPill.classList.add("visible");
4799 } else {
4800 navProjectTitle.textContent = "";
4801 navProjectPill.classList.remove("visible");
4802 }
4803 }
4804 }
4805
4806 function updateMixedPolicyUI() {
4807 var key = mixedLinePolicy.value || "code_only";
4808 var info = mixedPolicyInfo[key];
4809 document.getElementById("mixed-policy-description").textContent = info.description;
4810 document.getElementById("mixed-policy-example").textContent = info.example;
4811 }
4812
4813 function updatePythonDocstringUI() {
4814 var checked = !!pythonDocstrings.checked;
4815 document.getElementById("python-docstring-example").textContent = checked
4816 ? 'def greet():\n """Greet the user.""" ← comment\n print("hi")'
4817 : 'def greet():\n """Greet the user.""" ← not counted\n print("hi")';
4818 document.getElementById("python-docstring-live-help").textContent = checked
4819 ? "Enabled: docstrings contribute to comment-style totals."
4820 : "Disabled: docstrings are not counted as comment content.";
4821 }
4822
4823 function renderPresetChips(targetId, chips) {
4824 var target = document.getElementById(targetId);
4825 if (!target) return;
4826 target.innerHTML = (chips || []).map(function (chip) {
4827 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
4828 }).join('');
4829 }
4830
4831 function updatePresetDescriptions() {
4832 var scanInfo = scanPresetInfo[scanPreset.value];
4833 var artifactInfo = artifactPresetInfo[artifactPreset.value];
4834 document.getElementById("scan-preset-description").textContent = scanInfo.description;
4835 document.getElementById("scan-preset-example").textContent = scanInfo.example;
4836 document.getElementById("scan-preset-note").textContent = scanInfo.note;
4837 document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
4838 document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
4839 renderPresetChips("scan-preset-summary", scanInfo.chips);
4840 renderPresetChips("artifact-preset-summary", artifactInfo.chips);
4841 }
4842
4843 function applyScanPreset() {
4844 var info = scanPresetInfo[scanPreset.value];
4845 if (!info || !info.apply) return;
4846 mixedLinePolicy.value = info.apply.mixed;
4847 pythonDocstrings.checked = !!info.apply.docstrings;
4848 document.getElementById("generated_file_detection").value = info.apply.generated;
4849 document.getElementById("minified_file_detection").value = info.apply.minified;
4850 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
4851 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
4852 document.getElementById("binary_file_behavior").value = info.apply.binary;
4853 updateMixedPolicyUI();
4854 updatePythonDocstringUI();
4855 }
4856
4857 function applyArtifactPreset() {
4858 var enabled = { html: false, pdf: false, json: false };
4859 if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
4860 if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; enabled.json = true; }
4861 if (artifactPreset.value === "html_only") { enabled.html = true; }
4862 if (artifactPreset.value === "machine") { enabled.json = true; enabled.html = true; }
4863
4864 artifactCards.forEach(function (card) {
4865 var artifact = card.getAttribute("data-artifact");
4866 var checked = !!enabled[artifact];
4867 var checkbox = card.querySelector(".artifact-checkbox");
4868 checkbox.checked = checked;
4869 card.classList.toggle("selected", checked);
4870 });
4871 }
4872
4873 function toggleArtifactCard(card) {
4874 var checkbox = card.querySelector(".artifact-checkbox");
4875 checkbox.checked = !checkbox.checked;
4876 card.classList.toggle("selected", checkbox.checked);
4877 }
4878
4879 function updateReview() {
4880 var scanSummary = document.getElementById("review-scan-summary");
4881 var countSummary = document.getElementById("review-count-summary");
4882 var artifactSummary = document.getElementById("review-artifact-summary");
4883 var outputSummary = document.getElementById("review-output-summary");
4884 var previewSummary = document.getElementById("review-preview-summary");
4885 var readinessSummary = document.getElementById("review-readiness-summary");
4886 var includeText = document.getElementById("include_globs").value.trim();
4887 var excludeText = document.getElementById("exclude_globs").value.trim();
4888 var sidePathPreview = document.getElementById("side-path-preview");
4889 var sideOutputPreview = document.getElementById("side-output-preview");
4890 var sideTitlePreview = document.getElementById("side-title-preview");
4891
4892 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "samples/basic"; }
4893 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
4894 if (sideTitlePreview) {
4895 var rt = document.getElementById("report_title");
4896 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
4897 }
4898
4899 scanSummary.innerHTML = ""
4900 + "<li>Path: " + escapeHtml(pathInput.value || "samples/basic") + "</li>"
4901 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
4902 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
4903
4904 countSummary.innerHTML = ""
4905 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
4906 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
4907 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
4908 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
4909 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
4910 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
4911 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
4912 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
4913
4914 var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.querySelector("h4").textContent; });
4915 artifactSummary.innerHTML = ""
4916 + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
4917 + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
4918
4919 outputSummary.innerHTML = ""
4920 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
4921 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value || "samples/basic")) + "</li>";
4922
4923 if (previewSummary) {
4924 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
4925 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
4926 var statMap = {};
4927 statButtons.forEach(function (button) {
4928 var valueNode = button.querySelector('.scope-stat-value');
4929 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
4930 });
4931 previewSummary.innerHTML = ''
4932 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
4933 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
4934 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
4935 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
4936 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
4937 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
4938
4939 if (readinessSummary) {
4940 var selectedArtifactsCount = selectedArtifacts.length;
4941 readinessSummary.innerHTML = ''
4942 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
4943 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
4944 + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
4945 + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
4946 }
4947 }
4948 }
4949
4950 function escapeHtml(value) {
4951 return String(value)
4952 .replace(/&/g, "&")
4953 .replace(/</g, "<")
4954 .replace(/>/g, ">")
4955 .replace(/"/g, """)
4956 .replace(/'/g, "'");
4957 }
4958
4959 function isPythonVisible() {
4960 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
4961 }
4962
4963 function syncPythonVisibility() {
4964 var html = previewPanel.textContent || "";
4965 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
4966 pythonWraps.forEach(function (node) {
4967 node.classList.toggle("hidden", !hasPython);
4968 });
4969 }
4970
4971 function attachPreviewInteractions() {
4972 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
4973 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
4974 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
4975 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
4976 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
4977 var searchInput = previewPanel.querySelector("#explorer-search");
4978 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
4979 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
4980 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
4981 var activeFilter = "all";
4982 var activeLanguage = "";
4983 var searchTerm = "";
4984 var currentSortKey = null;
4985 var currentSortOrder = "asc";
4986 var childRows = {};
4987
4988 rows.forEach(function (row) {
4989 var parentId = row.getAttribute("data-parent-id") || "";
4990 var rowId = row.getAttribute("data-row-id") || "";
4991 if (!childRows[parentId]) childRows[parentId] = [];
4992 childRows[parentId].push(rowId);
4993 });
4994
4995 function rowById(id) {
4996 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
4997 }
4998
4999 function hasCollapsedAncestor(row) {
5000 var parentId = row.getAttribute("data-parent-id");
5001 while (parentId) {
5002 var parent = rowById(parentId);
5003 if (!parent) break;
5004 if (parent.getAttribute("data-expanded") === "false") return true;
5005 parentId = parent.getAttribute("data-parent-id");
5006 }
5007 return false;
5008 }
5009
5010 function updateToggleGlyph(row) {
5011 var toggle = row.querySelector(".tree-toggle");
5012 if (!toggle) return;
5013 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
5014 }
5015
5016 function rowSortValue(row, key) {
5017 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
5018 }
5019
5020 function updateSortButtons() {
5021 sortButtons.forEach(function (button) {
5022 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
5023 var indicator = button.querySelector(".tree-sort-indicator");
5024 button.classList.toggle("active", isActive);
5025 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
5026 if (indicator) {
5027 indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
5028 }
5029 });
5030 }
5031
5032 function sortSiblingRows() {
5033 if (!treeContainer) {
5034 updateSortButtons();
5035 return;
5036 }
5037
5038 var rowMap = {};
5039 var childrenMap = {};
5040 rows.forEach(function (row) {
5041 var rowId = row.getAttribute("data-row-id");
5042 var parentId = row.getAttribute("data-parent-id") || "";
5043 rowMap[rowId] = row;
5044 if (!childrenMap[parentId]) childrenMap[parentId] = [];
5045 childrenMap[parentId].push(rowId);
5046 });
5047
5048 Object.keys(childrenMap).forEach(function (parentId) {
5049 if (!parentId) return;
5050 childrenMap[parentId].sort(function (a, b) {
5051 var rowA = rowMap[a];
5052 var rowB = rowMap[b];
5053 if (!currentSortKey) {
5054 return Number(a) - Number(b);
5055 }
5056 var valueA = rowSortValue(rowA, currentSortKey);
5057 var valueB = rowSortValue(rowB, currentSortKey);
5058 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
5059 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
5060 var fallbackA = rowSortValue(rowA, "name");
5061 var fallbackB = rowSortValue(rowB, "name");
5062 if (fallbackA < fallbackB) return -1;
5063 if (fallbackA > fallbackB) return 1;
5064 return Number(a) - Number(b);
5065 });
5066 });
5067
5068 var orderedIds = [];
5069 function pushChildren(parentId) {
5070 (childrenMap[parentId] || []).forEach(function (childId) {
5071 orderedIds.push(childId);
5072 pushChildren(childId);
5073 });
5074 }
5075
5076 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
5077 orderedIds.push(topId);
5078 pushChildren(topId);
5079 });
5080
5081 orderedIds.forEach(function (id) {
5082 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
5083 });
5084 updateSortButtons();
5085 }
5086
5087 function updateLanguageButtons() {
5088 languageButtons.forEach(function (button) {
5089 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
5090 var isActive = languageValue === activeLanguage;
5091 button.classList.toggle("active", isActive);
5092 });
5093 }
5094
5095 function rowSelfMatches(row) {
5096 var kind = row.getAttribute("data-kind");
5097 var status = row.getAttribute("data-status");
5098 var language = (row.getAttribute("data-language") || "").toLowerCase();
5099 var name = row.getAttribute("data-name-lower") || "";
5100 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
5101 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
5102 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
5103 var passesLanguage = !activeLanguage || language === activeLanguage;
5104 return passesFilter && passesSearch && passesLanguage;
5105 }
5106
5107 function hasMatchingDescendant(rowId) {
5108 return (childRows[rowId] || []).some(function (childId) {
5109 var childRow = rowById(childId);
5110 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
5111 });
5112 }
5113
5114 function rowMatches(row) {
5115 if (rowSelfMatches(row)) return true;
5116 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
5117 }
5118
5119 function resetViewState() {
5120 activeFilter = "all";
5121 activeLanguage = "";
5122 searchTerm = "";
5123 currentSortKey = null;
5124 currentSortOrder = "asc";
5125 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
5126 if (searchInput) searchInput.value = "";
5127 if (filterSelect) filterSelect.value = "all";
5128 updateLanguageButtons();
5129 }
5130
5131 function applyVisibility() {
5132 rows.forEach(function (row) {
5133 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
5134 row.classList.toggle("hidden-by-filter", !visible);
5135 row.style.display = visible ? "grid" : "none";
5136 });
5137 buttons.forEach(function (button) {
5138 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
5139 });
5140 if (filterSelect) filterSelect.value = activeFilter;
5141 }
5142
5143 buttons.forEach(function (button) {
5144 button.addEventListener("click", function () {
5145 var filterValue = button.getAttribute("data-filter") || "all";
5146 if (filterValue === "reset-view") {
5147 resetViewState();
5148 sortSiblingRows();
5149 applyVisibility();
5150 return;
5151 }
5152 activeFilter = filterValue;
5153 applyVisibility();
5154 });
5155 });
5156
5157 rows.forEach(function (row) {
5158 updateToggleGlyph(row);
5159 var toggle = row.querySelector(".tree-toggle");
5160 if (toggle) {
5161 toggle.addEventListener("click", function () {
5162 var expanded = row.getAttribute("data-expanded") !== "false";
5163 row.setAttribute("data-expanded", expanded ? "false" : "true");
5164 updateToggleGlyph(row);
5165 applyVisibility();
5166 });
5167 }
5168 });
5169
5170 actionButtons.forEach(function (button) {
5171 button.addEventListener("click", function () {
5172 var action = button.getAttribute("data-explorer-action");
5173 if (action === "expand-all") {
5174 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
5175 } else if (action === "collapse-all") {
5176 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
5177 } else if (action === "clear-filters") {
5178 resetViewState();
5179 }
5180 sortSiblingRows();
5181 applyVisibility();
5182 });
5183 });
5184
5185 if (filterSelect) {
5186 filterSelect.addEventListener("change", function () {
5187 activeFilter = filterSelect.value || "all";
5188 applyVisibility();
5189 });
5190 }
5191
5192 languageButtons.forEach(function (button) {
5193 button.addEventListener("click", function () {
5194 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
5195 updateLanguageButtons();
5196 applyVisibility();
5197 });
5198 });
5199
5200 sortButtons.forEach(function (button) {
5201 button.addEventListener("click", function () {
5202 var sortKey = button.getAttribute("data-sort-key");
5203 if (currentSortKey === sortKey) {
5204 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
5205 } else {
5206 currentSortKey = sortKey;
5207 currentSortOrder = "asc";
5208 }
5209 sortSiblingRows();
5210 applyVisibility();
5211 });
5212 });
5213
5214 if (searchInput) {
5215 searchInput.addEventListener("input", function () {
5216 searchTerm = searchInput.value.trim().toLowerCase();
5217 applyVisibility();
5218 });
5219 }
5220
5221 updateLanguageButtons();
5222 sortSiblingRows();
5223 applyVisibility();
5224 }
5225
5226 function loadPreview() {
5227 if (!previewPanel || !pathInput) return;
5228 var path = pathInput.value || "samples/basic";
5229 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
5230 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
5231 previewPanel.innerHTML = '<div class="preview-error">Refreshing preview...</div>';
5232 var previewUrl = "/preview?path=" + encodeURIComponent(path)
5233 + "&include_globs=" + encodeURIComponent(includeValue)
5234 + "&exclude_globs=" + encodeURIComponent(excludeValue);
5235 fetch(previewUrl)
5236 .then(function (response) { return response.text(); })
5237 .then(function (html) {
5238 previewPanel.innerHTML = html;
5239 attachPreviewInteractions();
5240 syncPythonVisibility();
5241 updateReview();
5242 setTimeout(collapseLanguagePills, 50);
5243 })
5244 .catch(function (err) {
5245 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
5246 });
5247 }
5248
5249 function pickDirectory(targetInput, kind) {
5250 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
5251 if (browseButton) browseButton.disabled = true;
5252
5253 if (previewPanel && targetInput === pathInput) {
5254 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
5255 }
5256
5257 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
5258 .then(function (response) { return response.json(); })
5259 .then(function (data) {
5260 if (data && data.selected_path) {
5261 targetInput.value = data.selected_path;
5262
5263 if (targetInput === pathInput) {
5264 updateReportTitleFromPath();
5265 autoSetOutputDir(data.selected_path);
5266 fetchProjectHistory(data.selected_path);
5267 loadPreview();
5268 }
5269
5270 updateReview();
5271 } else if (targetInput === pathInput) {
5272 // Cancelled — keep existing value and refresh preview with current path
5273 loadPreview();
5274 }
5275 })
5276 .catch(function () {
5277 window.alert("Directory picker request failed.");
5278 if (previewPanel && targetInput === pathInput) {
5279 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
5280 }
5281 })
5282 .finally(function () {
5283 if (browseButton) browseButton.disabled = false;
5284 });
5285 }
5286
5287 if (themeToggle) {
5288 themeToggle.addEventListener("click", function () {
5289 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
5290 applyTheme(nextTheme);
5291 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
5292 });
5293 }
5294
5295 stepButtons.forEach(function (button) {
5296 button.addEventListener("click", function () {
5297 setStep(Number(button.getAttribute("data-step-target")));
5298 });
5299 });
5300
5301 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
5302 button.addEventListener("click", function () {
5303 setStep(Number(button.getAttribute("data-step-target")) || 1);
5304 });
5305 });
5306
5307 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
5308 button.addEventListener("click", function () {
5309 updateReview();
5310 setStep(Number(button.getAttribute("data-next")));
5311 });
5312 });
5313
5314 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
5315 button.addEventListener("click", function () {
5316 setStep(Number(button.getAttribute("data-prev")));
5317 });
5318 });
5319
5320 if (useSamplePath) {
5321 useSamplePath.addEventListener("click", function () {
5322 pathInput.value = "samples/basic";
5323 updateReportTitleFromPath();
5324 loadPreview();
5325 });
5326 }
5327
5328 if (useDefaultOutput) {
5329 useDefaultOutput.addEventListener("click", function () {
5330 delete outputDirInput.dataset.userEdited;
5331 autoSetOutputDir(pathInput ? pathInput.value : "");
5332 updateReview();
5333 });
5334 }
5335
5336 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
5337 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
5338
5339 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
5340
5341 // ── Language pill overflow: collapse to "+N more" chip ─────────────
5342 function collapseLanguagePills() {
5343 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
5344 rows.forEach(function(row) {
5345 // Remove any previous overflow chip
5346 var prev = row.querySelector('.lang-overflow-chip');
5347 if (prev) prev.remove();
5348 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
5349 pills.forEach(function(p) { p.style.display = ''; });
5350 if (!pills.length) return;
5351
5352 // Measure after restoring all pills
5353 var containerRight = row.getBoundingClientRect().right;
5354 var hidden = [];
5355 for (var i = pills.length - 1; i >= 1; i--) {
5356 var rect = pills[i].getBoundingClientRect();
5357 if (rect.right > containerRight + 2) {
5358 hidden.unshift(pills[i]);
5359 pills[i].style.display = 'none';
5360 } else {
5361 break;
5362 }
5363 }
5364
5365 if (hidden.length) {
5366 var chip = document.createElement('button');
5367 chip.type = 'button';
5368 chip.className = 'language-pill lang-overflow-chip';
5369 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
5370 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
5371 row.appendChild(chip);
5372 }
5373 });
5374 }
5375
5376 // Run after preview loads (preview panel populates language pills)
5377 var _origLoadPreviewCb = window.__previewLoaded;
5378 document.addEventListener('previewLoaded', collapseLanguagePills);
5379 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
5380 setTimeout(collapseLanguagePills, 400);
5381
5382 // ── Project history & output dir auto-set ──────────────────────────
5383 var wsOutputRoot = document.getElementById("ws-output-root");
5384 var wsScanCount = document.getElementById("ws-scan-count");
5385 var wsLastScan = document.getElementById("ws-last-scan");
5386 var historyBadge = document.getElementById("path-history-badge");
5387 var historyTimer = null;
5388
5389 var wsOutputLink = document.getElementById("ws-output-link");
5390 function syncStripOutputRoot() {
5391 var val = outputDirInput ? outputDirInput.value : "";
5392 var display = val || "project/sloc";
5393 if (wsOutputRoot) wsOutputRoot.textContent = display;
5394 if (wsOutputLink) wsOutputLink.dataset.folder = val;
5395 }
5396
5397 function autoSetOutputDir(projectPath) {
5398 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
5399 if (!projectPath || !projectPath.trim()) return;
5400 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
5401 outputDirInput.value = cleaned + "/sloc";
5402 syncStripOutputRoot();
5403 updateReview();
5404 }
5405
5406 var wsBranch = document.getElementById("ws-branch");
5407
5408 function fetchProjectHistory(projectPath) {
5409 if (!projectPath || !projectPath.trim()) {
5410 if (wsScanCount) wsScanCount.textContent = "—";
5411 if (wsLastScan) wsLastScan.textContent = "—";
5412 if (wsBranch) wsBranch.textContent = "—";
5413 if (historyBadge) historyBadge.style.display = "none";
5414 return;
5415 }
5416 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
5417 .then(function (r) { return r.ok ? r.json() : null; })
5418 .then(function (data) {
5419 if (!data) return;
5420 var countStr = data.scan_count > 0
5421 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
5422 : "never";
5423 var tsStr = data.last_scan_timestamp
5424 ? data.last_scan_timestamp.replace(" UTC","")
5425 : "—";
5426 if (wsScanCount) wsScanCount.textContent = countStr;
5427 if (wsLastScan) wsLastScan.textContent = tsStr;
5428 if (wsBranch) wsBranch.textContent = data.last_git_branch || "—";
5429 if (data.scan_count > 0) {
5430 if (historyBadge) {
5431 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
5432 historyBadge.textContent = data.scan_count + " previous scan" +
5433 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
5434 "Last: " + (data.last_scan_timestamp || "—") +
5435 " — " + (data.last_scan_code_lines ? Number(data.last_scan_code_lines).toLocaleString() : "?") + " code lines.";
5436 historyBadge.className = "path-history-badge found";
5437 historyBadge.style.display = "";
5438 }
5439 } else {
5440 if (historyBadge) historyBadge.style.display = "none";
5441 }
5442 })
5443 .catch(function () {});
5444 }
5445
5446 function onPathChange() {
5447 var val = pathInput ? pathInput.value : "";
5448 updateReportTitleFromPath();
5449 autoSetOutputDir(val);
5450 clearTimeout(historyTimer);
5451 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
5452 if (previewTimer) clearTimeout(previewTimer);
5453 previewTimer = setTimeout(loadPreview, 280);
5454 }
5455
5456 if (pathInput) {
5457 pathInput.addEventListener("input", onPathChange);
5458 }
5459
5460 if (outputDirInput) {
5461 outputDirInput.addEventListener("input", function () {
5462 outputDirInput.dataset.userEdited = "1";
5463 syncStripOutputRoot();
5464 updateReview();
5465 });
5466 }
5467
5468 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
5469 if (!node) return;
5470 node.addEventListener("input", function () {
5471 updateReview();
5472 if (previewTimer) clearTimeout(previewTimer);
5473 previewTimer = setTimeout(loadPreview, 280);
5474 });
5475 });
5476
5477 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
5478 var node = document.getElementById(id);
5479 if (node) node.addEventListener("change", updateReview);
5480 });
5481
5482 if (reportTitleInput) {
5483 reportTitleInput.addEventListener("input", function () {
5484 reportTitleTouched = reportTitleInput.value.trim().length > 0;
5485 updateReportTitleFromPath();
5486 updateReview();
5487 });
5488 }
5489
5490 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
5491 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
5492 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); });
5493 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); });
5494
5495 artifactCards.forEach(function (card) {
5496 card.addEventListener("click", function () {
5497 toggleArtifactCard(card);
5498 updateReview();
5499 });
5500 });
5501
5502 if (form && loading && submitButton) {
5503 form.addEventListener("submit", function () {
5504 submitButton.disabled = true;
5505 submitButton.textContent = "Scanning...";
5506 loading.classList.add("active");
5507 });
5508 }
5509
5510 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
5511 btn.addEventListener('click', function () {
5512 var folder = btn.getAttribute('data-folder') || btn.dataset.folder || '';
5513 if (!folder) return;
5514 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
5515 });
5516 });
5517
5518 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
5519 if (wsOutputLink) {
5520 wsOutputLink.addEventListener('click', function () {
5521 var folder = wsOutputLink.dataset.folder || '';
5522 if (!folder) return;
5523 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
5524 });
5525 }
5526
5527 loadSavedTheme();
5528 updateMixedPolicyUI();
5529 updatePythonDocstringUI();
5530 applyScanPreset();
5531 updatePresetDescriptions();
5532 applyArtifactPreset();
5533 updateReview();
5534 updateScrollProgress(); // initialise bar to 0% (step 1)
5535 window.addEventListener("scroll", updateScrollProgress, { passive: true });
5536 onPathChange(); // seed output dir, history badge, and preview from initial path
5537 loadPreview();
5538 updateStepNav(1);
5539
5540 // Restore step from URL hash on initial load (e.g., back-forward cache)
5541 (function() {
5542 var hashMatch = location.hash.match(/^#step([1-4])$/);
5543 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
5544 })();
5545
5546 (function randomizeWatermarks() {
5547 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
5548 if (!wms.length) return;
5549 var placed = [];
5550 function tooClose(top, left) {
5551 for (var i = 0; i < placed.length; i++) {
5552 var dt = Math.abs(placed[i][0] - top);
5553 var dl = Math.abs(placed[i][1] - left);
5554 if (dt < 16 && dl < 12) return true;
5555 }
5556 return false;
5557 }
5558 function pick(leftBand) {
5559 for (var attempt = 0; attempt < 50; attempt++) {
5560 var top = Math.random() * 88 + 2;
5561 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
5562 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
5563 }
5564 var top = Math.random() * 88 + 2;
5565 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
5566 placed.push([top, left]);
5567 return [top, left];
5568 }
5569 var half = Math.floor(wms.length / 2);
5570 wms.forEach(function (img, i) {
5571 var pos = pick(i < half);
5572 var size = Math.floor(Math.random() * 80 + 110);
5573 var rot = (Math.random() * 360).toFixed(1);
5574 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
5575 img.style.cssText = "width:" + size + "px;top:" + pos[0].toFixed(1) + "%;left:" + pos[1].toFixed(1) + "%;transform:rotate(" + rot + "deg);opacity:" + op + ";";
5576 });
5577 })();
5578
5579 (function spawnCodeParticles() {
5580 var container = document.getElementById('code-particles');
5581 if (!container) return;
5582 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'];
5583 for (var i = 0; i < 38; i++) {
5584 (function(idx) {
5585 var el = document.createElement('span');
5586 el.className = 'code-particle';
5587 el.textContent = snippets[idx % snippets.length];
5588 var left = Math.random() * 94 + 2;
5589 var top = Math.random() * 88 + 6;
5590 var dur = (Math.random() * 10 + 9).toFixed(1);
5591 var delay = (Math.random() * 18).toFixed(1);
5592 var rot = (Math.random() * 26 - 13).toFixed(1);
5593 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
5594 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
5595 container.appendChild(el);
5596 })(i);
5597 }
5598 })();
5599 })();
5600 </script>
5601 <script>
5602 (function () {
5603 var raw = {{ prefill_json|safe }};
5604 if (!raw || typeof raw !== 'object' || !raw.path) return;
5605 function setVal(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
5606 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
5607 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
5608 setVal('path-input', raw.path || '');
5609 setVal('include-globs', raw.include_globs || '');
5610 setVal('exclude-globs', raw.exclude_globs || '');
5611 setVal('output-dir', raw.output_dir || '');
5612 setVal('report-title', raw.report_title || '');
5613 if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
5614 setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
5615 setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
5616 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
5617 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
5618 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
5619 if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
5620 setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
5621 setChecked('generate-html', raw.generate_html !== false);
5622 setChecked('generate-pdf', !!raw.generate_pdf);
5623 // Trigger dynamic UI updates after pre-fill.
5624 setTimeout(function () {
5625 var pathEl = document.getElementById('path-input');
5626 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
5627 var policyEl = document.getElementById('mixed-line-policy');
5628 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
5629 }, 80);
5630 })();
5631 </script>
5632 <footer class="site-footer">
5633 oxide-sloc v{{ version }} — local source line analysis workbench ·
5634 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
5635 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
5636 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
5637 </footer>
5638</body>
5639</html>
5640"##,
5641 ext = "html"
5642)]
5643struct IndexTemplate {
5644 version: &'static str,
5645 prefill_json: String,
5646}
5647
5648#[derive(Template)]
5651#[template(
5652 source = r##"
5653<!doctype html>
5654<html lang="en">
5655<head>
5656 <meta charset="utf-8">
5657 <meta name="viewport" content="width=device-width, initial-scale=1">
5658 <title>OxideSLOC — Source Line Analysis Workbench</title>
5659 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
5660 <style>
5661 :root {
5662 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
5663 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
5664 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
5665 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
5666 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
5667 }
5668 body.dark-theme {
5669 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
5670 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
5671 }
5672 *{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);}
5673 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
5674 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
5675 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
5676 .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;}
5677 @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));}}
5678 .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);}
5679 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
5680 .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));}
5681 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
5682 .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;}
5683 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
5684 .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;}
5685 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
5686 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
5687 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
5688 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
5689 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
5690 .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;}
5691 .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;}
5692 .page{max-width:1100px;margin:0 auto;padding:48px 24px 60px;position:relative;z-index:1;}
5693 .hero{text-align:center;margin-bottom:52px;}
5694 .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;}
5695 @keyframes logoBob{0%,100%{transform:translateY(0) scale(1);}40%{transform:translateY(-18px) scale(1.07);}60%{transform:translateY(-14px) scale(1.05);}}
5696 .hero-title{font-size:51px;font-weight:900;letter-spacing:-0.04em;margin:0 0 10px;
5697 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
5698 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
5699 animation:titleShimmer 4s linear infinite;}
5700 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
5701 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;}
5702 .hero-subtitle{font-size:18px;color:var(--muted);line-height:1.6;max-width:600px;margin:0 auto;animation:fadeSlideUp 0.9s ease both;}
5703 @keyframes fadeSlideUp{from{opacity:0;transform:translateY(18px);}to{opacity:1;transform:translateY(0);}}
5704 .action-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-bottom:32px;}
5705 @media(max-width:760px){.action-grid{grid-template-columns:1fr 1fr;}}
5706 @media(max-width:480px){.action-grid{grid-template-columns:1fr;}}
5707 .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;}
5708 .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;}
5709 @keyframes cardRise{from{opacity:0;transform:translateY(24px);}to{opacity:1;transform:translateY(0);}}
5710 .action-card:hover{transform:translateY(-6px) scale(1.012);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
5711 .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);}
5712 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
5713 .action-card-icon svg{width:26px;height:26px;stroke:currentColor;fill:none;stroke-width:2;}
5714 .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);}
5715 .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);}
5716 .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);}
5717 .action-card-title{font-size:20px;font-weight:850;letter-spacing:-0.02em;margin:0 0 8px;}
5718 .action-card-desc{font-size:14px;color:var(--muted);line-height:1.6;margin:0 0 20px;flex:1;}
5719 .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;}
5720 body.dark-theme .action-card-cta{color:var(--oxide);}
5721 .action-card.view .action-card-cta{color:var(--accent-2);}
5722 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
5723 .action-card.compare .action-card-cta{color:#7c3aed;}
5724 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
5725 .action-card:hover .action-card-cta{gap:12px;}
5726 .divider{height:1px;background:var(--line);margin:40px 0;}
5727 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:16px;}
5728 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
5729 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
5730 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:18px 20px;text-align:center;position:relative;cursor:default;
5731 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
5732 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
5733 .info-chip-val{font-size:22px;font-weight:900;color:var(--oxide);}
5734 body.dark-theme .info-chip-val{color:var(--oxide);}
5735 .info-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
5736 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
5737 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
5738 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
5739 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
5740 border:6px solid transparent;border-top-color:var(--text);}
5741 .info-chip:hover .info-chip-tip{display:block;}
5742 .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
5743 .site-footer a{color:var(--muted);}
5744 </style>
5745</head>
5746<body>
5747 <div class="background-watermarks" aria-hidden="true">
5748 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
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 </div>
5756 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
5757 <div class="top-nav">
5758 <div class="top-nav-inner">
5759 <a class="brand" href="/">
5760 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
5761 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Source line analysis workbench</div></div>
5762 </a>
5763 <div class="nav-right">
5764 <a class="nav-pill" href="/">Home</a>
5765 <a class="nav-pill" href="/view-reports">View Reports</a>
5766 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
5767 <div class="server-status-wrap">
5768 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
5769 <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>
5770 </div>
5771 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
5772 <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>
5773 <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>
5774 </button>
5775 </div>
5776 </div>
5777 </div>
5778
5779 <div class="page">
5780 <div class="hero">
5781 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
5782 <h1 class="hero-title">OxideSLOC</h1>
5783 <p class="hero-subtitle">A fast, self-contained source line analysis workbench. Count code, track history, and compare scan snapshots — no setup required.</p>
5784 </div>
5785
5786 <div class="action-grid">
5787 <a class="action-card scan" href="/scan-setup">
5788 <div class="action-card-icon">
5789 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
5790 </div>
5791 <div class="action-card-title">Scan Project</div>
5792 <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>
5793 <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>
5794 </a>
5795
5796 <a class="action-card view" href="/view-reports">
5797 <div class="action-card-icon">
5798 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
5799 </div>
5800 <div class="action-card-title">View Reports</div>
5801 <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>
5802 <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>
5803 </a>
5804
5805 <a class="action-card compare" href="/compare-scans">
5806 <div class="action-card-icon">
5807 <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>
5808 </div>
5809 <div class="action-card-title">Compare Scans</div>
5810 <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>
5811 <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>
5812 </a>
5813 </div>
5814
5815 <div class="divider"></div>
5816
5817 <div class="info-strip">
5818 <div class="info-chip">
5819 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
5820 <div class="info-chip-val">41</div>
5821 <div class="info-chip-label">Languages</div>
5822 </div>
5823 <div class="info-chip">
5824 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
5825 <div class="info-chip-val">100%</div>
5826 <div class="info-chip-label">Self-contained</div>
5827 </div>
5828 <div class="info-chip">
5829 <div class="info-chip-tip">Self-contained HTML reports with<br>light/dark theme — share without a server</div>
5830 <div class="info-chip-val">HTML</div>
5831 <div class="info-chip-label">Exportable reports</div>
5832 </div>
5833 <div class="info-chip">
5834 <div class="info-chip-tip">Detects .gitmodules and produces<br>per-submodule breakdowns automatically</div>
5835 <div class="info-chip-val">Git</div>
5836 <div class="info-chip-label">Submodule support</div>
5837 </div>
5838 <div class="info-chip">
5839 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
5840 <div class="info-chip-val">IEEE</div>
5841 <div class="info-chip-label">1045-1992</div>
5842 </div>
5843 </div>
5844 </div>
5845
5846 <footer class="site-footer">
5847 oxide-sloc — local source line analysis workbench ·
5848 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
5849 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
5850 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
5851 </footer>
5852
5853 <script>
5854 (function () {
5855 var storageKey = 'oxide-sloc-theme';
5856 var body = document.body;
5857 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
5858 var toggle = document.getElementById('theme-toggle');
5859 if (toggle) toggle.addEventListener('click', function () {
5860 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
5861 body.classList.toggle('dark-theme', next === 'dark');
5862 try { localStorage.setItem(storageKey, next); } catch(e) {}
5863 });
5864 (function randomizeWatermarks() {
5865 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
5866 if (!wms.length) return;
5867 var placed = [];
5868 function tooClose(top, left) {
5869 for (var i = 0; i < placed.length; i++) {
5870 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
5871 if (dt < 16 && dl < 12) return true;
5872 }
5873 return false;
5874 }
5875 function pick(leftBand) {
5876 for (var attempt = 0; attempt < 50; attempt++) {
5877 var top = Math.random() * 88 + 2;
5878 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
5879 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
5880 }
5881 var top = Math.random() * 88 + 2;
5882 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
5883 placed.push([top, left]); return [top, left];
5884 }
5885 var half = Math.floor(wms.length / 2);
5886 wms.forEach(function (img, i) {
5887 var pos = pick(i < half);
5888 var size = Math.floor(Math.random() * 100 + 120);
5889 var rot = (Math.random() * 360).toFixed(1);
5890 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
5891 img.style.cssText = 'width:' + size + 'px;top:' + pos[0].toFixed(1) + '%;left:' + pos[1].toFixed(1) + '%;transform:rotate(' + rot + 'deg);opacity:' + op + ';';
5892 });
5893 })();
5894
5895 (function spawnCodeParticles() {
5896 var container = document.getElementById('code-particles');
5897 if (!container) return;
5898 var snippets = [
5899 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
5900 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
5901 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
5902 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
5903 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
5904 ];
5905 var count = 38;
5906 for (var i = 0; i < count; i++) {
5907 (function(idx) {
5908 var el = document.createElement('span');
5909 el.className = 'code-particle';
5910 var text = snippets[idx % snippets.length];
5911 el.textContent = text;
5912 var left = Math.random() * 94 + 2;
5913 var top = Math.random() * 88 + 6;
5914 var dur = (Math.random() * 10 + 9).toFixed(1);
5915 var delay = (Math.random() * 18).toFixed(1);
5916 var rot = (Math.random() * 26 - 13).toFixed(1);
5917 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
5918 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;'
5919 + '--rot:' + rot + 'deg;--op:' + op + ';'
5920 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
5921 container.appendChild(el);
5922 })(i);
5923 }
5924 })();
5925 })();
5926 </script>
5927</body>
5928</html>
5929"##,
5930 ext = "html"
5931)]
5932struct SplashTemplate {}
5933
5934#[derive(Template)]
5937#[template(
5938 source = r##"
5939<!doctype html>
5940<html lang="en">
5941<head>
5942 <meta charset="utf-8">
5943 <meta name="viewport" content="width=device-width, initial-scale=1">
5944 <title>OxideSLOC — Start a Scan</title>
5945 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
5946 <style>
5947 :root {
5948 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
5949 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
5950 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
5951 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
5952 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
5953 }
5954 body.dark-theme {
5955 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
5956 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
5957 }
5958 *{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);}
5959 .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);}
5960 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
5961 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
5962 .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));}
5963 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
5964 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
5965 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;}
5966 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
5967 .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;}
5968 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
5969 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
5970 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
5971 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
5972 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
5973 .page{max-width:960px;margin:0 auto;padding:40px 24px 64px;position:relative;z-index:1;}
5974 .page-header{text-align:center;margin-bottom:32px;}
5975 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
5976 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
5977 .breadcrumb{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted);margin-bottom:28px;}
5978 .breadcrumb a{color:var(--muted);text-decoration:none;} .breadcrumb a:hover{color:var(--oxide);}
5979 .breadcrumb svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2;}
5980 /* Cards */
5981 .option-grid{display:flex;flex-direction:column;gap:16px;}
5982 .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;}
5983 .option-card:hover{border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
5984 /* Two-column layout inside each card */
5985 .card-body{display:grid;grid-template-columns:1fr 240px;gap:24px;align-items:center;}
5986 .card-left{display:flex;align-items:flex-start;gap:16px;min-width:0;}
5987 .option-icon{width:46px;height:46px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex:0 0 auto;}
5988 .option-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
5989 .option-icon.new-scan{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 6px 18px rgba(184,80,40,0.28);}
5990 .option-icon.load-config{background:linear-gradient(135deg,#3b82f6,#1d4ed8);color:#fff;box-shadow:0 6px 18px rgba(59,130,246,0.28);}
5991 .option-icon.rescan{background:linear-gradient(135deg,#8b5cf6,#6d28d9);color:#fff;box-shadow:0 6px 18px rgba(139,92,246,0.28);}
5992 .card-text{min-width:0;}
5993 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 4px;}
5994 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
5995 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
5996 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
5997 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
5998 /* Right CTA column */
5999 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
6000 .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;}
6001 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
6002 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
6003 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
6004 body.dark-theme .btn-secondary{color:var(--oxide);}
6005 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
6006 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
6007 /* File input overlay — must be full-width so it aligns with other card-right buttons */
6008 .file-input-wrap{position:relative;width:100%;}
6009 .file-input-wrap .btn{width:100%;}
6010 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
6011 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
6012 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
6013 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
6014 .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;}
6015 @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));}}
6016 /* Recent list (card 3 — full-width section below header) */
6017 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
6018 .recent-list{display:flex;flex-direction:column;gap:8px;}
6019 .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;}
6020 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
6021 .recent-item-info{flex:1;min-width:0;}
6022 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
6023 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
6024 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
6025 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
6026 .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
6027 .site-footer a{color:var(--muted);}
6028 @media(max-width:680px){
6029 .card-body{grid-template-columns:1fr;}
6030 .card-right{flex-direction:row;flex-wrap:wrap;}
6031 .btn{flex:1;}
6032 }
6033 </style>
6034</head>
6035<body>
6036 <div class="background-watermarks" aria-hidden="true">
6037 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
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 </div>
6045 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
6046 <div class="top-nav">
6047 <div class="top-nav-inner">
6048 <a class="brand" href="/">
6049 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
6050 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Source line analysis workbench</div></div>
6051 </a>
6052 <div class="nav-right">
6053 <a class="nav-pill" href="/">Home</a>
6054 <a class="nav-pill" href="/view-reports">View Reports</a>
6055 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
6056 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
6057 <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>
6058 <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>
6059 </button>
6060 </div>
6061 </div>
6062 </div>
6063
6064 <div class="page">
6065 <div class="breadcrumb">
6066 <a href="/">Home</a>
6067 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
6068 <span>Scan Setup</span>
6069 </div>
6070
6071 <div class="page-header">
6072 <h1>How would you like to scan?</h1>
6073 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
6074 </div>
6075
6076 <div class="option-grid">
6077
6078 <!-- Option 1: New scan -->
6079 <div class="option-card">
6080 <div class="card-body">
6081 <div class="card-left">
6082 <div class="option-icon new-scan">
6083 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
6084 </div>
6085 <div class="card-text">
6086 <div class="option-title">Start a new scan</div>
6087 <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>
6088 <ul class="feature-list">
6089 <li>Live project scope preview before you run</li>
6090 <li>4 line-counting modes with interactive examples</li>
6091 <li>HTML, PDF, and JSON output — your choice</li>
6092 <li>IEEE 1045-1992 compliant physical SLOC counting</li>
6093 </ul>
6094 </div>
6095 </div>
6096 <div class="card-right">
6097 <a class="btn btn-primary" href="/scan">
6098 Configure & scan
6099 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
6100 </a>
6101 <p class="card-tip">Full 4-step setup · all options</p>
6102 </div>
6103 </div>
6104 </div>
6105
6106 <!-- Option 2: Load from config file -->
6107 <div class="option-card">
6108 <div class="card-body">
6109 <div class="card-left">
6110 <div class="option-icon load-config">
6111 <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>
6112 </div>
6113 <div class="card-text">
6114 <div class="option-title">Load a saved config</div>
6115 <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>
6116 <ul class="feature-list">
6117 <li>All 15 settings restored from the file</li>
6118 <li>Fully editable — change path or output dir</li>
6119 <li>Works with any scan-config.json</li>
6120 </ul>
6121 </div>
6122 </div>
6123 <div class="card-right">
6124 <div class="file-input-wrap">
6125 <button class="btn btn-secondary" id="load-config-btn" type="button">
6126 <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>
6127 Choose config file
6128 </button>
6129 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
6130 </div>
6131 <p class="card-tip" id="config-file-name">Exported after every scan</p>
6132 </div>
6133 </div>
6134 </div>
6135
6136 <!-- Option 3: Re-scan recent project -->
6137 <div class="option-card" id="recent-card">
6138 <div class="card-body">
6139 <div class="card-left" style="grid-column:1/-1;">
6140 <div class="option-icon rescan">
6141 <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>
6142 </div>
6143 <div class="card-text">
6144 <div class="option-title">Re-scan a recent project</div>
6145 <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>
6146 <ul class="feature-list">
6147 <li>All 15+ settings restored from the saved config</li>
6148 <li>Path and output dir are editable before running</li>
6149 <li>Only scans with a saved config appear here</li>
6150 </ul>
6151 </div>
6152 </div>
6153 </div>
6154 <div class="section-divider"></div>
6155 <div class="recent-list" id="recent-list">
6156 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
6157 </div>
6158 </div>
6159
6160 </div>
6161 </div>
6162
6163 <footer class="site-footer">
6164 oxide-sloc — local source line analysis workbench ·
6165 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
6166 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
6167 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
6168 </footer>
6169
6170 <script>
6171 (function () {
6172 var storageKey = 'oxide-sloc-theme';
6173 var body = document.body;
6174 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
6175 var toggle = document.getElementById('theme-toggle');
6176 if (toggle) toggle.addEventListener('click', function () {
6177 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
6178 body.classList.toggle('dark-theme', next === 'dark');
6179 try { localStorage.setItem(storageKey, next); } catch(e) {}
6180 });
6181
6182 (function randomizeWatermarks() {
6183 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6184 if (!wms.length) return;
6185 var placed = [];
6186 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; }
6187 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]; }
6188 var half = Math.floor(wms.length / 2);
6189 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 + ';'; });
6190 })();
6191 (function spawnCodeParticles() {
6192 var container = document.getElementById('code-particles');
6193 if (!container) return;
6194 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'];
6195 var count = 38;
6196 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); }
6197 })();
6198
6199 // Recent scans data injected from server
6200 var recentScans = {{ recent_scans_json|safe }};
6201
6202 function configToParams(cfg) {
6203 var p = new URLSearchParams();
6204 p.set('prefilled', '1');
6205 if (cfg.path) p.set('path', cfg.path);
6206 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
6207 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
6208 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
6209 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
6210 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
6211 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
6212 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
6213 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
6214 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
6215 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
6216 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
6217 if (cfg.report_title) p.set('report_title', cfg.report_title);
6218 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
6219 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
6220 return p;
6221 }
6222
6223 // Build recent scan list (capped at 3 visible entries)
6224 var list = document.getElementById('recent-list');
6225 var noNote = document.getElementById('no-recent-note');
6226 var hasAny = false;
6227 var MAX_RECENT = 3;
6228 if (Array.isArray(recentScans)) {
6229 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
6230 var shown = 0;
6231 validEntries.forEach(function (entry) {
6232 if (shown >= MAX_RECENT) return;
6233 shown++;
6234 hasAny = true;
6235 var item = document.createElement('div');
6236 item.className = 'recent-item';
6237 item.title = 'Restore all settings and open wizard';
6238 item.innerHTML =
6239 '<div class="recent-item-info">' +
6240 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
6241 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' · ' + escHtml(entry.timestamp || '') + '</div>' +
6242 '</div>' +
6243 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
6244 item.addEventListener('click', function () {
6245 var params = configToParams(entry.config);
6246 window.location.href = '/scan?' + params.toString();
6247 });
6248 list.appendChild(item);
6249 });
6250 if (validEntries.length > MAX_RECENT) {
6251 var moreEl = document.createElement('div');
6252 moreEl.className = 'recent-more-link';
6253 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
6254 list.appendChild(moreEl);
6255 }
6256 }
6257 if (hasAny && noNote) noNote.style.display = 'none';
6258
6259 // Config file loader
6260 var fileInput = document.getElementById('config-file-input');
6261 var fileName = document.getElementById('config-file-name');
6262 if (fileInput) {
6263 fileInput.addEventListener('change', function () {
6264 var file = fileInput.files && fileInput.files[0];
6265 if (!file) return;
6266 if (fileName) fileName.textContent = '✓ ' + file.name;
6267 var reader = new FileReader();
6268 reader.onload = function (e) {
6269 try {
6270 var cfg = JSON.parse(e.target.result);
6271 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
6272 var params = configToParams(cfg);
6273 window.location.href = '/scan?' + params.toString();
6274 } catch (err) {
6275 alert('Could not parse config file: ' + err.message);
6276 }
6277 };
6278 reader.readAsText(file);
6279 });
6280 }
6281
6282 function escHtml(s) {
6283 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
6284 }
6285 })();
6286 </script>
6287</body>
6288</html>
6289"##,
6290 ext = "html"
6291)]
6292struct ScanSetupTemplate {
6293 recent_scans_json: String,
6294}
6295
6296#[derive(Template)]
6297#[template(
6298 source = r##"
6299<!doctype html>
6300<html lang="en">
6301<head>
6302 <meta charset="utf-8">
6303 <meta name="viewport" content="width=device-width, initial-scale=1">
6304 <title>OxideSLOC | {{ report_title }} | Report</title>
6305 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
6306 <style>
6307 :root {
6308 --radius: 18px;
6309 --bg: #f5efe8;
6310 --surface: rgba(255,255,255,0.82);
6311 --surface-2: #fbf7f2;
6312 --surface-3: #efe6dc;
6313 --line: #e6d0bf;
6314 --line-strong: #dcb89f;
6315 --text: #43342d;
6316 --muted: #7b675b;
6317 --muted-2: #a08777;
6318 --nav: #b85d33;
6319 --nav-2: #7a371b;
6320 --accent: #6f9bff;
6321 --accent-2: #4a78ee;
6322 --oxide: #d37a4c;
6323 --oxide-2: #b35428;
6324 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
6325 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
6326 --success-bg: #e8f5ed;
6327 --success-text: #1a8f47;
6328 --info-bg: #eef3ff;
6329 --info-text: #4467d8;
6330 }
6331
6332 body.dark-theme {
6333 --bg: #1b1511;
6334 --surface: #261c17;
6335 --surface-2: #2d221d;
6336 --surface-3: #372922;
6337 --line: #524238;
6338 --line-strong: #6c5649;
6339 --text: #f5ece6;
6340 --muted: #c7b7aa;
6341 --muted-2: #aa9485;
6342 --nav: #b85d33;
6343 --nav-2: #7a371b;
6344 --accent: #6f9bff;
6345 --accent-2: #4a78ee;
6346 --oxide: #d37a4c;
6347 --oxide-2: #b35428;
6348 --shadow: 0 18px 42px rgba(0,0,0,0.28);
6349 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
6350 --success-bg: #163927;
6351 --success-text: #8fe2a8;
6352 --info-bg: #1c2847;
6353 --info-text: #a9c1ff;
6354 }
6355
6356 * { box-sizing: border-box; }
6357 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); }
6358 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
6359 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
6360 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
6361 .top-nav, .page { position: relative; z-index: 2; }
6362 .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); }
6363 .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; }
6364 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
6365 .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)); }
6366 .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; }
6367 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
6368 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
6369 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
6370 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
6371 .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; }
6372 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
6373 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; }
6374 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
6375 .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); }
6376 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
6377 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
6378 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
6379 .theme-toggle .icon-sun { display:none; }
6380 body.dark-theme .theme-toggle .icon-sun { display:block; }
6381 body.dark-theme .theme-toggle .icon-moon { display:none; }
6382 .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; }
6383 .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;}
6384 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
6385 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
6386 .hero, .panel { padding: 22px; }
6387 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
6388 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
6389 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
6390 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
6391 .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; }
6392 .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
6393 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
6394 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
6395 .delta-chip.pos { background:#e6f4ea; color:#1e7e34; }
6396 .delta-chip.neg { background:#fde8e8; color:#b91c1c; }
6397 .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; }
6398 .delta-card-inline { background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:6px 12px; text-align:center; min-width:80px; }
6399 .delta-card-val { font-size:16px; font-weight:800; }
6400 .delta-card-val.pos { color:#1e7e34; }
6401 .delta-card-val.neg { color:#b91c1c; }
6402 .delta-card-val.mod { color:#b35428; }
6403 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
6404 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
6405 .compare-ts { font-size:13px; color:var(--muted); }
6406 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
6407 .compare-arrow { color: var(--muted); }
6408 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-top: 18px; }
6409 .action-card { padding: 16px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
6410 .action-card h3 { margin:0 0 10px; font-size: 16px; }
6411 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; }
6412 .button, .copy-button {
6413 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;
6414 }
6415 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
6416 .path-list { display: grid; grid-template-columns: 1fr 0.6fr 1.4fr; gap: 10px; margin-top: 18px; }
6417 .path-item { padding: 10px 14px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: space-between; }
6418 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
6419 .path-item strong { display: block; margin-bottom: 6px; }
6420 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
6421 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
6422 .path-subitem { flex: 1; }
6423 .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); }
6424 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); }
6425 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
6426 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
6427 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
6428 th:first-child, td:first-child { width: 28%; }
6429 th { color: var(--muted); font-weight: 700; }
6430 tr:last-child td { border-bottom: none; }
6431 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
6432 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
6433 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
6434 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
6435 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
6436 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
6437 .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; }
6438 .soft-chip.success { background: var(--success-bg); color: var(--success-text); }
6439 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
6440 .muted { color: var(--muted); }
6441 .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; }
6442 .site-footer a { color: var(--muted-2); font-weight: 700; text-decoration: none; }
6443 .site-footer a:hover { color: var(--text); text-decoration: underline; }
6444 .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; }
6445 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
6446 .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; }
6447 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
6448 /* Submodule panel */
6449 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
6450 /* Metrics tables stack */
6451 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
6452 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
6453 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
6454 .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)); }
6455 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
6456 /* Metrics table */
6457 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
6458 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
6459 .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; }
6460 .metrics-table thead th:not(:first-child) { text-align: right; }
6461 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
6462 .metrics-table tbody tr:last-child td { border-bottom: none; }
6463 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
6464 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
6465 .metrics-table tbody tr:hover td { background: var(--surface-2); }
6466 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
6467 .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; }
6468 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
6469 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
6470 .mt-val-pos { color: #1e7e34; font-weight: 700; }
6471 .mt-val-neg { color: #b91c1c; font-weight: 700; }
6472 .mt-val-zero { color: var(--muted); }
6473 .mt-val-mod { color: var(--oxide-2); }
6474 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
6475 @media (max-width: 1180px) {
6476 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
6477 .nav-project-slot, .nav-status { justify-content:flex-start; }
6478 .hero-top { flex-direction: column; }
6479 }
6480 .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;}
6481 @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));}}
6482 </style>
6483</head>
6484<body>
6485 <div class="background-watermarks" aria-hidden="true">
6486 <img src="/images/logo/logo-text.png" alt="" />
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 </div>
6501 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
6502 <div class="top-nav">
6503 <div class="top-nav-inner">
6504 <a class="brand" href="/">
6505 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
6506 <div class="brand-copy">
6507 <div class="brand-title">OxideSLOC</div>
6508 <div class="brand-subtitle">Local analysis workbench</div>
6509 </div>
6510 </a>
6511 <div class="nav-project-slot">
6512 <div class="nav-project-pill"><span class="nav-project-label">Project</span><span class="nav-project-value">{{ report_title }}</span></div>
6513 </div>
6514 <div class="nav-status">
6515 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
6516 <a class="nav-pill" href="/view-reports" style="text-decoration:none;">View Reports</a>
6517 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
6518 <div class="server-status-wrap">
6519 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
6520 <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>
6521 </div>
6522 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
6523 <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>
6524 <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>
6525 </button>
6526 </div>
6527 </div>
6528 </div>
6529
6530 <div class="page">
6531 <section class="hero">
6532 <div class="hero-top">
6533 <div>
6534 <div class="soft-chip success">Run finished successfully</div>
6535 <h1 class="hero-title">{{ report_title }}</h1>
6536 <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>
6537 </div>
6538 <div class="hero-quick-actions">
6539 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
6540 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
6541 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
6542 </div>
6543 </div>
6544
6545 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
6546 <div class="compare-banner">
6547 <div class="compare-banner-body">
6548 <div class="compare-banner-meta">
6549 <span class="compare-label">Previous scan</span>
6550 <span class="compare-ts">{{ prev_ts }}</span>
6551 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
6552 {% if let Some(prev_code) = prev_run_code_lines %}
6553 <div class="compare-banner-stats" style="margin-top:4px;">
6554 <span>Code before: <strong>{{ prev_code }}</strong></span>
6555 <span class="compare-arrow">→</span>
6556 <span>Code now: <strong>{{ code_lines }}</strong></span>
6557 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
6558 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−{{ removed }} removed</span>{% endif %}
6559 </div>
6560 {% endif %}
6561 </div>
6562 {% if delta_lines_added.is_some() %}
6563 <div class="delta-cards-inline">
6564 <div class="delta-card-inline">
6565 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
6566 <div class="delta-card-lbl">lines added</div>
6567 </div>
6568 <div class="delta-card-inline">
6569 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v }}{% else %}—{% endif %}</div>
6570 <div class="delta-card-lbl">lines removed</div>
6571 </div>
6572 <div class="delta-card-inline">
6573 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
6574 <div class="delta-card-lbl">unmodified lines</div>
6575 </div>
6576 <div class="delta-card-inline">
6577 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
6578 <div class="delta-card-lbl">files modified</div>
6579 </div>
6580 <div class="delta-card-inline">
6581 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
6582 <div class="delta-card-lbl">files added</div>
6583 </div>
6584 <div class="delta-card-inline">
6585 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
6586 <div class="delta-card-lbl">files removed</div>
6587 </div>
6588 <div class="delta-card-inline">
6589 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
6590 <div class="delta-card-lbl">files unchanged</div>
6591 </div>
6592 </div>
6593 {% else %}
6594 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
6595 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
6596 </p>
6597 {% endif %}
6598 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
6599 </div>
6600 </div>
6601 {% endif %}{% endif %}
6602
6603 <div class="action-grid">
6604 <div class="action-card">
6605 <h3>HTML report</h3>
6606 <div class="action-buttons">
6607 {% match html_url %}
6608 {% when Some with (url) %}
6609 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
6610 {% when None %}{% endmatch %}
6611 {% match html_download_url %}
6612 {% when Some with (url) %}
6613 <a class="button secondary" href="{{ url }}">Download HTML</a>
6614 {% when None %}{% endmatch %}
6615 {% match html_path %}
6616 {% when Some with (_path) %}{% when None %}{% endmatch %}
6617 </div>
6618 </div>
6619 <div class="action-card">
6620 <h3>PDF report</h3>
6621 <div class="action-buttons">
6622 {% match pdf_url %}
6623 {% when Some with (url) %}
6624 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open PDF</a>
6625 {% when None %}{% endmatch %}
6626 {% match pdf_download_url %}
6627 {% when Some with (url) %}
6628 <a class="button secondary" href="{{ url }}">Download PDF</a>
6629 {% when None %}{% endmatch %}
6630 {% match pdf_path %}
6631 {% when Some with (_path) %}{% when None %}{% endmatch %}
6632 </div>
6633 </div>
6634 <div class="action-card">
6635 <h3>JSON result</h3>
6636 <div class="action-buttons">
6637 {% match json_url %}
6638 {% when Some with (url) %}
6639 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
6640 {% when None %}{% endmatch %}
6641 {% match json_download_url %}
6642 {% when Some with (url) %}
6643 <a class="button secondary" href="{{ url }}">Download JSON</a>
6644 {% when None %}{% endmatch %}
6645 {% match json_path %}
6646 {% when Some with (_path) %}{% when None %}
6647 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
6648 {% endmatch %}
6649 </div>
6650 </div>
6651 <div class="action-card">
6652 <h3>Scan config</h3>
6653 <div class="action-buttons">
6654 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
6655 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
6656 </div>
6657 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
6658 </div>
6659 </div>
6660 {% if !submodule_rows.is_empty() %}
6661 <div class="submodule-panel">
6662 <div class="toolbar-row">
6663 <div>
6664 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
6665 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
6666 </div>
6667 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
6668 </div>
6669 <div style="overflow:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
6670 <table style="width:100%;border-collapse:collapse;font-size:14px;">
6671 <thead>
6672 <tr>
6673 <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>
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;">Path</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:right;">Files</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;">Physical</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;">Code</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;">Comments</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;">Blank</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:center;">Report</th>
6681 </tr>
6682 </thead>
6683 <tbody>
6684 {% for row in submodule_rows %}
6685 <tr>
6686 <td style="padding:10px 14px;border-bottom:1px solid var(--line);font-weight:700;"><strong>{{ row.name }}</strong></td>
6687 <td style="padding:10px 14px;border-bottom:1px solid var(--line);"><code style="font-size:12px;">{{ row.relative_path }}</code></td>
6688 <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.files_analyzed }}</td>
6689 <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.total_physical_lines }}</td>
6690 <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.code_lines }}</td>
6691 <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.comment_lines }}</td>
6692 <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.blank_lines }}</td>
6693 <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>
6694 </tr>
6695 {% endfor %}
6696 </tbody>
6697 </table>
6698 </div>
6699 </div>
6700 {% endif %}
6701
6702 <div class="metrics-tables-stack">
6703
6704 <div class="metrics-table-wrap">
6705 <div class="metrics-table-title">Files</div>
6706 <table class="metrics-table">
6707 <thead>
6708 <tr>
6709 <th>Metric</th>
6710 <th>This Run</th>
6711 <th>Previous</th>
6712 <th>Change</th>
6713 </tr>
6714 </thead>
6715 <tbody>
6716 <tr>
6717 <td>Files analyzed</td>
6718 <td class="mt-val-large">{{ files_analyzed }}</td>
6719 <td>{{ prev_fa_str }}</td>
6720 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
6721 </tr>
6722 <tr>
6723 <td>Files skipped</td>
6724 <td>{{ files_skipped }}</td>
6725 <td>{{ prev_fs_str }}</td>
6726 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
6727 </tr>
6728 <tr>
6729 <td>Files modified</td>
6730 <td class="mt-val-na">—</td>
6731 <td class="mt-val-na">—</td>
6732 <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>
6733 </tr>
6734 <tr>
6735 <td>Files unchanged</td>
6736 <td class="mt-val-na">—</td>
6737 <td class="mt-val-na">—</td>
6738 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
6739 </tr>
6740 </tbody>
6741 </table>
6742 </div>
6743
6744 <div class="metrics-table-wrap">
6745 <div class="metrics-table-title">Line Counts</div>
6746 <table class="metrics-table">
6747 <thead>
6748 <tr>
6749 <th>Metric</th>
6750 <th>This Run</th>
6751 <th>Previous</th>
6752 <th>Change</th>
6753 </tr>
6754 </thead>
6755 <tbody>
6756 <tr>
6757 <td>Physical lines</td>
6758 <td class="mt-val-large">{{ physical_lines }}</td>
6759 <td>{{ prev_pl_str }}</td>
6760 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
6761 </tr>
6762 <tr>
6763 <td>Code lines</td>
6764 <td class="mt-val-large">{{ code_lines }}</td>
6765 <td>{{ prev_cl_str }}</td>
6766 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
6767 </tr>
6768 <tr>
6769 <td>Comment lines</td>
6770 <td>{{ comment_lines }}</td>
6771 <td>{{ prev_cml_str }}</td>
6772 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
6773 </tr>
6774 <tr>
6775 <td>Blank lines</td>
6776 <td>{{ blank_lines }}</td>
6777 <td>{{ prev_bl_str }}</td>
6778 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
6779 </tr>
6780 <tr>
6781 <td>Mixed (separate)</td>
6782 <td>{{ mixed_lines }}</td>
6783 <td class="mt-val-na">—</td>
6784 <td class="mt-val-na">—</td>
6785 </tr>
6786 </tbody>
6787 </table>
6788 </div>
6789
6790 <div class="metrics-tables-lower">
6791 <div class="metrics-table-wrap">
6792 <div class="metrics-table-title">Code Structure</div>
6793 <table class="metrics-table">
6794 <thead>
6795 <tr>
6796 <th>Metric</th>
6797 <th>This Run</th>
6798 </tr>
6799 </thead>
6800 <tbody>
6801 <tr>
6802 <td>Functions</td>
6803 <td>{{ functions }}</td>
6804 </tr>
6805 <tr>
6806 <td>Classes / Types</td>
6807 <td>{{ classes }}</td>
6808 </tr>
6809 <tr>
6810 <td>Variables</td>
6811 <td>{{ variables }}</td>
6812 </tr>
6813 <tr>
6814 <td>Imports</td>
6815 <td>{{ imports }}</td>
6816 </tr>
6817 </tbody>
6818 </table>
6819 </div>
6820
6821 <div class="metrics-table-wrap">
6822 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
6823 <table class="metrics-table">
6824 <thead>
6825 <tr>
6826 <th>Metric</th>
6827 <th>Change</th>
6828 </tr>
6829 </thead>
6830 <tbody>
6831 <tr>
6832 <td>Lines added</td>
6833 <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>
6834 </tr>
6835 <tr>
6836 <td>Lines removed</td>
6837 <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>
6838 </tr>
6839 <tr>
6840 <td>Lines modified (net)</td>
6841 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
6842 </tr>
6843 <tr>
6844 <td>Lines unmodified</td>
6845 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
6846 </tr>
6847 </tbody>
6848 </table>
6849 </div>
6850 </div>
6851
6852 </div>
6853
6854 <div class="path-list">
6855 <div class="path-item">
6856 <div class="path-item-label">Project path</div>
6857 <code>{{ project_path }}</code>
6858 </div>
6859 <div class="path-item">
6860 <div class="path-item-label">Git branch</div>
6861 {% if let Some(branch) = git_branch %}
6862 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
6863 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
6864 {% else %}
6865 <code style="color:var(--muted)">—</code>
6866 {% endif %}
6867 </div>
6868 <div class="path-item path-item-split">
6869 <div class="path-subitem">
6870 <div class="path-item-label">Output folder</div>
6871 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
6872 </div>
6873 <div class="path-subitem" style="border-top:1px solid var(--line);padding-top:8px;margin-top:8px;">
6874 <div class="path-item-label">Run ID</div>
6875 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
6876 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
6877 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
6878 </div>
6879 </div>
6880 </div>
6881 </div>
6882 </section>
6883
6884 <section class="panel" style="margin-bottom: 18px;">
6885 <div class="toolbar-row">
6886 <div>
6887 <h2>Language breakdown</h2>
6888 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
6889 </div>
6890 </div>
6891 <table>
6892 <thead>
6893 <tr>
6894 <th>Language</th>
6895 <th>Files</th>
6896 <th>Physical</th>
6897 <th>Code</th>
6898 <th>Comments</th>
6899 <th>Blank</th>
6900 <th>Mixed</th>
6901 <th>Functions</th>
6902 <th>Classes</th>
6903 <th>Variables</th>
6904 <th>Imports</th>
6905 </tr>
6906 </thead>
6907 <tbody>
6908 {% for row in language_rows %}
6909 <tr>
6910 <td>{{ row.language }}</td>
6911 <td>{{ row.files }}</td>
6912 <td>{{ row.physical }}</td>
6913 <td>{{ row.code }}</td>
6914 <td>{{ row.comments }}</td>
6915 <td>{{ row.blank }}</td>
6916 <td>{{ row.mixed }}</td>
6917 <td>{{ row.functions }}</td>
6918 <td>{{ row.classes }}</td>
6919 <td>{{ row.variables }}</td>
6920 <td>{{ row.imports }}</td>
6921 </tr>
6922 {% endfor %}
6923 </tbody>
6924 </table>
6925 </section>
6926
6927 </div>
6928
6929 <script>
6930 (function () {
6931 var body = document.body;
6932 var themeToggle = document.getElementById('theme-toggle');
6933 var storageKey = 'oxide-sloc-theme';
6934
6935 function applyTheme(theme) {
6936 body.classList.toggle('dark-theme', theme === 'dark');
6937 }
6938
6939 function loadSavedTheme() {
6940 try {
6941 var saved = localStorage.getItem(storageKey);
6942 if (saved === 'dark' || saved === 'light') {
6943 applyTheme(saved);
6944 }
6945 } catch (e) {}
6946 }
6947
6948 if (themeToggle) {
6949 themeToggle.addEventListener('click', function () {
6950 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
6951 applyTheme(nextTheme);
6952 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
6953 });
6954 }
6955
6956 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
6957 button.addEventListener('click', function () {
6958 var value = button.getAttribute('data-copy-value') || '';
6959 if (!value) return;
6960 if (navigator.clipboard && navigator.clipboard.writeText) {
6961 navigator.clipboard.writeText(value).catch(function () {});
6962 }
6963 });
6964 });
6965
6966 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
6967 btn.addEventListener('click', function () {
6968 var folder = btn.getAttribute('data-folder') || '';
6969 if (!folder) return;
6970 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
6971 });
6972 });
6973
6974 loadSavedTheme();
6975
6976 (function randomizeWatermarks() {
6977 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
6978 if (!wms.length) return;
6979 var placed = [];
6980 function tooClose(top, left) {
6981 for (var i = 0; i < placed.length; i++) {
6982 var dt = Math.abs(placed[i][0] - top);
6983 var dl = Math.abs(placed[i][1] - left);
6984 if (dt < 20 && dl < 18) return true;
6985 }
6986 return false;
6987 }
6988 function pick(leftBand) {
6989 for (var attempt = 0; attempt < 50; attempt++) {
6990 var top = Math.random() * 85 + 5;
6991 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
6992 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
6993 }
6994 var top = Math.random() * 85 + 5;
6995 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
6996 placed.push([top, left]);
6997 return [top, left];
6998 }
6999 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
7000 var half = Math.floor(wms.length / 2);
7001 wms.forEach(function (img, i) {
7002 var pos = pick(i < half);
7003 var size = Math.floor(Math.random() * 100 + 160);
7004 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
7005 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
7006 img.style.cssText = "width:" + size + "px;top:" + pos[0].toFixed(1) + "%;left:" + pos[1].toFixed(1) + "%;transform:rotate(" + rot.toFixed(1) + "deg);opacity:" + op + ";";
7007 });
7008 })();
7009
7010 (function spawnCodeParticles() {
7011 var container = document.getElementById('code-particles');
7012 if (!container) return;
7013 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'];
7014 for (var i = 0; i < 38; i++) {
7015 (function(idx) {
7016 var el = document.createElement('span');
7017 el.className = 'code-particle';
7018 el.textContent = snippets[idx % snippets.length];
7019 var left = Math.random() * 94 + 2;
7020 var top = Math.random() * 88 + 6;
7021 var dur = (Math.random() * 10 + 9).toFixed(1);
7022 var delay = (Math.random() * 18).toFixed(1);
7023 var rot = (Math.random() * 26 - 13).toFixed(1);
7024 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7025 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
7026 container.appendChild(el);
7027 })(i);
7028 }
7029 })();
7030 })();
7031 </script>
7032 <footer class="site-footer">
7033 oxide-sloc — local source line analysis workbench ·
7034 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7035 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7036 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7037 </footer>
7038</body>
7039</html>
7040"##,
7041 ext = "html"
7042)]
7043struct ResultTemplate {
7044 report_title: String,
7045 project_path: String,
7046 output_dir: String,
7047 run_id: String,
7048 files_analyzed: u64,
7049 files_skipped: u64,
7050 physical_lines: u64,
7051 code_lines: u64,
7052 comment_lines: u64,
7053 blank_lines: u64,
7054 mixed_lines: u64,
7055 functions: u64,
7056 classes: u64,
7057 variables: u64,
7058 imports: u64,
7059 html_url: Option<String>,
7060 pdf_url: Option<String>,
7061 json_url: Option<String>,
7062 html_download_url: Option<String>,
7063 pdf_download_url: Option<String>,
7064 json_download_url: Option<String>,
7065 html_path: Option<String>,
7066 pdf_path: Option<String>,
7067 json_path: Option<String>,
7068 language_rows: Vec<LanguageSummaryRow>,
7069 prev_run_id: Option<String>,
7070 prev_run_timestamp: Option<String>,
7071 prev_run_code_lines: Option<u64>,
7072 prev_fa_str: String,
7074 prev_fs_str: String,
7075 prev_pl_str: String,
7076 prev_cl_str: String,
7077 prev_cml_str: String,
7078 prev_bl_str: String,
7079 delta_fa_str: String,
7081 delta_fa_class: String,
7082 delta_fs_str: String,
7083 delta_fs_class: String,
7084 delta_pl_str: String,
7085 delta_pl_class: String,
7086 delta_cl_str: String,
7087 delta_cl_class: String,
7088 delta_cml_str: String,
7089 delta_cml_class: String,
7090 delta_bl_str: String,
7091 delta_bl_class: String,
7092 delta_lines_added: Option<i64>,
7094 delta_lines_removed: Option<i64>,
7095 delta_lines_net_str: String,
7096 delta_lines_net_class: String,
7097 delta_files_added: Option<usize>,
7098 delta_files_removed: Option<usize>,
7099 delta_files_modified: Option<usize>,
7100 delta_files_unchanged: Option<usize>,
7101 delta_unmodified_lines: Option<u64>,
7102 git_branch: Option<String>,
7104 git_commit: Option<String>,
7105 git_author: Option<String>,
7106 prev_scan_count: usize,
7108 current_scan_number: usize,
7109 submodule_rows: Vec<SubmoduleRow>,
7111 scan_config_url: String,
7112}
7113
7114#[derive(Template)]
7115#[template(
7116 source = r##"
7117<!doctype html>
7118<html lang="en">
7119<head>
7120 <meta charset="utf-8">
7121 <meta name="viewport" content="width=device-width, initial-scale=1">
7122 <title>OxideSLOC | Error</title>
7123 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7124 <style>
7125 :root {
7126 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
7127 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7128 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#4a78ee;
7129 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7130 }
7131 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
7132 *{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);}
7133 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7134 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
7135 .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);}
7136 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
7137 .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));}
7138 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
7139 .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;}
7140 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
7141 .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;}
7142 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
7143 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
7144 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
7145 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
7146 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
7147 .page{max-width:1720px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
7148 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
7149 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
7150 .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;}
7151 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
7152 .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);}
7153 .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;}
7154 .btn-secondary:hover{background:var(--line);}
7155 .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;}
7156 .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;}
7157 .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;}
7158 @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));}}
7159 </style>
7160</head>
7161<body>
7162 <div class="background-watermarks" aria-hidden="true">
7163 <img src="/images/logo/logo-text.png" alt="" style="width:320px;top:-40px;left:-60px;transform:rotate(-12deg);" />
7164 <img src="/images/logo/logo-text.png" alt="" style="width:280px;top:120px;right:-50px;transform:rotate(8deg);" />
7165 <img src="/images/logo/logo-text.png" alt="" style="width:260px;bottom:60px;left:30px;transform:rotate(15deg);" />
7166 <img src="/images/logo/logo-text.png" alt="" style="width:300px;bottom:-20px;right:80px;transform:rotate(-6deg);" />
7167 <img src="/images/logo/logo-text.png" alt="" style="width:240px;top:50%;left:45%;transform:rotate(22deg);" />
7168 <img src="/images/logo/logo-text.png" alt="" style="width:270px;top:10%;left:35%;transform:rotate(-18deg);" />
7169 </div>
7170 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7171 <div class="top-nav">
7172 <div class="top-nav-inner">
7173 <a class="brand" href="/">
7174 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
7175 <div class="brand-copy">
7176 <div class="brand-title">OxideSLOC</div>
7177 <div class="brand-subtitle">Local analysis workbench</div>
7178 </div>
7179 </a>
7180 <div class="nav-right">
7181 <a class="nav-pill" href="/">Home</a>
7182 <a class="nav-pill" href="/view-reports">View Reports</a>
7183 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7184 <div class="server-status-wrap">
7185 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
7186 <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>
7187 </div>
7188 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7189 <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>
7190 <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>
7191 </button>
7192 </div>
7193 </div>
7194 </div>
7195
7196 <div class="page">
7197 <div class="panel">
7198 <h1>Analysis failed</h1>
7199 <div class="error-box">{{ message }}</div>
7200 <div class="actions">
7201 <a class="btn-primary" href="/scan">Back to setup</a>
7202 {% if let Some(report_url) = last_report_url %}
7203 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
7204 {% endif %}
7205 <a class="btn-secondary" href="/view-reports">View Reports</a>
7206 </div>
7207 </div>
7208 </div>
7209 <script>
7210 (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");});})();
7211 (function spawnCodeParticles() {
7212 var container = document.getElementById('code-particles');
7213 if (!container) return;
7214 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'];
7215 for (var i = 0; i < 38; i++) {
7216 (function(idx) {
7217 var el = document.createElement('span');
7218 el.className = 'code-particle';
7219 el.textContent = snippets[idx % snippets.length];
7220 var left = Math.random() * 94 + 2;
7221 var top = Math.random() * 88 + 6;
7222 var dur = (Math.random() * 10 + 9).toFixed(1);
7223 var delay = (Math.random() * 18).toFixed(1);
7224 var rot = (Math.random() * 26 - 13).toFixed(1);
7225 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7226 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
7227 container.appendChild(el);
7228 })(i);
7229 }
7230 })();
7231 </script>
7232</body>
7233</html>
7234"##,
7235 ext = "html"
7236)]
7237struct ErrorTemplate {
7238 message: String,
7239 last_report_url: Option<String>,
7241 last_report_label: Option<String>,
7243}
7244
7245#[derive(Template)]
7248#[template(
7249 source = r##"
7250<!doctype html>
7251<html lang="en">
7252<head>
7253 <meta charset="utf-8">
7254 <meta name="viewport" content="width=device-width, initial-scale=1">
7255 <title>OxideSLOC | View Reports</title>
7256 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7257 <style>
7258 :root {
7259 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7260 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7261 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
7262 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7263 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fdeaea;
7264 }
7265 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
7266 *{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);}
7267 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7268 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
7269 .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);}
7270 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
7271 .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));}
7272 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
7273 .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;}
7274 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
7275 .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;}
7276 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
7277 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
7278 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
7279 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
7280 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
7281 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
7282 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
7283 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
7284 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
7285 .panel-meta{font-size:13px;color:var(--muted);}
7286 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
7287 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
7288 .per-page-label{font-size:13px;color:var(--muted);}
7289 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;}
7290 .filter-input{min-width:180px;cursor:text;}
7291 .table-wrap{width:100%;overflow-x:auto;}
7292 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
7293 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;}
7294 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
7295 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
7296 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
7297 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
7298 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
7299 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
7300 tr:last-child td{border-bottom:none;}
7301 tr:hover td{background:var(--surface-2);}
7302 .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);}
7303 .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);}
7304 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
7305 .metric-num{font-weight:700;color:var(--text);}
7306 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
7307 .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;}
7308 .btn:hover{background:var(--line);}
7309 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
7310 .btn.primary:hover{opacity:.9;}
7311 .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;}
7312 .btn-back:hover{background:var(--line);}
7313 .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;}
7314 .export-btn:hover{background:var(--line);}
7315 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
7316 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
7317 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
7318 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
7319 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
7320 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
7321 .pagination-info{font-size:13px;color:var(--muted);}
7322 .pagination-btns{display:flex;gap:6px;}
7323 .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;}
7324 .pg-btn:hover:not(:disabled){background:var(--line);}
7325 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
7326 .pg-btn:disabled{opacity:.35;cursor:default;}
7327 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
7328 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
7329 .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;}
7330 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
7331 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
7332 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
7333 .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);}
7334 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
7335 .stat-chip:hover .stat-chip-tip{opacity:1;}
7336 .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
7337 .site-footer a{color:var(--muted);}
7338 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
7339 .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%;}
7340 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
7341 .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;}
7342 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
7343 .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;}
7344 .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;}
7345 .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;}
7346 @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));}}
7347 </style>
7348</head>
7349<body>
7350 <div class="background-watermarks" aria-hidden="true">
7351 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
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 </div>
7358 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7359 <div class="top-nav">
7360 <div class="top-nav-inner">
7361 <a class="brand" href="/">
7362 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7363 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
7364 </a>
7365 <div class="nav-right">
7366 <a class="nav-pill" href="/">Home</a>
7367 <a class="nav-pill" href="/view-reports">View Reports</a>
7368 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7369 <div class="server-status-wrap">
7370 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
7371 <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>
7372 </div>
7373 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7374 <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>
7375 <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>
7376 </button>
7377 </div>
7378 </div>
7379 </div>
7380
7381 <div class="page">
7382 {% if linked %}
7383 <div class="toast-success">
7384 <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>
7385 Report linked successfully — it now appears in the list below.
7386 </div>
7387 {% endif %}
7388 {% if total_scans > 0 %}
7389 <div class="summary-strip">
7390 <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>
7391 <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>
7392 <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>
7393 <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>
7394 </div>
7395 {% endif %}
7396
7397 <section class="panel">
7398 <div class="panel-header">
7399 <div>
7400 <h1>View Reports</h1>
7401 <p class="panel-meta">{{ total_scans }} report(s) available. Click any row to open it.</p>
7402 </div>
7403 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
7404 <div class="export-group">
7405 <button type="button" class="export-btn" onclick="exportHistoryCsv()">
7406 <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>
7407 Export CSV
7408 </button>
7409 <button type="button" class="export-btn" onclick="exportHistoryXls()">
7410 <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>
7411 Export Excel
7412 </button>
7413 </div>
7414 <a class="btn-back" href="/">
7415 <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>
7416 Home
7417 </a>
7418 </div>
7419 </div>
7420
7421 <div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;flex-wrap:wrap;">
7422 <span class="locate-label" style="white-space:nowrap;">Have a saved report on disk? Browse to link it here.</span>
7423 {% if !entries.is_empty() %}
7424 <div style="margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
7425 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…" oninput="applyFilters()">
7426 <select class="filter-select" id="branch-filter" onchange="applyFilters()"><option value="">All branches</option></select>
7427 <button type="button" class="btn" onclick="resetView()">↻ Reset view</button>
7428 </div>
7429 {% endif %}
7430 </div>
7431 <div style="margin-bottom:14px;">
7432 <button type="button" class="btn" onclick="browseReport()">
7433 <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>
7434 Browse for Report…
7435 </button>
7436 </div>
7437
7438 {% if entries.is_empty() %}
7439 <div class="empty-state">
7440 <strong>No reports with viewable HTML yet</strong>
7441 Run a new analysis from the <a href="/scan">scan page</a>, or use the browse button above to link an existing report.
7442 </div>
7443 {% else %}
7444 <div class="table-wrap">
7445 <table id="history-table">
7446 <colgroup>
7447 <col style="width:155px">
7448 <col style="width:160px">
7449 <col style="width:115px">
7450 <col style="width:88px">
7451 <col style="width:88px">
7452 <col style="width:88px">
7453 <col style="width:72px">
7454 <col style="width:80px">
7455 <col style="width:76px">
7456 <col style="width:80px">
7457 <col style="width:72px">
7458 <col style="width:92px">
7459 <col style="width:92px">
7460 <col style="width:160px">
7461 </colgroup>
7462 <thead>
7463 <tr id="history-thead">
7464 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7465 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7466 <th>Run ID<div class="col-resize-handle"></div></th>
7467 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7468 <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>
7469 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7470 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7471 <th>Functions<div class="col-resize-handle"></div></th>
7472 <th>Classes<div class="col-resize-handle"></div></th>
7473 <th>Variables<div class="col-resize-handle"></div></th>
7474 <th>Imports<div class="col-resize-handle"></div></th>
7475 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7476 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7477 <th>Report<div class="col-resize-handle"></div></th>
7478 </tr>
7479 </thead>
7480 <tbody id="history-tbody">
7481 {% for entry in entries %}
7482 <tr class="history-row" data-run="{{ entry.run_id }}"
7483 data-timestamp="{{ entry.timestamp }}"
7484 data-project="{{ entry.project_label }}"
7485 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
7486 data-skipped="{{ entry.files_skipped }}"
7487 data-comments="{{ entry.comment_lines }}"
7488 data-blank="{{ entry.blank_lines }}"
7489 data-branch="{{ entry.git_branch }}"
7490 data-commit="{{ entry.git_commit }}"
7491 style="cursor:pointer;"
7492 onclick="window.open('/runs/{{ entry.run_id }}/html', '_blank')">
7493 <td>{{ entry.timestamp }}</td>
7494 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
7495 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
7496 <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
7497 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
7498 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
7499 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
7500 <td><span class="metric-num">{{ entry.functions }}</span></td>
7501 <td><span class="metric-num">{{ entry.classes }}</span></td>
7502 <td><span class="metric-num">{{ entry.variables }}</span></td>
7503 <td><span class="metric-num">{{ entry.imports }}</span></td>
7504 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
7505 <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>
7506 <td style="overflow:visible;white-space:normal;">
7507 <div class="actions-cell">
7508 <a class="btn primary" href="/runs/{{ entry.run_id }}/html" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="View HTML report">View</a>
7509 {% 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 %}
7510 </div>
7511 </td>
7512 </tr>
7513 {% endfor %}
7514 </tbody>
7515 </table>
7516 </div>
7517 <div class="pagination">
7518 <span class="pagination-info" id="pagination-info"></span>
7519 <div class="pagination-btns" id="pagination-btns"></div>
7520 <div style="display:flex;align-items:center;gap:8px;">
7521 <span class="per-page-label">Show</span>
7522 <select class="per-page" id="per-page-sel" onchange="setPerPage(this.value)">
7523 <option value="10">10 per page</option>
7524 <option value="25" selected>25 per page</option>
7525 <option value="50">50 per page</option>
7526 <option value="100">100 per page</option>
7527 </select>
7528 <span class="per-page-label" id="page-range-label"></span>
7529 </div>
7530 </div>
7531 {% endif %}
7532 </section>
7533 </div>
7534
7535 <footer class="site-footer">
7536 oxide-sloc — local source line analysis workbench ·
7537 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7538 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7539 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7540 </footer>
7541
7542 <script>
7543 (function () {
7544 // ── Theme ──────────────────────────────────────────────────────────────
7545 var storageKey = 'oxide-sloc-theme';
7546 var body = document.body;
7547 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
7548 var toggle = document.getElementById('theme-toggle');
7549 if (toggle) toggle.addEventListener('click', function () {
7550 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
7551 body.classList.toggle('dark-theme', next === 'dark');
7552 try { localStorage.setItem(storageKey, next); } catch(e) {}
7553 });
7554
7555 // ── State ─────────────────────────────────────────────────────────────
7556 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
7557 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
7558 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
7559
7560 // Aggregate stats from first (most recent) row
7561 if (allRows.length) {
7562 var first = allRows[0];
7563 var ce = document.getElementById('agg-code'); if (ce) ce.textContent = Number(first.dataset.code).toLocaleString();
7564 var fe = document.getElementById('agg-files'); if (fe) fe.textContent = first.dataset.files;
7565 var se = document.getElementById('agg-skipped'); if (se) se.textContent = first.dataset.skipped;
7566 }
7567
7568 // ── Branch filter population ──────────────────────────────────────────
7569 (function() {
7570 var branches = {};
7571 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
7572 var sel = document.getElementById('branch-filter');
7573 if (sel) Object.keys(branches).sort().forEach(function(b) {
7574 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
7575 });
7576 })();
7577
7578 // ── Filter ────────────────────────────────────────────────────────────
7579 function getFilteredRows() {
7580 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
7581 var branch = ((document.getElementById('branch-filter') || {}).value || '');
7582 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
7583 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
7584 if (branch && (r.dataset.branch || '') !== branch) return false;
7585 return true;
7586 });
7587 }
7588
7589 // ── Pagination ────────────────────────────────────────────────────────
7590 function renderPage() {
7591 var filtered = getFilteredRows();
7592 var total = filtered.length;
7593 var totalPages = Math.max(1, Math.ceil(total / perPage));
7594 currentPage = Math.min(currentPage, totalPages);
7595 var start = (currentPage - 1) * perPage;
7596 var end = Math.min(start + perPage, total);
7597 var shown = {};
7598 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
7599 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
7600 r.style.display = shown[r.dataset.run] ? '' : 'none';
7601 });
7602 var rl = document.getElementById('page-range-label');
7603 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
7604 var info = document.getElementById('pagination-info');
7605 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
7606 var btns = document.getElementById('pagination-btns');
7607 if (!btns) return;
7608 btns.innerHTML = '';
7609 function makeBtn(lbl, pg, active, disabled) {
7610 var b = document.createElement('button');
7611 b.className = 'pg-btn' + (active ? ' active' : '');
7612 b.textContent = lbl; b.disabled = disabled;
7613 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
7614 return b;
7615 }
7616 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
7617 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
7618 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
7619 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
7620 }
7621
7622 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
7623 window.applyFilters = function() { currentPage = 1; renderPage(); };
7624
7625 // ── Sorting ───────────────────────────────────────────────────────────
7626 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
7627 function doSort(col, type, order) {
7628 var tbody = document.getElementById('history-tbody');
7629 if (!tbody) return;
7630 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
7631 rows.sort(function(a, b) {
7632 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
7633 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
7634 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
7635 return va < vb ? 1 : va > vb ? -1 : 0;
7636 });
7637 rows.forEach(function(r) { tbody.appendChild(r); });
7638 currentPage = 1; renderPage();
7639 }
7640 sortHeaders.forEach(function(th) {
7641 th.addEventListener('click', function(e) {
7642 if (e.target.classList.contains('col-resize-handle')) return;
7643 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
7644 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
7645 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
7646 th.classList.add('sort-' + sortOrder);
7647 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
7648 doSort(col, type, sortOrder);
7649 });
7650 });
7651
7652 // ── Column resize ─────────────────────────────────────────────────────
7653 (function() {
7654 var table = document.getElementById('history-table');
7655 if (!table) return;
7656 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
7657 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
7658 ths.forEach(function(th, i) {
7659 var handle = th.querySelector('.col-resize-handle');
7660 if (!handle || !cols[i]) return;
7661 var startX, startW;
7662 handle.addEventListener('mousedown', function(e) {
7663 e.stopPropagation(); e.preventDefault();
7664 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
7665 handle.classList.add('dragging');
7666 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
7667 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
7668 document.addEventListener('mousemove', onMove);
7669 document.addEventListener('mouseup', onUp);
7670 });
7671 });
7672 })();
7673
7674 // ── Reset view ────────────────────────────────────────────────────────
7675 window.resetView = function() {
7676 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
7677 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
7678 sortCol = null; sortOrder = 'asc';
7679 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
7680 var tbody = document.getElementById('history-tbody');
7681 if (tbody) {
7682 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
7683 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
7684 rows.forEach(function(r) { tbody.appendChild(r); });
7685 }
7686 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
7687 var table = document.getElementById('history-table');
7688 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
7689 currentPage = 1; renderPage();
7690 };
7691
7692 renderPage();
7693
7694 (function randomizeWatermarks() {
7695 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7696 if (!wms.length) return;
7697 var placed = [];
7698 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;}
7699 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];}
7700 var half=Math.floor(wms.length/2);
7701 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+';';});
7702 })();
7703
7704 (function spawnCodeParticles() {
7705 var container = document.getElementById('code-particles');
7706 if (!container) return;
7707 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'];
7708 for (var i = 0; i < 38; i++) {
7709 (function(idx) {
7710 var el = document.createElement('span');
7711 el.className = 'code-particle';
7712 el.textContent = snippets[idx % snippets.length];
7713 var left = Math.random() * 94 + 2;
7714 var top = Math.random() * 88 + 6;
7715 var dur = (Math.random() * 10 + 9).toFixed(1);
7716 var delay = (Math.random() * 18).toFixed(1);
7717 var rot = (Math.random() * 26 - 13).toFixed(1);
7718 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7719 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
7720 container.appendChild(el);
7721 })(i);
7722 }
7723 })();
7724 })();
7725
7726 function rowClick(runId, hasHtml) {
7727 if (hasHtml) window.open('/runs/' + runId + '/html', '_blank');
7728 }
7729
7730 function browseReport() {
7731 fetch('/pick-file?kind=report')
7732 .then(function(r) { return r.json(); })
7733 .then(function(data) {
7734 if (!data.cancelled && data.selected_path) {
7735 var form = document.createElement('form');
7736 form.method = 'POST';
7737 form.action = '/locate-report';
7738 var input = document.createElement('input');
7739 input.type = 'hidden';
7740 input.name = 'file_path';
7741 input.value = data.selected_path;
7742 form.appendChild(input);
7743 document.body.appendChild(form);
7744 form.submit();
7745 }
7746 })
7747 .catch(function(e) { alert('Could not open file picker: ' + e); });
7748 }
7749
7750 // ── Export helpers ────────────────────────────────────────────────────────
7751 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
7752 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
7753 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);}
7754 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;');}
7755 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');}
7756
7757 var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
7758 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;}
7759 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
7760 window.exportHistoryXls = function(){slocXls('scan-history.xls','Scan History',_hh,getHistoryRows());};
7761 </script>
7762</body>
7763</html>
7764"##,
7765 ext = "html"
7766)]
7767struct HistoryTemplate {
7768 entries: Vec<HistoryEntryRow>,
7769 total_scans: usize,
7770 linked: bool,
7771}
7772
7773#[derive(Template)]
7776#[template(
7777 source = r##"
7778<!doctype html>
7779<html lang="en">
7780<head>
7781 <meta charset="utf-8">
7782 <meta name="viewport" content="width=device-width, initial-scale=1">
7783 <title>OxideSLOC | Compare Scans</title>
7784 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7785 <style>
7786 :root {
7787 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7788 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7789 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
7790 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7791 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
7792 }
7793 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
7794 *{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);}
7795 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7796 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
7797 .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);}
7798 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
7799 .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));}
7800 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
7801 .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;}
7802 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
7803 .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;}
7804 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
7805 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
7806 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
7807 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
7808 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
7809 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
7810 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
7811 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
7812 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
7813 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
7814 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
7815 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
7816 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
7817 .per-page-label{font-size:13px;color:var(--muted);}
7818 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;}
7819 .filter-input{min-width:180px;cursor:text;}
7820 .table-wrap{width:100%;overflow-x:auto;}
7821 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
7822 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;}
7823 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--accent-2);}
7824 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
7825 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--accent-2);}
7826 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
7827 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(111,155,255,0.3);}
7828 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
7829 tr:last-child td{border-bottom:none;}
7830 tr.selected td{background:var(--sel-bg);}
7831 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
7832 tr:hover:not(.selected) td{background:var(--surface-2);}
7833 tr{cursor:pointer;}
7834 .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);}
7835 .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);}
7836 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
7837 .metric-num{font-weight:700;}
7838 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
7839 .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;}
7840 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
7841 .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;}
7842 .btn:hover{background:var(--line);}
7843 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
7844 .btn.primary:hover{opacity:.9;}
7845 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
7846 .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;}
7847 .btn-back:hover{background:var(--line);}
7848 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
7849 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
7850 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
7851 .pagination-info{font-size:13px;color:var(--muted);}
7852 .pagination-btns{display:flex;gap:6px;}
7853 .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;}
7854 .pg-btn:hover:not(:disabled){background:var(--line);}
7855 .pg-btn.active{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
7856 .pg-btn:disabled{opacity:.35;cursor:default;}
7857 .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
7858 .site-footer a{color:var(--muted);}
7859 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
7860 .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;}
7861 .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;}
7862 .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;}
7863 @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));}}
7864 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
7865 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
7866 .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;}
7867 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
7868 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
7869 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
7870 .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);}
7871 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
7872 .stat-chip:hover .stat-chip-tip{opacity:1;}
7873 .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;}
7874 .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%;}
7875 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
7876 </style>
7877</head>
7878<body>
7879 <div class="background-watermarks" aria-hidden="true">
7880 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
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 </div>
7887 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7888 <div class="top-nav">
7889 <div class="top-nav-inner">
7890 <a class="brand" href="/">
7891 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7892 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
7893 </a>
7894 <div class="nav-right">
7895 <a class="nav-pill" href="/">Home</a>
7896 <a class="nav-pill" href="/view-reports">View Reports</a>
7897 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7898 <div class="server-status-wrap">
7899 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
7900 <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>
7901 </div>
7902 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7903 <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>
7904 <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>
7905 </button>
7906 </div>
7907 </div>
7908 </div>
7909
7910 <div class="page">
7911 {% if total_scans > 0 %}
7912 <div class="summary-strip">
7913 <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>
7914 <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>
7915 <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>
7916 <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>
7917 </div>
7918 {% endif %}
7919 <section class="panel">
7920 <div class="panel-header">
7921 <div>
7922 <h1>Compare Scans</h1>
7923 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
7924 </div>
7925 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
7926 <button class="btn primary" id="compare-btn" onclick="doCompare()" disabled>
7927 <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>
7928 Compare <span class="sel-count" id="sel-count">0/2</span>
7929 </button>
7930 <a class="btn-back" href="/">
7931 <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>
7932 Home
7933 </a>
7934 </div>
7935 </div>
7936
7937 {% if entries.is_empty() %}
7938 <div class="empty-state">
7939 <strong>No scans yet</strong>
7940 Run your first analysis from the <a href="/scan">scan page</a>.
7941 </div>
7942 {% else %}
7943 <div style="display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;">
7944 <div class="instruction-bar" style="margin-bottom:0;flex-shrink:0;">
7945 <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>
7946 Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
7947 </div>
7948 <div style="margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
7949 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…" oninput="applyFilters()">
7950 <select class="filter-select" id="branch-filter" onchange="applyFilters()"><option value="">All branches</option></select>
7951 <button type="button" class="btn" onclick="resetView()">↻ Reset view</button>
7952 </div>
7953 </div>
7954 <div class="table-wrap">
7955 <table id="compare-table">
7956 <colgroup>
7957 <col style="width:44px">
7958 <col style="width:165px">
7959 <col style="width:180px">
7960 <col style="width:110px">
7961 <col style="width:100px">
7962 <col style="width:80px">
7963 <col style="width:100px">
7964 <col style="width:90px">
7965 <col style="width:100px">
7966 </colgroup>
7967 <thead>
7968 <tr id="compare-thead">
7969 <th style="text-align:center;padding-left:8px;padding-right:8px;"><div class="col-resize-handle"></div></th>
7970 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7971 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7972 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
7973 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7974 <th class="sortable" data-sort-col="code" data-sort-type="num">Code<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7975 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7976 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7977 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7978 </tr>
7979 </thead>
7980 <tbody id="compare-tbody">
7981 {% for entry in entries %}
7982 <tr class="compare-row" data-run="{{ entry.run_id }}"
7983 data-timestamp="{{ entry.timestamp }}"
7984 data-project="{{ entry.project_label }}"
7985 data-files="{{ entry.files_analyzed }}"
7986 data-code="{{ entry.code_lines }}"
7987 data-comments="{{ entry.comment_lines }}"
7988 data-branch="{{ entry.git_branch }}"
7989 data-commit="{{ entry.git_commit }}"
7990 onclick="toggleRow(this, '{{ entry.run_id }}')">
7991 <td style="text-align:center;padding-left:8px;padding-right:8px;"><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
7992 <td>{{ entry.timestamp }}</td>
7993 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
7994 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
7995 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
7996 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
7997 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
7998 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
7999 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
8000 </tr>
8001 {% endfor %}
8002 </tbody>
8003 </table>
8004 </div>
8005 <div class="pagination">
8006 <span class="pagination-info" id="pagination-info"></span>
8007 <div class="pagination-btns" id="pagination-btns"></div>
8008 <div style="display:flex;align-items:center;gap:8px;">
8009 <span class="per-page-label">Show</span>
8010 <select class="per-page" id="per-page-sel" onchange="setPerPage(this.value)">
8011 <option value="10">10 per page</option>
8012 <option value="25" selected>25 per page</option>
8013 <option value="50">50 per page</option>
8014 <option value="100">100 per page</option>
8015 </select>
8016 <span class="per-page-label" id="page-range-label"></span>
8017 </div>
8018 </div>
8019 {% endif %}
8020 </section>
8021 </div>
8022
8023 <footer class="site-footer">
8024 oxide-sloc — local source line analysis workbench ·
8025 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8026 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8027 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
8028 </footer>
8029
8030 <script>
8031 (function () {
8032 // ── Theme ──────────────────────────────────────────────────────────────
8033 var storageKey = 'oxide-sloc-theme';
8034 var body = document.body;
8035 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
8036 var toggle = document.getElementById('theme-toggle');
8037 if (toggle) toggle.addEventListener('click', function () {
8038 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
8039 body.classList.toggle('dark-theme', next === 'dark');
8040 try { localStorage.setItem(storageKey, next); } catch(e) {}
8041 });
8042
8043 // ── State ─────────────────────────────────────────────────────────────
8044 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
8045 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
8046 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
8047
8048 // ── Stat chips ────────────────────────────────────────────────────────
8049 (function() {
8050 var projects = {}, latestTs = '', latestRow = null;
8051 allRows.forEach(function(r) {
8052 var p = r.dataset.project || ''; if (p) projects[p] = true;
8053 var ts = r.dataset.timestamp || '';
8054 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
8055 });
8056 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
8057 if (latestRow) {
8058 var ce = document.getElementById('agg-code'); if (ce) ce.textContent = Number(latestRow.dataset.code).toLocaleString();
8059 var fe = document.getElementById('agg-files'); if (fe) fe.textContent = latestRow.dataset.files;
8060 }
8061 })();
8062
8063 // ── Branch filter population ──────────────────────────────────────────
8064 (function() {
8065 var branches = {};
8066 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
8067 var sel = document.getElementById('branch-filter');
8068 if (sel) Object.keys(branches).sort().forEach(function(b) {
8069 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
8070 });
8071 })();
8072
8073 // ── Filter ────────────────────────────────────────────────────────────
8074 function getFilteredRows() {
8075 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
8076 var branch = ((document.getElementById('branch-filter') || {}).value || '');
8077 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
8078 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
8079 if (branch && (r.dataset.branch || '') !== branch) return false;
8080 return true;
8081 });
8082 }
8083
8084 // ── Pagination ────────────────────────────────────────────────────────
8085 function renderPage() {
8086 var filtered = getFilteredRows();
8087 var total = filtered.length;
8088 var totalPages = Math.max(1, Math.ceil(total / perPage));
8089 currentPage = Math.min(currentPage, totalPages);
8090 var start = (currentPage - 1) * perPage;
8091 var end = Math.min(start + perPage, total);
8092 var shown = {};
8093 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
8094 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
8095 r.style.display = shown[r.dataset.run] ? '' : 'none';
8096 });
8097 var rl = document.getElementById('page-range-label');
8098 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
8099 var info = document.getElementById('pagination-info');
8100 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
8101 var btns = document.getElementById('pagination-btns');
8102 if (!btns) return;
8103 btns.innerHTML = '';
8104 function makeBtn(lbl, pg, active, disabled) {
8105 var b = document.createElement('button');
8106 b.className = 'pg-btn' + (active ? ' active' : '');
8107 b.textContent = lbl; b.disabled = disabled;
8108 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
8109 return b;
8110 }
8111 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
8112 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
8113 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
8114 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
8115 }
8116
8117 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
8118 window.applyFilters = function() { currentPage = 1; renderPage(); };
8119
8120 // ── Sorting ───────────────────────────────────────────────────────────
8121 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
8122 function doSort(col, type, order) {
8123 var tbody = document.getElementById('compare-tbody');
8124 if (!tbody) return;
8125 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
8126 rows.sort(function(a, b) {
8127 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
8128 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
8129 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
8130 return va < vb ? 1 : va > vb ? -1 : 0;
8131 });
8132 rows.forEach(function(r) { tbody.appendChild(r); });
8133 currentPage = 1; renderPage();
8134 }
8135 sortHeaders.forEach(function(th) {
8136 th.addEventListener('click', function(e) {
8137 if (e.target.classList.contains('col-resize-handle')) return;
8138 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
8139 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
8140 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8141 th.classList.add('sort-' + sortOrder);
8142 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
8143 doSort(col, type, sortOrder);
8144 });
8145 });
8146
8147 // ── Column resize ─────────────────────────────────────────────────────
8148 (function() {
8149 var table = document.getElementById('compare-table');
8150 if (!table) return;
8151 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
8152 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
8153 ths.forEach(function(th, i) {
8154 var handle = th.querySelector('.col-resize-handle');
8155 if (!handle || !cols[i]) return;
8156 var startX, startW;
8157 handle.addEventListener('mousedown', function(e) {
8158 e.stopPropagation(); e.preventDefault();
8159 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
8160 handle.classList.add('dragging');
8161 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
8162 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
8163 document.addEventListener('mousemove', onMove);
8164 document.addEventListener('mouseup', onUp);
8165 });
8166 });
8167 })();
8168
8169 // ── Reset view ────────────────────────────────────────────────────────
8170 window.resetView = function() {
8171 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
8172 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
8173 sortCol = null; sortOrder = 'asc';
8174 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8175 var tbody = document.getElementById('compare-tbody');
8176 if (tbody) {
8177 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
8178 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
8179 rows.forEach(function(r) { tbody.appendChild(r); });
8180 }
8181 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
8182 var table = document.getElementById('compare-table');
8183 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
8184 currentPage = 1; renderPage();
8185 };
8186
8187 renderPage();
8188
8189 (function randomizeWatermarks() {
8190 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8191 if (!wms.length) return;
8192 var placed = [];
8193 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;}
8194 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];}
8195 var half=Math.floor(wms.length/2);
8196 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+';';});
8197 })();
8198
8199 (function spawnCodeParticles() {
8200 var container = document.getElementById('code-particles');
8201 if (!container) return;
8202 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'];
8203 for (var i = 0; i < 38; i++) {
8204 (function(idx) {
8205 var el = document.createElement('span');
8206 el.className = 'code-particle';
8207 el.textContent = snippets[idx % snippets.length];
8208 var left = Math.random() * 94 + 2;
8209 var top = Math.random() * 88 + 6;
8210 var dur = (Math.random() * 10 + 9).toFixed(1);
8211 var delay = (Math.random() * 18).toFixed(1);
8212 var rot = (Math.random() * 26 - 13).toFixed(1);
8213 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
8214 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
8215 container.appendChild(el);
8216 })(i);
8217 }
8218 })();
8219 })();
8220
8221 var selected = [];
8222 function updateCompareBtn() {
8223 var btn = document.getElementById('compare-btn');
8224 var cnt = document.getElementById('sel-count');
8225 if (!btn) return;
8226 btn.disabled = selected.length !== 2;
8227 if (cnt) cnt.textContent = selected.length + '/2';
8228 }
8229
8230 function toggleRow(row, runId) {
8231 var idx = selected.indexOf(runId);
8232 if (idx >= 0) {
8233 selected.splice(idx, 1);
8234 row.classList.remove('selected');
8235 var b = document.getElementById('badge-' + runId);
8236 if (b) b.textContent = '';
8237 } else {
8238 if (selected.length >= 2) return;
8239 selected.push(runId);
8240 row.classList.add('selected');
8241 var b = document.getElementById('badge-' + runId);
8242 if (b) b.textContent = selected.length;
8243 }
8244 selected.forEach(function(id, i) {
8245 var b = document.getElementById('badge-' + id);
8246 if (b) b.textContent = i + 1;
8247 });
8248 updateCompareBtn();
8249 }
8250
8251 function doCompare() {
8252 if (selected.length !== 2) return;
8253 window.location.href = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
8254 }
8255 </script>
8256</body>
8257</html>
8258"##,
8259 ext = "html"
8260)]
8261struct CompareSelectTemplate {
8262 entries: Vec<HistoryEntryRow>,
8263 total_scans: usize,
8264}
8265
8266#[derive(Template)]
8269#[template(
8270 source = r##"
8271<!doctype html>
8272<html lang="en">
8273<head>
8274 <meta charset="utf-8">
8275 <meta name="viewport" content="width=device-width, initial-scale=1">
8276 <title>OxideSLOC | Scan Delta</title>
8277 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8278 <style>
8279 :root {
8280 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
8281 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
8282 --nav:#b85d33; --nav-2:#7a371b;
8283 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
8284 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fdeaea; --zero-bg:transparent;
8285 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
8286 }
8287 body.dark-theme {
8288 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
8289 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#f5a3a3; --neg-bg:#3d1c1c;
8290 }
8291 *{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);}
8292 .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);}
8293 .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;}
8294 .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));}
8295 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
8296 .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;}
8297 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
8298 .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;}
8299 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
8300 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
8301 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
8302 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
8303 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
8304 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
8305 .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;}
8306 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
8307 .hero-body{display:block;}
8308 .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;}
8309 .btn-back:hover{background:var(--line);}
8310 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;}
8311 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
8312 .muted{color:var(--muted);font-size:14px;}
8313 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
8314 .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;}
8315 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
8316 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
8317 .vpill-arrow{font-size:20px;color:var(--muted);}
8318 .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%;}
8319 .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;}
8320 .delta-card.delta-card-wide{padding:14px 18px;}
8321 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);}
8322 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
8323 .delta-card-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:4px;}
8324 .delta-card-from{font-size:12px;color:var(--muted);}
8325 .delta-card-to{font-size:20px;font-weight:800;margin:2px 0;}
8326 .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;}
8327 .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);}
8328 .delta-card:hover .dc-tip{display:block;}
8329 .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;}
8330 .export-btn:hover{background:var(--line);}
8331 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
8332 .delta-card-change{font-size:13px;font-weight:700;border-radius:6px;padding:1px 7px;display:inline-block;margin-top:2px;}
8333 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
8334 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
8335 .delta-card-change.zero{color:var(--muted);background:transparent;}
8336 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
8337 .fc-row{display:flex;align-items:center;gap:8px;}
8338 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
8339 .fc-label{color:var(--muted);}
8340 .fc-modified .fc-count{color:#926000;}
8341 .fc-added .fc-count{color:var(--pos);}
8342 .fc-removed .fc-count{color:var(--neg);}
8343 .fc-unchanged .fc-count{color:var(--muted);}
8344 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
8345 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
8346 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
8347 .chip.modified{background:#fff2d8;color:#926000;}
8348 .chip.added{background:#e8f5ed;color:#1a8f47;}
8349 .chip.removed{background:#fdeaea;color:#b33b3b;}
8350 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
8351 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
8352 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
8353 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
8354 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
8355 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
8356 .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;}
8357 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
8358 .tab-btn:hover:not(.active){background:var(--line);}
8359 .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;}
8360 .btn-reset:hover{background:var(--line);}
8361 .table-wrap{width:100%;overflow-x:auto;}
8362 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
8363 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;}
8364 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
8365 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
8366 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
8367 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
8368 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
8369 td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
8370 tr:last-child td{border-bottom:none;}
8371 tr.row-added td{background:rgba(26,143,71,0.06);}
8372 tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
8373 tr.row-modified td{background:rgba(146,96,0,0.05);}
8374 tr.row-unchanged td{opacity:.6;}
8375 .file-path{font-family:ui-monospace,monospace;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
8376 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
8377 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
8378 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
8379 .status-badge.modified{background:#fff2d8;color:#926000;}
8380 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
8381 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
8382 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
8383 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
8384 .delta-val{font-weight:700;}
8385 .delta-val.pos{color:var(--pos);}
8386 .delta-val.neg{color:var(--neg);}
8387 .delta-val.zero{color:var(--muted);}
8388 .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
8389 .from-to strong{color:var(--text);}
8390 .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
8391 .site-footer a{color:var(--muted);}
8392 @media(max-width:1400px){.delta-strip{grid-template-columns:repeat(3,1fr);}}
8393 @media(max-width:900px){.delta-strip{grid-template-columns:repeat(2,1fr);}}
8394 @media(max-width:600px){.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
8395 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
8396 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
8397 .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;}
8398 .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;}
8399 .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;}
8400 @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));}}
8401 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
8402 .path-link:hover{color:var(--oxide-2);}
8403 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
8404 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
8405 a.vpill-id:hover{color:var(--oxide);}
8406 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
8407 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
8408 .pagination-info{font-size:13px;color:var(--muted);}
8409 .pagination-btns{display:flex;gap:6px;}
8410 .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;}
8411 .pg-btn:hover:not(:disabled){background:var(--line);}
8412 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
8413 .pg-btn:disabled{opacity:.35;cursor:default;}
8414 .per-page-label{font-size:13px;color:var(--muted);}
8415 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;}
8416 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
8417 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
8418 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
8419 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
8420 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
8421 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
8422 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
8423 .tab-btn.tab-unchanged{color:var(--muted);}
8424 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
8425 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
8426 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
8427 </style>
8428</head>
8429<body>
8430 <div class="background-watermarks" aria-hidden="true">
8431 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
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 </div>
8438 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8439 <div class="top-nav">
8440 <div class="top-nav-inner">
8441 <a class="brand" href="/">
8442 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
8443 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
8444 </a>
8445 <div class="nav-right">
8446 <a class="nav-pill" href="/">Home</a>
8447 <a class="nav-pill" href="/view-reports">View Reports</a>
8448 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
8449 <div class="server-status-wrap">
8450 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
8451 <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>
8452 </div>
8453 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
8454 <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>
8455 <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>
8456 </button>
8457 </div>
8458 </div>
8459 </div>
8460
8461 <div class="page">
8462 <section class="hero">
8463 <div class="hero-header">
8464 <div>
8465 <h1 style="margin:0 0 6px;">Scan Delta</h1>
8466 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
8467 <span class="muted" style="font-size:13px;">Comparing two scans of</span>
8468 <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>
8469 </div>
8470 <div style="display:flex;align-items:center;gap:8px;margin-top:10px;flex-wrap:wrap;">
8471 <span style="font-size:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:4px 10px;color:var(--muted);">
8472 <span style="color:var(--text);font-weight:700;">Baseline</span> {{ baseline_timestamp }}
8473 </span>
8474 <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>
8475 <span style="font-size:12px;background:var(--surface-2);border:1px solid var(--oxide);border-radius:8px;padding:4px 10px;color:var(--muted);">
8476 <span style="color:var(--oxide);font-weight:700;">Current</span> {{ current_timestamp }}
8477 </span>
8478 </div>
8479 </div>
8480 <a class="btn-back" href="/compare-scans">
8481 <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>
8482 Compare Scans
8483 </a>
8484 </div>
8485 <div class="hero-body">
8486 <div class="delta-strip">
8487 <div class="delta-card delta-card-meta">
8488 <div class="delta-card-label">Baseline</div>
8489 <div class="delta-card-to" style="font-size:15px;line-height:1.2;">{{ baseline_timestamp }}</div>
8490 <a class="vpill-id" href="/runs/{{ baseline_run_id }}/html" target="_blank">{{ baseline_run_id_short }}</a>
8491 {% if !baseline_git_branch.is_empty() %}<span class="vpill-meta">Branch: {{ baseline_git_branch }}</span>{% endif %}
8492 {% if let Some(author) = baseline_git_author %}<span class="vpill-meta">Last commit by: {{ author }}</span>{% endif %}
8493 {% if let Some(tags) = baseline_git_tags %}<span class="vpill-meta">Tags: {{ tags }}</span>{% endif %}
8494 </div>
8495 <div class="delta-card delta-card-meta">
8496 <div class="delta-card-label">Current</div>
8497 <div class="delta-card-to" style="font-size:15px;line-height:1.2;">{{ current_timestamp }}</div>
8498 <a class="vpill-id" href="/runs/{{ current_run_id }}/html" target="_blank">{{ current_run_id_short }}</a>
8499 {% if !current_git_branch.is_empty() %}<span class="vpill-meta">Branch: {{ current_git_branch }}</span>{% endif %}
8500 {% if let Some(author) = current_git_author %}<span class="vpill-meta">Last commit by: {{ author }}</span>{% endif %}
8501 {% if let Some(tags) = current_git_tags %}<span class="vpill-meta">Tags: {{ tags }}</span>{% endif %}
8502 </div>
8503 <div class="delta-card">
8504 <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
8505 <div class="delta-card-label">Code lines</div>
8506 <div class="delta-card-from">Before: {{ baseline_code }}</div>
8507 <div class="delta-card-to">{{ current_code }}</div>
8508 {% if code_lines_delta_class == "pos" %}<span class="delta-card-change pos">{{ code_lines_delta_str }}</span>
8509 {% else if code_lines_delta_class == "neg" %}<span class="delta-card-change neg">{{ code_lines_delta_str }}</span>
8510 {% endif %}
8511 </div>
8512 <div class="delta-card">
8513 <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
8514 <div class="delta-card-label">Files analyzed</div>
8515 <div class="delta-card-from">Before: {{ baseline_files }}</div>
8516 <div class="delta-card-to">{{ current_files }}</div>
8517 {% if files_analyzed_delta_class == "pos" %}<span class="delta-card-change pos">{{ files_analyzed_delta_str }}</span>
8518 {% else if files_analyzed_delta_class == "neg" %}<span class="delta-card-change neg">{{ files_analyzed_delta_str }}</span>
8519 {% endif %}
8520 </div>
8521 <div class="delta-card">
8522 <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
8523 <div class="delta-card-label">Comment lines</div>
8524 <div class="delta-card-from">Before: {{ baseline_comments }}</div>
8525 <div class="delta-card-to">{{ current_comments }}</div>
8526 {% if comment_lines_delta_class == "pos" %}<span class="delta-card-change pos">{{ comment_lines_delta_str }}</span>
8527 {% else if comment_lines_delta_class == "neg" %}<span class="delta-card-change neg">{{ comment_lines_delta_str }}</span>
8528 {% endif %}
8529 </div>
8530 <div class="delta-card delta-card-wide">
8531 <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>
8532 <div class="delta-card-label">File changes</div>
8533 <div class="file-changes-grid">
8534 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
8535 <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
8536 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
8537 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
8538 </div>
8539 </div>
8540 </div>
8541 </div>
8542 </section>
8543
8544 <section class="panel">
8545 <h2>File-level delta</h2>
8546 <div class="filter-tabs-row">
8547 <div class="filter-tabs">
8548 <button class="tab-btn tab-all active" onclick="filterRows('all', this)">All</button>
8549 <button class="tab-btn tab-modified" onclick="filterRows('modified', this)">Modified ({{ files_modified }})</button>
8550 <button class="tab-btn tab-added" onclick="filterRows('added', this)">Added ({{ files_added }})</button>
8551 <button class="tab-btn tab-removed" onclick="filterRows('removed', this)">Removed ({{ files_removed }})</button>
8552 <button class="tab-btn tab-unchanged" onclick="filterRows('unchanged', this)">Unchanged ({{ files_unchanged }})</button>
8553 </div>
8554 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
8555 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
8556 <div class="export-group">
8557 <button type="button" class="btn-reset" onclick="resetDeltaTable()">↻ Reset</button>
8558 <button type="button" class="export-btn" onclick="exportDeltaCsv()">
8559 <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>
8560 CSV
8561 </button>
8562 <button type="button" class="export-btn" onclick="exportDeltaXls()">
8563 <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>
8564 Excel
8565 </button>
8566 <button type="button" class="export-btn" onclick="exportDeltaCharts()">
8567 <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>
8568 Charts
8569 </button>
8570 </div>
8571 </div>
8572 </div>
8573
8574 <div class="table-wrap">
8575 <table id="delta-table">
8576 <colgroup>
8577 <col style="width:34%">
8578 <col style="width:10%">
8579 <col style="width:9%">
8580 <col style="width:15%">
8581 <col style="width:8%">
8582 <col style="width:8%">
8583 <col style="width:8%">
8584 </colgroup>
8585 <thead>
8586 <tr id="delta-thead">
8587 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
8588 <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>
8589 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
8590 <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>
8591 <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>
8592 <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>
8593 <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>
8594 </tr>
8595 </thead>
8596 <tbody id="delta-tbody">
8597 {% for row in file_rows %}
8598 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
8599 data-path="{{ row.relative_path }}"
8600 data-language="{{ row.language }}"
8601 data-baseline-code="{{ row.baseline_code }}"
8602 data-current-code="{{ row.current_code }}"
8603 data-code-delta="{{ row.code_delta_str }}"
8604 data-comment-delta="{{ row.comment_delta_str }}"
8605 data-total-delta="{{ row.total_delta_str }}"
8606 data-orig-idx="">
8607 <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
8608 <td class="hide-sm">{{ row.language }}</td>
8609 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
8610 <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
8611 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
8612 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
8613 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
8614 </tr>
8615 {% endfor %}
8616 </tbody>
8617 </table>
8618 </div>
8619 <div class="pagination">
8620 <span class="pagination-info" id="pg-info"></span>
8621 <div class="pagination-btns" id="pg-btns"></div>
8622 <div style="display:flex;align-items:center;gap:8px;">
8623 <span class="per-page-label">Show</span>
8624 <select class="per-page" id="per-page-sel" onchange="setDeltaPerPage(this.value)">
8625 <option value="10">10 per page</option>
8626 <option value="25" selected>25 per page</option>
8627 <option value="50">50 per page</option>
8628 <option value="100">100 per page</option>
8629 </select>
8630 <span class="per-page-label" id="pg-range-label"></span>
8631 </div>
8632 </div>
8633 </section>
8634 </div>
8635
8636 <footer class="site-footer">
8637 oxide-sloc — local source line analysis workbench ·
8638 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8639 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8640 </footer>
8641
8642 <script>
8643 (function () {
8644 var storageKey = 'oxide-sloc-theme';
8645 var body = document.body;
8646 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
8647 var toggle = document.getElementById('theme-toggle');
8648 if (toggle) toggle.addEventListener('click', function () {
8649 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
8650 body.classList.toggle('dark-theme', next === 'dark');
8651 try { localStorage.setItem(storageKey, next); } catch(e) {}
8652 });
8653
8654 (function randomizeWatermarks() {
8655 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8656 if (!wms.length) return;
8657 var placed = [];
8658 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;}
8659 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];}
8660 var half=Math.floor(wms.length/2);
8661 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+';';});
8662 })();
8663
8664 (function spawnCodeParticles() {
8665 var container = document.getElementById('code-particles');
8666 if (!container) return;
8667 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'];
8668 for (var i = 0; i < 38; i++) {
8669 (function(idx) {
8670 var el = document.createElement('span');
8671 el.className = 'code-particle';
8672 el.textContent = snippets[idx % snippets.length];
8673 var left = Math.random() * 94 + 2;
8674 var top = Math.random() * 88 + 6;
8675 var dur = (Math.random() * 10 + 9).toFixed(1);
8676 var delay = (Math.random() * 18).toFixed(1);
8677 var rot = (Math.random() * 26 - 13).toFixed(1);
8678 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
8679 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
8680 container.appendChild(el);
8681 })(i);
8682 }
8683 })();
8684 })();
8685
8686 var activeStatusFilter = 'all';
8687 var deltaPerPage = 25, deltaCurrPage = 1;
8688
8689 function openFolder(path) {
8690 fetch('/open-path?path=' + encodeURIComponent(path)).catch(function(){});
8691 }
8692
8693 function getDeltaFilteredRows() {
8694 return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
8695 return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
8696 });
8697 }
8698
8699 function renderDeltaPage() {
8700 var filtered = getDeltaFilteredRows();
8701 var total = filtered.length;
8702 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
8703 deltaCurrPage = Math.min(deltaCurrPage, totalPages);
8704 var start = (deltaCurrPage - 1) * deltaPerPage;
8705 var end = Math.min(start + deltaPerPage, total);
8706 var shownSet = {};
8707 filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
8708 Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
8709 r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
8710 });
8711 var rl = document.getElementById('pg-range-label');
8712 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
8713 var info = document.getElementById('pg-info');
8714 if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
8715 var btns = document.getElementById('pg-btns');
8716 if (!btns) return;
8717 btns.innerHTML = '';
8718 if (totalPages <= 1) return;
8719 function makeBtn(lbl, pg, active, disabled) {
8720 var b = document.createElement('button');
8721 b.className = 'pg-btn' + (active ? ' active' : '');
8722 b.textContent = lbl; b.disabled = disabled;
8723 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
8724 return b;
8725 }
8726 btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
8727 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
8728 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
8729 btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
8730 }
8731
8732 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
8733
8734 function filterRows(status, btn) {
8735 activeStatusFilter = status;
8736 deltaCurrPage = 1;
8737 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
8738 b.classList.remove('active');
8739 });
8740 if (btn) btn.classList.add('active');
8741 renderDeltaPage();
8742 }
8743
8744 // ── Sorting ──────────────────────────────────────────────────────────────
8745 var sortCol = null, sortOrder = 'asc';
8746 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
8747 (function() {
8748 var tbody = document.getElementById('delta-tbody');
8749 if (!tbody) return;
8750 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
8751 rows.forEach(function(r, i) { r.dataset.origIdx = i; });
8752 })();
8753
8754 function parseDeltaNum(str) {
8755 if (!str || str === '—') return 0;
8756 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
8757 }
8758
8759 sortHeaders.forEach(function(th) {
8760 th.addEventListener('click', function(e) {
8761 if (e.target.classList.contains('col-resize-handle')) return;
8762 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
8763 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
8764 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8765 th.classList.add('sort-' + sortOrder);
8766 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
8767 var tbody = document.getElementById('delta-tbody');
8768 if (!tbody) return;
8769 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
8770 rows.sort(function(a, b) {
8771 var va, vb;
8772 if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
8773 else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
8774 else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
8775 else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
8776 else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
8777 else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
8778 else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
8779 else { va = ''; vb = ''; }
8780 if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
8781 return va < vb ? 1 : va > vb ? -1 : 0;
8782 });
8783 rows.forEach(function(r) { tbody.appendChild(r); });
8784 deltaCurrPage = 1;
8785 renderDeltaPage();
8786 var activeBtn = document.querySelector('.tab-btn.active');
8787 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
8788 if (activeBtn) activeBtn.classList.add('active');
8789 });
8790 });
8791
8792 // ── Column resize ─────────────────────────────────────────────────────────
8793 (function() {
8794 var table = document.getElementById('delta-table');
8795 if (!table) return;
8796 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
8797 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
8798 ths.forEach(function(th, i) {
8799 var handle = th.querySelector('.col-resize-handle');
8800 if (!handle || !cols[i]) return;
8801 var startX, startW;
8802 handle.addEventListener('mousedown', function(e) {
8803 e.stopPropagation(); e.preventDefault();
8804 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
8805 handle.classList.add('dragging');
8806 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
8807 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
8808 document.addEventListener('mousemove', onMove);
8809 document.addEventListener('mouseup', onUp);
8810 });
8811 });
8812 })();
8813
8814 // ── Reset ─────────────────────────────────────────────────────────────────
8815 window.resetDeltaTable = function() {
8816 sortCol = null; sortOrder = 'asc';
8817 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8818 var tbody = document.getElementById('delta-tbody');
8819 if (tbody) {
8820 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
8821 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
8822 rows.forEach(function(r) { tbody.appendChild(r); });
8823 }
8824 var table = document.getElementById('delta-table');
8825 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
8826 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
8827 activeStatusFilter = 'all';
8828 deltaCurrPage = 1;
8829 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
8830 var allBtn = document.querySelector('.tab-btn');
8831 if (allBtn) allBtn.classList.add('active');
8832 renderDeltaPage();
8833 };
8834
8835 renderDeltaPage();
8836
8837 // ── Export helpers ────────────────────────────────────────────────────────
8838 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
8839 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
8840 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);}
8841 function slocMakeXlsx(fname,sd,dr){
8842 var enc=new TextEncoder();
8843 // CRC-32 table
8844 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;}
8845 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;}
8846 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
8847 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
8848 // Shared string table
8849 var ss=[],si={};
8850 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
8851 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
8852 // Worksheet builder — each WS() call gets its own row counter R
8853 function WS(){
8854 var R=0,buf=[];
8855 function cl(c){return String.fromCharCode(65+c);}
8856 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
8857 '<v>'+S(v)+'</v></c>';}
8858 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
8859 (st?' s="'+st+'"':'')+'>'+
8860 '<v>'+(+v)+'</v></c>';}
8861 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
8862 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
8863 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
8864 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
8865 '<sheetFormatPr defaultRowHeight="15"/>'+
8866 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
8867 return{sc:sc,nc:nc,row:row,xml:xml};
8868 }
8869 // Language breakdown
8870 var lm={};
8871 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;});
8872 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
8873 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
8874 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
8875 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
8876 // Summary sheet
8877 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
8878 r1(s1(0,'OxideSLOC — Scan Delta Report',1));
8879 r1(s1(0,proj,2));
8880 r1(s1(0,sd.bts+' → '+sd.cts,2));
8881 r1('');
8882 r1(s1(0,'Metric',3)+s1(1,'Baseline',3)+s1(2,'Current',3)+s1(3,'Delta',3));
8883 r1(s1(0,'Code Lines')+n1(1,sd.bc,4)+n1(2,sd.cc,4)+s1(3,sd.cd,dstyle(sd.cd)));
8884 r1(s1(0,'Files Analyzed')+n1(1,sd.bf,4)+n1(2,sd.cf,4)+s1(3,sd.fd,dstyle(sd.fd)));
8885 r1(s1(0,'Comment Lines')+n1(1,sd.bcm,4)+n1(2,sd.ccm,4)+s1(3,sd.cmd,dstyle(sd.cmd)));
8886 r1('');
8887 r1(s1(0,'FILE CHANGES',8));
8888 r1(s1(0,'Category',3)+s1(3,'Count',3));
8889 r1(s1(0,'Modified')+n1(3,sd.fm,4));
8890 r1(s1(0,'Added')+n1(3,sd.fa,4));
8891 r1(s1(0,'Removed')+n1(3,sd.fr,4));
8892 r1(s1(0,'Unchanged')+n1(3,sd.fu,4));
8893 if(langs.length){
8894 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
8895 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
8896 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)));});
8897 }
8898 r1('');r1(s1(0,'SCAN METADATA',8));
8899 r1(s1(1,'Baseline')+s1(2,'Current'));
8900 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
8901 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
8902 var sh1=W1.xml('<col min="1" max="1" width="24" customWidth="1"/><col min="2" max="4" width="14" customWidth="1"/>');
8903 // File Delta sheet
8904 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
8905 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));
8906 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])));});
8907 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="8" width="13" customWidth="1"/>');
8908 // Shared strings XML
8909 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
8910 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
8911 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
8912 // XLSX file map
8913 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
8914 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>',
8915 '_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>',
8916 '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>',
8917 '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>',
8918 '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>',
8919 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
8920 // ZIP packer — STORED (no compression), compatible with all XLSX readers
8921 var zparts=[],zcds=[],zoff=0,znf=0;
8922 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
8923 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
8924 ].forEach(function(name){
8925 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
8926 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]);
8927 var entry=new Uint8Array(lha.length+nb.length+sz);
8928 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
8929 zparts.push(entry);
8930 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));
8931 var cde=new Uint8Array(cda.length+nb.length);
8932 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
8933 zcds.push(cde);zoff+=entry.length;znf++;
8934 });
8935 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
8936 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]);
8937 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
8938 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
8939 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
8940 zout.set(new Uint8Array(ea),zpos);
8941 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
8942 var xurl=URL.createObjectURL(xblob);
8943 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
8944 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
8945 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
8946 }
8947 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;');}
8948 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;}
8949
8950 var _summaryHdrs = ['Metric','Baseline','Current','Delta'];
8951 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 }}'};
8952 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)]];}
8953 var _dh = ['File','Language','Status','Code Before','Code After','Code Delta','Comment Delta','Total Delta'];
8954 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;}
8955 window.exportDeltaCsv = function(){slocCsvMulti(getExportFilename('csv'),[{hdrs:_summaryHdrs,rows:getSummaryExportRows()},{hdrs:_dh,rows:getDeltaExportRows()}]);};
8956 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
8957
8958 // ── Chart HTML report ─────────────────────────────────────────────────────
8959 function slocChartReport(fname, sd, dr) {
8960 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
8961 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
8962 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
8963 function fmt(n){return Number(n).toLocaleString();}
8964 function px(n){return Math.round(n);}
8965 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
8966 // Language map
8967 var lm={};
8968 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;});
8969 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
8970
8971 // Builds onmouse* attrs for interactive tooltip on each SVG element
8972 function barTT(label,val){
8973 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
8974 }
8975
8976 // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
8977 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}];
8978 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
8979 var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
8980 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
8981 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
8982 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"/>';}
8983 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
8984 c1mets.forEach(function(m,i){
8985 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
8986 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
8987 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>';
8988 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))+'/>';
8989 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>';
8990 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))+'/>';
8991 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>';
8992 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>';
8993 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>';
8994 });
8995 c1+='</svg>';
8996
8997 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
8998 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}];
8999 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
9000 var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
9001 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
9002 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9003 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9004 mets.forEach(function(m,i){
9005 var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
9006 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
9007 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
9008 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>';
9009 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
9010 if(bw>=52){
9011 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>';
9012 }else{
9013 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
9014 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>';
9015 }
9016 });
9017 c2+='</svg>';
9018
9019 // ── Chart 3: Language Code Delta ─────────────────────────────────────
9020 var c3='';
9021 if(langs.length){
9022 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
9023 var C3W=550,c3LW=124,c3FW=52;
9024 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
9025 var L3rH=30,C3H=langs.length*L3rH+20;
9026 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9027 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9028 langs.forEach(function(l,i){
9029 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
9030 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
9031 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
9032 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
9033 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':''))+'/>';
9034 if(bw>=48){
9035 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>';
9036 }else{
9037 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
9038 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>';
9039 }
9040 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>';
9041 });
9042 c3+='</svg>';
9043 }
9044
9045 // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
9046 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;});
9047 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
9048 var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
9049 var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9050 var ang=-Math.PI/2;
9051 segs.forEach(function(s){
9052 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
9053 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
9054 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
9055 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
9056 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
9057 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)+'%')+'/>';
9058 ang+=sw;
9059 });
9060 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>';
9061 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
9062 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>';});
9063 c4+='</svg>';
9064
9065 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
9066 var ttJs='var tt=document.getElementById("ox-tt");'+
9067 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
9068 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
9069 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
9070 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
9071 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
9072 'function oxHT(){tt.style.display="none";}';
9073
9074 // body max-width keeps charts from inflating beyond design dimensions on
9075 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
9076 // each chart's height blows up proportionally, breaking the one-page layout.
9077 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;}'+
9078 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
9079 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
9080 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
9081 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
9082 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
9083 'svg{display:block;}'+
9084 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
9085 '#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;}'+
9086 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
9087 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
9088 '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
9089 '<div id="ox-tt"><\/div>'+
9090 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
9091 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
9092 '<div class="two-col">'+
9093 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
9094 '<div class="leg"><span><span class="dot" style="background:#AAAAAA"><\/span>Baseline<\/span>'+
9095 '<span><span class="dot" style="background:#C45C10"><\/span>Current<\/span><\/div>'+c1+'<\/div>'+
9096 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
9097 '<\/div>'+
9098 '<div class="two-col">'+
9099 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
9100 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
9101 '<\/div>'+
9102 '<script>'+ttJs+'<\/script>'+
9103 '<\/body><\/html>';
9104 slocDownload(html, fname, 'text/html;charset=utf-8;');
9105 }
9106 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
9107 </script>
9108</body>
9109</html>
9110"##,
9111 ext = "html"
9112)]
9113struct CompareTemplate {
9114 baseline_run_id: String,
9115 current_run_id: String,
9116 baseline_run_id_short: String,
9117 current_run_id_short: String,
9118 baseline_timestamp: String,
9119 current_timestamp: String,
9120 project_path: String,
9121 baseline_code: u64,
9122 current_code: u64,
9123 code_lines_delta_str: String,
9124 code_lines_delta_class: String,
9125 baseline_files: u64,
9126 current_files: u64,
9127 files_analyzed_delta_str: String,
9128 files_analyzed_delta_class: String,
9129 baseline_comments: u64,
9130 current_comments: u64,
9131 comment_lines_delta_str: String,
9132 comment_lines_delta_class: String,
9133 files_added: usize,
9134 files_removed: usize,
9135 files_modified: usize,
9136 files_unchanged: usize,
9137 file_rows: Vec<CompareFileDeltaRow>,
9138 baseline_git_author: Option<String>,
9139 current_git_author: Option<String>,
9140 baseline_git_branch: String,
9141 current_git_branch: String,
9142 baseline_git_tags: Option<String>,
9143 current_git_tags: Option<String>,
9144}