1#![allow(clippy::multiple_crate_versions)]
4
5pub(crate) mod git_browser;
6pub(crate) mod git_webhook;
7
8use std::{
9 collections::{HashMap, VecDeque},
10 fmt::Write,
11 fs,
12 net::{IpAddr, SocketAddr},
13 path::{Path, PathBuf},
14 process::Stdio,
15 sync::Arc,
16 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
17};
18
19use anyhow::{Context, Result};
20use askama::Template;
21use axum::{
22 body::Body,
23 extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
24 http::{header, HeaderValue, Request, StatusCode},
25 middleware::{self, Next},
26 response::{Html, IntoResponse, Response},
27 routing::{get, post},
28 Json, Router,
29};
30use serde::{Deserialize, Serialize};
31use tokio::sync::Mutex;
32use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
33
34use sloc_config::{AppConfig, BinaryFileBehavior, MixedLinePolicy};
35use sloc_git::ScheduleStore;
36
37#[derive(Clone)]
38struct CspNonce(String);
39
40static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
41
42use sloc_core::{
43 analyze, compute_delta, read_json, AnalysisRun, FileChangeStatus, RegistryEntry, ScanRegistry,
44 ScanSummarySnapshot, SummaryTotals,
45};
46use sloc_report::{render_html, render_sub_report_html, write_pdf_from_html};
47const MAX_CONCURRENT_ANALYSES: usize = 4;
48
49struct IpRateLimiter {
52 window: Duration,
53 max_requests: usize,
54 state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
55 auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
56}
57
58impl IpRateLimiter {
59 fn new(window: Duration, max_requests: usize) -> Self {
60 Self {
61 window,
62 max_requests,
63 state: std::sync::Mutex::new(HashMap::new()),
64 auth_failures: std::sync::Mutex::new(HashMap::new()),
65 }
66 }
67
68 #[allow(clippy::significant_drop_tightening)]
71 fn is_allowed(&self, ip: IpAddr) -> bool {
72 let now = Instant::now();
73 let cutoff = now.checked_sub(self.window).unwrap_or(now);
74 let mut state = self
75 .state
76 .lock()
77 .unwrap_or_else(std::sync::PoisonError::into_inner);
78 if state.len() > 10_000 {
79 state.retain(|_, bucket| {
80 while bucket.front().is_some_and(|t| *t <= cutoff) {
81 bucket.pop_front();
82 }
83 !bucket.is_empty()
84 });
85 }
86 let bucket = state.entry(ip).or_default();
87 while bucket.front().is_some_and(|t| *t <= cutoff) {
88 bucket.pop_front();
89 }
90 if bucket.len() >= self.max_requests {
91 false
92 } else {
93 bucket.push_back(now);
94 true
95 }
96 }
97
98 fn record_auth_failure(&self, ip: IpAddr) {
99 let now = Instant::now();
100 let mut map = self
101 .auth_failures
102 .lock()
103 .unwrap_or_else(std::sync::PoisonError::into_inner);
104 map.entry(ip)
105 .and_modify(|e| {
106 e.0 += 1;
107 e.1 = now;
108 })
109 .or_insert_with(|| (1, now));
110 }
111
112 fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
113 const LOCKOUT_THRESHOLD: u32 = 10;
114 const LOCKOUT_WINDOW: Duration = Duration::from_hours(1);
115 let mut map = self
116 .auth_failures
117 .lock()
118 .unwrap_or_else(std::sync::PoisonError::into_inner);
119 let expired = map.get(&ip).is_some_and(|e| e.1.elapsed() > LOCKOUT_WINDOW);
120 if expired {
121 map.remove(&ip);
122 return false;
123 }
124 map.get(&ip).is_some_and(|e| e.0 >= LOCKOUT_THRESHOLD)
125 }
126}
127
128#[derive(Clone)]
129struct AppState {
130 base_config: AppConfig,
131 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
132 registry: Arc<Mutex<ScanRegistry>>,
133 registry_path: PathBuf,
134 analyze_semaphore: Arc<tokio::sync::Semaphore>,
135 server_mode: bool,
136 tls_enabled: bool,
137 api_keys: Vec<secrecy::Secret<String>>,
138 rate_limiter: Arc<IpRateLimiter>,
139 trust_proxy: bool,
140 git_clones_dir: PathBuf,
142 schedules: Arc<Mutex<ScheduleStore>>,
144 schedules_path: PathBuf,
145}
146
147type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
148
149#[derive(Clone, Debug)]
150struct RunArtifacts {
151 output_dir: PathBuf,
152 html_path: Option<PathBuf>,
153 pdf_path: Option<PathBuf>,
154 json_path: Option<PathBuf>,
155 report_title: String,
156}
157
158#[allow(clippy::too_many_lines)]
169pub async fn serve(config: AppConfig) -> Result<()> {
170 let bind_address = config.web.bind_address.clone();
171 let server_mode = config.web.server_mode;
172 let output_root = resolve_output_root(None);
173 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
175 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
176 let mut registry = ScanRegistry::load(®istry_path);
177 registry.prune_stale();
178 let _ = registry.save(®istry_path);
179
180 let api_keys: Vec<secrecy::Secret<String>> = std::env::var("SLOC_API_KEYS")
181 .or_else(|_| std::env::var("SLOC_API_KEY"))
182 .unwrap_or_default()
183 .split(',')
184 .map(str::trim)
185 .filter(|s| !s.is_empty())
186 .map(|s| secrecy::Secret::new(s.to_owned()))
187 .collect();
188 if server_mode && api_keys.is_empty() {
189 println!(
190 "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
191 unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
192 );
193 }
194
195 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
196 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
197 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
198 if server_mode && !tls_enabled {
199 println!(
200 "WARNING: TLS is not configured. Traffic is cleartext. \
201 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
202 or terminate TLS at a reverse proxy (nginx, caddy)."
203 );
204 }
205 if server_mode {
206 println!(
207 "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
208 to restrict cross-origin access (comma-separated)."
209 );
210 }
211 let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
212 if trust_proxy {
213 println!(
214 "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For header is trusted for rate limiting. \
215 Only set this when oxide-sloc is behind a trusted reverse proxy."
216 );
217 }
218
219 let rate_limiter = Arc::new(IpRateLimiter::new(Duration::from_mins(1), 60));
221
222 let git_clones_dir = resolve_git_clones_dir(&output_root);
223 let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
224 .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
225 let schedules = ScheduleStore::load(&schedules_path);
226
227 let state = AppState {
228 base_config: config,
229 artifacts: Arc::new(Mutex::new(HashMap::new())),
230 registry: Arc::new(Mutex::new(registry)),
231 registry_path,
232 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
233 server_mode,
234 tls_enabled,
235 api_keys,
236 rate_limiter,
237 trust_proxy,
238 git_clones_dir,
239 schedules: Arc::new(Mutex::new(schedules)),
240 schedules_path,
241 };
242
243 restart_poll_schedules(&state).await;
244
245 let protected = Router::new()
246 .route("/", get(splash))
247 .route("/scan-setup", get(scan_setup_handler))
248 .route("/scan", get(index))
249 .route("/analyze", post(analyze_handler))
250 .route("/preview", get(preview_handler))
251 .route("/pick-directory", get(pick_directory_handler))
252 .route("/open-path", get(open_path_handler))
253 .route("/pick-file", get(pick_file_handler))
254 .route("/locate-report", post(locate_report_handler))
255 .route("/view-reports", get(history_handler))
256 .route("/compare-scans", get(compare_select_handler))
257 .route("/compare", get(compare_handler))
258 .route("/images/{folder}/{file}", get(image_handler))
259 .route("/runs/{run_id}/{artifact}", get(artifact_handler))
260 .route("/api/metrics/latest", get(api_metrics_latest_handler))
261 .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
262 .route("/api/project-history", get(project_history_handler))
263 .route("/embed/summary", get(embed_handler))
264 .route("/git-browser", get(git_browser::git_browser_handler))
266 .route("/api/git/refs", get(git_browser::api_list_refs))
267 .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
268 .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
269 .route("/webhook-setup", get(git_webhook::webhook_setup_handler))
271 .route("/api/schedules", get(git_webhook::api_list_schedules))
272 .route("/api/schedules", post(git_webhook::api_create_schedule))
273 .route(
274 "/api/schedules",
275 axum::routing::delete(git_webhook::api_delete_schedule),
276 )
277 .route_layer(middleware::from_fn_with_state(
278 state.clone(),
279 require_api_key,
280 ));
281
282 let app = protected
283 .route("/healthz", get(healthz))
284 .route("/badge/{metric}", get(badge_handler))
285 .route("/static/chart.js", get(chart_js_handler))
286 .route("/webhooks/github", post(git_webhook::handle_github_webhook))
288 .route("/webhooks/gitlab", post(git_webhook::handle_gitlab_webhook))
289 .route(
290 "/webhooks/bitbucket",
291 post(git_webhook::handle_bitbucket_webhook),
292 )
293 .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
294 .layer(middleware::from_fn_with_state(
295 state.clone(),
296 add_security_headers,
297 ))
298 .layer(build_cors_layer(state.server_mode))
299 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
300 .with_state(state.clone());
301
302 let preferred: SocketAddr = bind_address
307 .parse()
308 .with_context(|| format!("invalid bind address: {bind_address}"))?;
309 let (listener, addr) = {
310 let candidates = (0u16..=9).map(|offset| {
311 let mut a = preferred;
312 a.set_port(preferred.port().saturating_add(offset));
313 a
314 });
315 let mut found = None;
316 for candidate in candidates {
317 if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
318 found = Some((l, candidate));
319 break;
320 }
321 }
322 found.ok_or_else(|| {
323 anyhow::anyhow!(
324 "failed to bind local web UI on {} (tried ports {}-{}): all in use",
325 bind_address,
326 preferred.port(),
327 preferred.port().saturating_add(9)
328 )
329 })?
330 };
331 if addr != preferred {
332 eprintln!(
333 "NOTE: port {} is blocked by a system socket (Windows zombie); \
334 using {} instead.",
335 preferred.port(),
336 addr.port()
337 );
338 }
339
340 if tls_enabled {
341 let cert_path = tls_cert.expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
342 let key_path = tls_key.expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
343 let tls_config = build_tls_config(&cert_path, &key_path)
344 .context("failed to load TLS certificate/key")?;
345 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
346
347 let url = format!("https://{addr}/");
348 println!("OxideSLOC server running at {url} (TLS)");
349 println!("Use Ctrl+C to stop.");
350
351 return serve_tls(listener, app, acceptor, server_mode).await;
352 }
353
354 let url = format!("http://{addr}/");
355 log_startup_url(&url, server_mode);
356
357 axum::serve(
358 listener,
359 app.into_make_service_with_connect_info::<SocketAddr>(),
360 )
361 .with_graceful_shutdown(shutdown_signal(server_mode))
362 .await
363 .context("web server terminated unexpectedly")
364}
365
366fn log_startup_url(url: &str, server_mode: bool) {
368 if server_mode {
369 println!("OxideSLOC server running at {url}");
370 println!("Use Ctrl+C to stop.");
371 } else {
372 println!("OxideSLOC local web UI running at {url}");
373 println!("Press Ctrl+C to stop the server.");
374 let open_url = url.to_owned();
375 tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
376 }
377}
378
379fn open_browser_tab(url: &str) {
381 #[cfg(target_os = "windows")]
382 let _ = std::process::Command::new("cmd")
383 .args(["/c", "start", "", url])
384 .stdout(Stdio::null())
385 .stderr(Stdio::null())
386 .spawn();
387 #[cfg(target_os = "macos")]
388 let _ = std::process::Command::new("open")
389 .arg(url)
390 .stdout(Stdio::null())
391 .stderr(Stdio::null())
392 .spawn();
393 #[cfg(target_os = "linux")]
394 let _ = std::process::Command::new("xdg-open")
395 .arg(url)
396 .stdout(Stdio::null())
397 .stderr(Stdio::null())
398 .spawn();
399}
400
401async fn shutdown_signal(server_mode: bool) {
403 if tokio::signal::ctrl_c().await.is_ok() {
404 println!();
405 if server_mode {
406 println!("Shutting down OxideSLOC server...");
407 } else {
408 println!("Shutting down OxideSLOC local web UI...");
409 }
410 println!("Server stopped cleanly.");
411 }
412}
413
414fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
416 use rustls_pemfile::{certs, private_key};
417 use std::io::BufReader;
418
419 let cert_bytes =
420 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
421 let key_bytes =
422 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
423
424 let cert_chain: Vec<_> = certs(&mut BufReader::new(cert_bytes.as_slice()))
425 .collect::<std::result::Result<_, _>>()
426 .context("failed to parse TLS certificates")?;
427
428 let key = private_key(&mut BufReader::new(key_bytes.as_slice()))
429 .context("failed to parse TLS private key")?
430 .ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
431
432 rustls::ServerConfig::builder()
433 .with_no_client_auth()
434 .with_single_cert(cert_chain, key)
435 .context("failed to build TLS server config")
436}
437
438async fn serve_tls(
440 listener: tokio::net::TcpListener,
441 app: Router,
442 acceptor: tokio_rustls::TlsAcceptor,
443 server_mode: bool,
444) -> Result<()> {
445 use hyper_util::rt::{TokioExecutor, TokioIo};
446 use hyper_util::server::conn::auto::Builder as ConnBuilder;
447 use hyper_util::service::TowerToHyperService;
448 use tower::{Service, ServiceExt};
449
450 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
451
452 loop {
453 tokio::select! {
454 biased;
455 _ = tokio::signal::ctrl_c() => {
456 println!();
457 if server_mode {
458 println!("Shutting down OxideSLOC server...");
459 } else {
460 println!("Shutting down OxideSLOC local web UI...");
461 }
462 println!("Server stopped cleanly.");
463 return Ok(());
464 }
465 result = listener.accept() => {
466 let (tcp, peer_addr) = result.context("TLS accept failed")?;
467 let acceptor = acceptor.clone();
468 let mut factory = make_svc.clone();
469
470 tokio::spawn(async move {
471 let tls = match acceptor.accept(tcp).await {
472 Ok(s) => s,
473 Err(e) => {
474 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
475 return;
476 }
477 };
478 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
479 Ok(f) => match Service::call(f, peer_addr).await {
480 Ok(s) => s,
481 Err(_) => return,
482 },
483 Err(_) => return,
484 };
485 let io = TokioIo::new(tls);
486 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
487 .serve_connection(io, TowerToHyperService::new(svc))
488 .await
489 {
490 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
491 }
492 });
493 }
494 }
495 }
496}
497
498async fn require_api_key(
499 State(state): State<AppState>,
500 req: Request<Body>,
501 next: Next,
502) -> Response {
503 if !state.api_keys.is_empty() {
504 let keys = &state.api_keys;
505 let provided = req
506 .headers()
507 .get(header::AUTHORIZATION)
508 .and_then(|v| v.to_str().ok())
509 .and_then(|v| v.strip_prefix("Bearer "))
510 .or_else(|| req.headers().get("X-API-Key").and_then(|v| v.to_str().ok()));
511
512 let peer_ip = req
513 .extensions()
514 .get::<axum::extract::ConnectInfo<SocketAddr>>()
515 .map_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), |c| c.0.ip());
516
517 if state.rate_limiter.is_auth_locked_out(peer_ip) {
518 tracing::warn!(event = "auth_lockout", peer_addr = %peer_ip,
519 "Authentication locked out after repeated failures");
520 return (
521 StatusCode::TOO_MANY_REQUESTS,
522 [(header::RETRY_AFTER, "3600")],
523 "429 Too Many Requests — authentication temporarily locked\n",
524 )
525 .into_response();
526 }
527
528 if provided.is_some_and(|k| {
529 keys.iter().any(|expected| {
530 use secrecy::ExposeSecret;
531 ct_eq(k, expected.expose_secret())
532 })
533 }) {
534 return next.run(req).await;
535 }
536
537 state.rate_limiter.record_auth_failure(peer_ip);
538 let path = req.uri().path().to_owned();
539 tracing::warn!(event = "auth_failure", peer_addr = %peer_ip, path = %path,
540 "API key authentication failed");
541 return (
542 StatusCode::UNAUTHORIZED,
543 [(header::WWW_AUTHENTICATE, "Bearer realm=\"oxide-sloc\"")],
544 "401 Unauthorized\n",
545 )
546 .into_response();
547 }
548 next.run(req).await
549}
550
551fn ct_eq(a: &str, b: &str) -> bool {
552 use subtle::ConstantTimeEq;
553 a.as_bytes().ct_eq(b.as_bytes()).into()
554}
555
556fn build_cors_layer(server_mode: bool) -> CorsLayer {
557 if server_mode {
558 let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
559 .unwrap_or_default()
560 .split(',')
561 .filter(|s| !s.is_empty())
562 .filter_map(|s| s.trim().parse().ok())
563 .collect();
564 if allowed.is_empty() {
565 return CorsLayer::new();
566 }
567 CorsLayer::new()
568 .allow_origin(AllowOrigin::list(allowed))
569 .allow_methods(AllowMethods::list([
570 axum::http::Method::GET,
571 axum::http::Method::POST,
572 ]))
573 .allow_headers(AllowHeaders::list([
574 axum::http::header::AUTHORIZATION,
575 axum::http::header::CONTENT_TYPE,
576 ]))
577 } else {
578 CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
579 let s = origin.to_str().unwrap_or("");
580 s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
581 }))
582 }
583}
584
585async fn add_security_headers(
586 State(state): State<AppState>,
587 mut req: Request<Body>,
588 next: Next,
589) -> Response {
590 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
591 req.extensions_mut().insert(CspNonce(nonce.clone()));
592 let mut resp = next.run(req).await;
593 let h = resp.headers_mut();
594 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
595 h.insert(
596 "X-Content-Type-Options",
597 HeaderValue::from_static("nosniff"),
598 );
599 h.insert(
600 "Referrer-Policy",
601 HeaderValue::from_static("strict-origin-when-cross-origin"),
602 );
603 let csp = format!(
604 "default-src 'self'; \
605 style-src 'self' 'nonce-{nonce}'; \
606 img-src 'self' data: blob:; \
607 script-src 'self' 'nonce-{nonce}'; \
608 font-src 'self' data:; \
609 object-src 'none'; \
610 frame-ancestors 'none'"
611 );
612 h.insert(
613 "Content-Security-Policy",
614 HeaderValue::from_str(&csp).unwrap_or_else(|_| {
615 HeaderValue::from_static(
616 "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
617 )
618 }),
619 );
620 h.insert(
621 "X-Permitted-Cross-Domain-Policies",
622 HeaderValue::from_static("none"),
623 );
624 h.insert(
625 "Permissions-Policy",
626 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
627 );
628 h.insert(
629 "Cross-Origin-Opener-Policy",
630 HeaderValue::from_static("same-origin"),
631 );
632 h.insert(
633 "Cross-Origin-Resource-Policy",
634 HeaderValue::from_static("same-origin"),
635 );
636 if state.tls_enabled {
637 h.insert(
638 "Strict-Transport-Security",
639 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
640 );
641 }
642 resp
643}
644
645async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
646 let ip = req
647 .extensions()
648 .get::<axum::extract::ConnectInfo<SocketAddr>>()
649 .map(|c| c.0.ip())
650 .or_else(|| {
651 if state.trust_proxy {
652 req.headers()
653 .get("X-Forwarded-For")
654 .and_then(|v| v.to_str().ok())
655 .and_then(|s| s.split(',').next())
656 .and_then(|s| s.trim().parse::<IpAddr>().ok())
657 } else {
658 None
659 }
660 })
661 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
662
663 if !state.rate_limiter.is_allowed(ip) {
664 tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
665 path = %req.uri().path(), "Rate limit exceeded");
666 return (
667 StatusCode::TOO_MANY_REQUESTS,
668 [(header::RETRY_AFTER, "60")],
669 "429 Too Many Requests\n",
670 )
671 .into_response();
672 }
673 next.run(req).await
674}
675
676async fn splash(
677 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
678) -> impl IntoResponse {
679 let template = SplashTemplate { csp_nonce };
680 Html(
681 template
682 .render()
683 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
684 )
685}
686
687async fn index(
688 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
689 Query(query): Query<IndexQuery>,
690) -> impl IntoResponse {
691 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
692 let policy = query
693 .mixed_line_policy
694 .unwrap_or_else(|| "code_only".to_string());
695 let behavior = query
696 .binary_file_behavior
697 .unwrap_or_else(|| "skip".to_string());
698 let cfg = ScanConfig {
699 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
700 path: query.path.unwrap_or_default(),
701 include_globs: query.include_globs.unwrap_or_default(),
702 exclude_globs: query.exclude_globs.unwrap_or_default(),
703 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
704 mixed_line_policy: policy,
705 python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
706 != Some("off"),
707 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
708 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
709 vendor_directory_detection: query.vendor_directory_detection.as_deref()
710 != Some("disabled"),
711 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
712 binary_file_behavior: behavior,
713 output_dir: query.output_dir.unwrap_or_default(),
714 report_title: query.report_title.unwrap_or_default(),
715 generate_html: query.generate_html.as_deref() != Some("off"),
716 generate_pdf: query.generate_pdf.as_deref() == Some("on"),
717 };
718 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
719 } else {
720 "{}".to_string()
721 };
722
723 let template = IndexTemplate {
724 version: env!("CARGO_PKG_VERSION"),
725 prefill_json,
726 csp_nonce,
727 };
728
729 Html(
730 template
731 .render()
732 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
733 )
734}
735
736async fn scan_setup_handler(
737 State(state): State<AppState>,
738 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
739) -> impl IntoResponse {
740 let recent_scans_json = {
741 let arr: Vec<serde_json::Value> = {
742 let reg = state.registry.lock().await;
743 reg.entries
744 .iter()
745 .rev()
746 .take(6)
747 .map(|e| {
748 let run_dir = e
749 .html_path
750 .as_ref()
751 .or(e.json_path.as_ref())
752 .and_then(|p| p.parent().map(PathBuf::from));
753 let config_val: Option<serde_json::Value> = run_dir
754 .map(|d| d.join("scan-config.json"))
755 .filter(|p| p.exists())
756 .and_then(|p| fs::read_to_string(&p).ok())
757 .and_then(|s| serde_json::from_str(&s).ok());
758 serde_json::json!({
759 "project_label": e.project_label,
760 "timestamp": fmt_pst(e.timestamp_utc),
761 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
762 "config": config_val,
763 })
764 })
765 .collect()
766 };
767 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
768 };
769
770 let template = ScanSetupTemplate {
771 recent_scans_json,
772 csp_nonce,
773 };
774 Html(
775 template
776 .render()
777 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
778 )
779}
780
781async fn healthz() -> &'static str {
782 "ok"
783}
784
785async fn chart_js_handler() -> impl IntoResponse {
786 (
787 [(
788 header::CONTENT_TYPE,
789 "application/javascript; charset=utf-8",
790 )],
791 CHART_JS,
792 )
793}
794
795#[derive(Debug, Deserialize)]
796struct AnalyzeForm {
797 path: String,
798 mixed_line_policy: Option<MixedLinePolicy>,
799 python_docstrings_as_comments: Option<String>,
800 generated_file_detection: Option<String>,
801 minified_file_detection: Option<String>,
802 vendor_directory_detection: Option<String>,
803 include_lockfiles: Option<String>,
804 binary_file_behavior: Option<BinaryFileBehavior>,
805 output_dir: Option<String>,
806 report_title: Option<String>,
807 generate_html: Option<String>,
808 generate_pdf: Option<String>,
809 include_globs: Option<String>,
810 exclude_globs: Option<String>,
811 submodule_breakdown: Option<String>,
812}
813
814#[allow(clippy::struct_excessive_bools)]
815#[derive(Debug, Serialize, Deserialize, Clone)]
816struct ScanConfig {
817 oxide_sloc_version: String,
818 path: String,
819 include_globs: String,
820 exclude_globs: String,
821 submodule_breakdown: bool,
822 mixed_line_policy: String,
823 python_docstrings_as_comments: bool,
824 generated_file_detection: bool,
825 minified_file_detection: bool,
826 vendor_directory_detection: bool,
827 include_lockfiles: bool,
828 binary_file_behavior: String,
829 output_dir: String,
830 report_title: String,
831 generate_html: bool,
832 generate_pdf: bool,
833}
834
835#[derive(Debug, Deserialize, Default)]
836struct IndexQuery {
837 path: Option<String>,
838 include_globs: Option<String>,
839 exclude_globs: Option<String>,
840 submodule_breakdown: Option<String>,
841 mixed_line_policy: Option<String>,
842 python_docstrings_as_comments: Option<String>,
843 generated_file_detection: Option<String>,
844 minified_file_detection: Option<String>,
845 vendor_directory_detection: Option<String>,
846 include_lockfiles: Option<String>,
847 binary_file_behavior: Option<String>,
848 output_dir: Option<String>,
849 report_title: Option<String>,
850 generate_html: Option<String>,
851 generate_pdf: Option<String>,
852 prefilled: Option<String>,
853}
854
855#[derive(Debug, Deserialize)]
856struct PreviewQuery {
857 path: Option<String>,
858 include_globs: Option<String>,
859 exclude_globs: Option<String>,
860}
861
862#[cfg(feature = "native-dialog")]
863#[derive(Debug, Deserialize)]
864struct PickDirectoryQuery {
865 kind: Option<String>,
866 current: Option<String>,
867}
868
869#[cfg(not(feature = "native-dialog"))]
870#[derive(Debug, Deserialize)]
871struct PickDirectoryQuery {}
872
873#[derive(Debug, Deserialize, Default)]
874struct ArtifactQuery {
875 download: Option<String>,
876}
877
878#[cfg(feature = "native-dialog")]
879#[derive(Debug, Serialize)]
880struct PickDirectoryResponse {
881 selected_path: Option<String>,
882 cancelled: bool,
883}
884
885#[cfg(feature = "native-dialog")]
886async fn pick_directory_handler(
887 State(state): State<AppState>,
888 Query(query): Query<PickDirectoryQuery>,
889) -> Response {
890 if state.server_mode {
891 return StatusCode::NOT_FOUND.into_response();
892 }
893
894 let title = match query.kind.as_deref() {
895 Some("output") => "Select output directory",
896 _ => "Select project directory",
897 };
898
899 let mut dialog = rfd::FileDialog::new().set_title(title);
900 if let Some(current) = query.current.as_deref() {
901 let resolved = resolve_input_path(current);
902 let seed = if resolved.is_dir() {
903 Some(resolved)
904 } else {
905 resolved.parent().map(Path::to_path_buf)
906 };
907 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
908 dialog = dialog.set_directory(seed_dir);
909 }
910 }
911
912 let picked = dialog.pick_folder();
913
914 Json(PickDirectoryResponse {
915 selected_path: picked.as_ref().map(|p| display_path(p)),
916 cancelled: picked.is_none(),
917 })
918 .into_response()
919}
920
921#[cfg(not(feature = "native-dialog"))]
922async fn pick_directory_handler(
923 State(_state): State<AppState>,
924 Query(_query): Query<PickDirectoryQuery>,
925) -> Response {
926 StatusCode::NOT_FOUND.into_response()
927}
928
929#[cfg(feature = "native-dialog")]
930async fn pick_file_handler(State(state): State<AppState>) -> Response {
931 if state.server_mode {
932 return StatusCode::NOT_FOUND.into_response();
933 }
934 let picked = rfd::FileDialog::new()
935 .set_title("Select HTML report")
936 .add_filter("HTML report", &["html"])
937 .pick_file();
938 Json(PickDirectoryResponse {
939 selected_path: picked.as_ref().map(|p| display_path(p)),
940 cancelled: picked.is_none(),
941 })
942 .into_response()
943}
944
945#[cfg(not(feature = "native-dialog"))]
946async fn pick_file_handler(State(_state): State<AppState>) -> Response {
947 StatusCode::NOT_FOUND.into_response()
948}
949
950#[derive(Deserialize)]
951struct LocateReportForm {
952 file_path: String,
953}
954
955fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
957 let html = ErrorTemplate {
958 message: message.into(),
959 last_report_url: Some("/view-reports".to_string()),
960 last_report_label: Some("View Reports".to_string()),
961 csp_nonce: csp_nonce.to_owned(),
962 }
963 .render()
964 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
965 Html(html).into_response()
966}
967
968fn registry_entry_from_run(
970 run: &AnalysisRun,
971 json_path: PathBuf,
972 html_path: PathBuf,
973) -> RegistryEntry {
974 let project_label = run.input_roots.first().map_or_else(
975 || "Unknown Project".to_string(),
976 |r| sanitize_project_label(r),
977 );
978 RegistryEntry {
979 run_id: run.tool.run_id.clone(),
980 timestamp_utc: run.tool.timestamp_utc,
981 project_label,
982 input_roots: run.input_roots.clone(),
983 json_path: Some(json_path),
984 html_path: Some(html_path),
985 pdf_path: None,
986 summary: ScanSummarySnapshot {
987 files_analyzed: run.summary_totals.files_analyzed,
988 files_skipped: run.summary_totals.files_skipped,
989 total_physical_lines: run.summary_totals.total_physical_lines,
990 code_lines: run.summary_totals.code_lines,
991 comment_lines: run.summary_totals.comment_lines,
992 blank_lines: run.summary_totals.blank_lines,
993 functions: run.summary_totals.functions,
994 classes: run.summary_totals.classes,
995 variables: run.summary_totals.variables,
996 imports: run.summary_totals.imports,
997 },
998 git_branch: None,
999 git_commit: None,
1000 git_author: None,
1001 git_tags: None,
1002 }
1003}
1004
1005#[allow(clippy::result_large_err)]
1010fn validate_locate_request(
1011 state: &AppState,
1012 file_path: &str,
1013 csp_nonce: &str,
1014) -> Result<(PathBuf, PathBuf), Response> {
1015 let file_ext = Path::new(file_path)
1016 .extension()
1017 .and_then(|e| e.to_str())
1018 .unwrap_or("")
1019 .to_ascii_lowercase();
1020 if file_ext != "html" {
1021 return Err(locate_report_error(
1022 "Only .html report files can be located via this form.",
1023 csp_nonce,
1024 ));
1025 }
1026 let html_path = match fs::canonicalize(PathBuf::from(file_path)) {
1027 Ok(p) => strip_unc_prefix(p),
1028 Err(_) => {
1029 return Err(locate_report_error(
1030 "Report file not found or path is invalid.",
1031 csp_nonce,
1032 ));
1033 }
1034 };
1035 if state.server_mode {
1036 let output_root = resolve_output_root(None);
1037 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
1038 if !html_path.starts_with(&canonical_root) {
1039 return Err(locate_report_error(
1040 "Report file must be within the configured output directory.",
1041 csp_nonce,
1042 ));
1043 }
1044 }
1045 let parent = match html_path.parent() {
1046 Some(p) => p.to_path_buf(),
1047 None => {
1048 return Err(locate_report_error(
1049 "Report file has no parent directory.",
1050 csp_nonce,
1051 ));
1052 }
1053 };
1054 Ok((html_path, parent))
1055}
1056
1057fn locate_path_hint(server_mode: bool, path: &Path) -> String {
1059 if server_mode {
1060 String::new()
1061 } else {
1062 format!("\n\nFile: {}", path.display())
1063 }
1064}
1065
1066async fn locate_report_handler(
1067 State(state): State<AppState>,
1068 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1069 Form(form): Form<LocateReportForm>,
1070) -> impl IntoResponse {
1071 let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
1072 Ok(v) => v,
1073 Err(resp) => return resp,
1074 };
1075
1076 let json_candidate = parent.join("result.json");
1077 let mut reg = state.registry.lock().await;
1078 let entry_idx = reg.entries.iter().position(|e| {
1080 let json_match = e
1081 .json_path
1082 .as_ref()
1083 .and_then(|p| p.parent())
1084 .is_some_and(|p| p == parent);
1085 let html_match = e
1086 .html_path
1087 .as_ref()
1088 .and_then(|p| p.parent())
1089 .is_some_and(|p| p == parent);
1090 json_match || html_match
1091 });
1092 if let Some(idx) = entry_idx {
1093 reg.entries[idx].html_path = Some(html_path);
1094 let _ = reg.save(&state.registry_path);
1095 return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1096 }
1097 if json_candidate.exists() {
1099 match read_json(&json_candidate) {
1100 Ok(run) => {
1101 let entry = registry_entry_from_run(&run, json_candidate, html_path);
1102 reg.add_entry(entry);
1103 let _ = reg.save(&state.registry_path);
1104 return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1105 }
1106 Err(e) => {
1107 let file_hint = locate_path_hint(state.server_mode, &json_candidate);
1108 let err_detail = if state.server_mode {
1109 String::new()
1110 } else {
1111 format!("\n\nError: {e}")
1112 };
1113 return locate_report_error(
1114 format!(
1115 "Could not link this report.\n\nA 'result.json' was found but could not \
1116 be parsed — it may have been saved by an older version of OxideSLOC. \
1117 Re-running the analysis will create a fresh, compatible \
1118 record.{file_hint}{err_detail}"
1119 ),
1120 &csp_nonce,
1121 );
1122 }
1123 }
1124 }
1125 drop(reg);
1126 let file_hint = locate_path_hint(state.server_mode, &html_path);
1127 locate_report_error(
1128 format!(
1129 "Could not link this report.\n\nNo matching scan record was found, and no \
1130 'result.json' was found in the same folder.{file_hint}"
1131 ),
1132 &csp_nonce,
1133 )
1134}
1135
1136#[derive(Debug, Deserialize)]
1137struct OpenPathQuery {
1138 path: Option<String>,
1139}
1140
1141async fn open_path_handler(
1142 State(state): State<AppState>,
1143 Query(query): Query<OpenPathQuery>,
1144) -> impl IntoResponse {
1145 if state.server_mode {
1146 return StatusCode::NOT_FOUND.into_response();
1147 }
1148 let raw = match query.path.as_deref() {
1149 Some(p) if !p.is_empty() => p,
1150 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
1151 };
1152
1153 let Ok(canonical) = fs::canonicalize(raw) else {
1154 return (StatusCode::BAD_REQUEST, "path not found").into_response();
1155 };
1156
1157 let target = if canonical.is_file() {
1159 match canonical.parent() {
1160 Some(p) => p.to_path_buf(),
1161 None => return (StatusCode::BAD_REQUEST, "path has no parent").into_response(),
1162 }
1163 } else if canonical.is_dir() {
1164 canonical
1165 } else {
1166 return (StatusCode::BAD_REQUEST, "path is not a file or directory").into_response();
1168 };
1169
1170 #[cfg(target_os = "windows")]
1171 let _ = std::process::Command::new("explorer.exe")
1172 .arg(&target)
1173 .stdout(Stdio::null())
1174 .stderr(Stdio::null())
1175 .spawn();
1176 #[cfg(target_os = "macos")]
1177 let _ = std::process::Command::new("open")
1178 .arg(&target)
1179 .stdout(Stdio::null())
1180 .stderr(Stdio::null())
1181 .spawn();
1182 #[cfg(target_os = "linux")]
1183 let _ = std::process::Command::new("xdg-open")
1184 .arg(&target)
1185 .stdout(Stdio::null())
1186 .stderr(Stdio::null())
1187 .spawn();
1188
1189 (StatusCode::OK, "ok").into_response()
1190}
1191
1192async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
1193 let safe_folder = match folder.as_str() {
1194 "icons" | "logo" => folder,
1195 _ => return StatusCode::NOT_FOUND.into_response(),
1196 };
1197
1198 let safe_name = Path::new(&file)
1199 .file_name()
1200 .and_then(|name| name.to_str())
1201 .unwrap_or("");
1202
1203 if safe_name.is_empty() {
1204 return StatusCode::NOT_FOUND.into_response();
1205 }
1206
1207 let ext = Path::new(safe_name)
1208 .extension()
1209 .and_then(|e| e.to_str())
1210 .unwrap_or("")
1211 .to_ascii_lowercase();
1212
1213 let content_type = match ext.as_str() {
1214 "png" => "image/png",
1215 "jpg" | "jpeg" => "image/jpeg",
1216 "webp" => "image/webp",
1217 "svg" => "image/svg+xml",
1218 _ => return StatusCode::NOT_FOUND.into_response(),
1219 };
1220
1221 let path = workspace_root()
1222 .join("docs")
1223 .join("assets")
1224 .join(safe_folder)
1225 .join(safe_name);
1226 fs::read(path).map_or_else(
1227 |_| StatusCode::NOT_FOUND.into_response(),
1228 |bytes| ([(header::CONTENT_TYPE, content_type)], bytes).into_response(),
1229 )
1230}
1231
1232async fn preview_handler(
1233 State(state): State<AppState>,
1234 Query(query): Query<PreviewQuery>,
1235) -> impl IntoResponse {
1236 let raw_path = query
1237 .path
1238 .unwrap_or_else(|| "tests/fixtures/basic".to_string());
1239 let resolved = resolve_input_path(&raw_path);
1240
1241 if state.server_mode {
1242 let config = &state.base_config;
1243 if config.discovery.allowed_scan_roots.is_empty() {
1244 return Html(
1245 r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
1246 );
1247 }
1248 let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
1249 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
1250 fs::canonicalize(root)
1251 .ok()
1252 .is_some_and(|r| canonical.starts_with(&r))
1253 });
1254 if !allowed {
1255 return Html(
1256 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
1257 );
1258 }
1259 }
1260
1261 let include_patterns = split_patterns(query.include_globs.as_deref());
1262 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
1263
1264 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
1265 Ok(html) => Html(html),
1266 Err(err) => Html(format!(
1267 r#"<div class="preview-error">Preview failed: {}</div>"#,
1268 escape_html(&err.to_string())
1269 )),
1270 }
1271}
1272
1273#[allow(clippy::result_large_err)]
1275fn validate_server_scan_path(
1276 config: &sloc_config::AppConfig,
1277 resolved_path: &Path,
1278 csp_nonce: &str,
1279) -> Result<(), Response> {
1280 if config.discovery.allowed_scan_roots.is_empty() {
1281 let template = ErrorTemplate {
1282 message: "Scan path rejected: no allowed_scan_roots configured on this server. \
1283 Set allowed_scan_roots in the server config to permit scanning."
1284 .to_string(),
1285 last_report_url: None,
1286 last_report_label: None,
1287 csp_nonce: csp_nonce.to_owned(),
1288 };
1289 return Err((
1290 StatusCode::FORBIDDEN,
1291 Html(
1292 template
1293 .render()
1294 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
1295 ),
1296 )
1297 .into_response());
1298 }
1299 let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
1300 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
1301 fs::canonicalize(root)
1302 .ok()
1303 .is_some_and(|r| canonical.starts_with(&r))
1304 });
1305 if !allowed {
1306 tracing::warn!(event = "path_rejected", path = %canonical.display(),
1307 "Scan path not in allowed_scan_roots");
1308 let template = ErrorTemplate {
1309 message: "The requested path is not within an allowed scan directory.".to_string(),
1310 last_report_url: None,
1311 last_report_label: None,
1312 csp_nonce: csp_nonce.to_owned(),
1313 };
1314 return Err((
1315 StatusCode::FORBIDDEN,
1316 Html(
1317 template
1318 .render()
1319 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
1320 ),
1321 )
1322 .into_response());
1323 }
1324 Ok(())
1325}
1326
1327fn apply_output_dir_exclusions(
1329 config: &mut sloc_config::AppConfig,
1330 project_path: &str,
1331 raw_output_dir: &str,
1332) {
1333 let project_root = resolve_input_path(project_path);
1334 let raw_out = raw_output_dir.trim();
1335 let resolved_out = if raw_out.is_empty() {
1336 project_root.join("sloc")
1337 } else if Path::new(raw_out).is_absolute() {
1338 PathBuf::from(raw_out)
1339 } else {
1340 workspace_root().join(raw_out)
1341 };
1342 if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
1343 if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
1344 let dir = first.to_string();
1345 if !config.discovery.excluded_directories.contains(&dir) {
1346 config.discovery.excluded_directories.push(dir);
1347 }
1348 }
1349 }
1350 if !config
1351 .discovery
1352 .excluded_directories
1353 .iter()
1354 .any(|d| d == "sloc")
1355 {
1356 config
1357 .discovery
1358 .excluded_directories
1359 .push("sloc".to_string());
1360 }
1361}
1362
1363const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
1365 ScanSummarySnapshot {
1366 files_analyzed: run.summary_totals.files_analyzed,
1367 files_skipped: run.summary_totals.files_skipped,
1368 total_physical_lines: run.summary_totals.total_physical_lines,
1369 code_lines: run.summary_totals.code_lines,
1370 comment_lines: run.summary_totals.comment_lines,
1371 blank_lines: run.summary_totals.blank_lines,
1372 functions: run.summary_totals.functions,
1373 classes: run.summary_totals.classes,
1374 variables: run.summary_totals.variables,
1375 imports: run.summary_totals.imports,
1376 }
1377}
1378
1379fn build_run_registry_entry(
1381 run: &AnalysisRun,
1382 run_id: &str,
1383 project_label: &str,
1384 artifacts: &RunArtifacts,
1385) -> RegistryEntry {
1386 RegistryEntry {
1387 run_id: run_id.to_owned(),
1388 timestamp_utc: run.tool.timestamp_utc,
1389 project_label: project_label.to_owned(),
1390 input_roots: run.input_roots.clone(),
1391 json_path: artifacts.json_path.clone(),
1392 html_path: artifacts.html_path.clone(),
1393 pdf_path: artifacts.pdf_path.clone(),
1394 summary: summary_snapshot_from_run(run),
1395 git_branch: run.git_branch.clone(),
1396 git_commit: run.git_commit_short.clone(),
1397 git_author: run.git_commit_author.clone(),
1398 git_tags: run.git_tags.clone(),
1399 }
1400}
1401
1402fn build_scan_config_from_form(form: &AnalyzeForm, run: &AnalysisRun) -> ScanConfig {
1404 let policy_str = serde_json::to_value(form.mixed_line_policy)
1405 .ok()
1406 .filter(|v| !v.is_null())
1407 .and_then(|v| v.as_str().map(String::from))
1408 .unwrap_or_else(|| "code_only".to_string());
1409 let behavior_str = serde_json::to_value(form.binary_file_behavior)
1410 .ok()
1411 .filter(|v| !v.is_null())
1412 .and_then(|v| v.as_str().map(String::from))
1413 .unwrap_or_else(|| "skip".to_string());
1414 ScanConfig {
1415 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1416 path: form.path.clone(),
1417 include_globs: form.include_globs.clone().unwrap_or_default(),
1418 exclude_globs: form.exclude_globs.clone().unwrap_or_default(),
1419 submodule_breakdown: form.submodule_breakdown.as_deref() == Some("enabled"),
1420 mixed_line_policy: policy_str,
1421 python_docstrings_as_comments: form.python_docstrings_as_comments.is_some(),
1422 generated_file_detection: form.generated_file_detection.as_deref() != Some("disabled"),
1423 minified_file_detection: form.minified_file_detection.as_deref() != Some("disabled"),
1424 vendor_directory_detection: form.vendor_directory_detection.as_deref() != Some("disabled"),
1425 include_lockfiles: form.include_lockfiles.as_deref() == Some("enabled"),
1426 binary_file_behavior: behavior_str,
1427 output_dir: form.output_dir.clone().unwrap_or_default(),
1428 report_title: run.effective_configuration.reporting.report_title.clone(),
1429 generate_html: form.generate_html.is_some(),
1430 generate_pdf: form.generate_pdf.is_some(),
1431 }
1432}
1433
1434fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
1436 if let Some(policy) = form.mixed_line_policy {
1437 config.analysis.mixed_line_policy = policy;
1438 }
1439 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
1440 config.analysis.generated_file_detection =
1441 form.generated_file_detection.as_deref() != Some("disabled");
1442 config.analysis.minified_file_detection =
1443 form.minified_file_detection.as_deref() != Some("disabled");
1444 config.analysis.vendor_directory_detection =
1445 form.vendor_directory_detection.as_deref() != Some("disabled");
1446 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
1447 if let Some(binary_behavior) = form.binary_file_behavior {
1448 config.analysis.binary_file_behavior = binary_behavior;
1449 }
1450 if let Some(report_title) = form.report_title.as_deref() {
1451 let trimmed = report_title.trim();
1452 if !trimmed.is_empty() {
1453 config.reporting.report_title = trimmed.to_string();
1454 }
1455 }
1456 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
1457 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
1458 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
1459}
1460
1461fn spawn_pdf_background(pending_pdf: PendingPdf) {
1463 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
1464 tokio::spawn(async move {
1465 let result = tokio::task::spawn_blocking(move || {
1466 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
1467 if cleanup_src {
1468 let _ = fs::remove_file(&pdf_src);
1469 }
1470 r
1471 })
1472 .await;
1473 match result {
1474 Ok(Err(err)) => eprintln!("[oxide-sloc][pdf] background PDF failed: {err}"),
1475 Err(err) => eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}"),
1476 Ok(Ok(())) => {}
1477 }
1478 });
1479 }
1480}
1481
1482fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
1484 cmp.file_deltas
1485 .iter()
1486 .map(|f| match f.status {
1487 FileChangeStatus::Added => f.current_code,
1488 FileChangeStatus::Modified => f.code_delta.max(0),
1489 _ => 0,
1490 })
1491 .sum()
1492}
1493
1494fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
1496 cmp.file_deltas
1497 .iter()
1498 .map(|f| match f.status {
1499 FileChangeStatus::Removed => f.baseline_code,
1500 FileChangeStatus::Modified => (-f.code_delta).max(0),
1501 _ => 0,
1502 })
1503 .sum()
1504}
1505
1506fn build_submodule_row(
1508 s: &sloc_core::SubmoduleSummary,
1509 run: &AnalysisRun,
1510 run_id: &str,
1511 run_dir: &Path,
1512 generate_html: bool,
1513) -> SubmoduleRow {
1514 let safe = sanitize_project_label(&s.name);
1515 let artifact_key = format!("sub_{safe}");
1516 let html_url = if run.effective_configuration.discovery.submodule_breakdown && generate_html {
1517 let parent_path = run
1518 .input_roots
1519 .first()
1520 .map_or("", std::string::String::as_str);
1521 let sub_run = build_sub_run(run, s, parent_path);
1522 render_sub_report_html(&sub_run).ok().and_then(|sub_html| {
1523 let path = run_dir.join(format!("{artifact_key}.html"));
1524 if fs::write(&path, sub_html.as_bytes()).is_ok() {
1525 Some(format!("/runs/{run_id}/{artifact_key}"))
1526 } else {
1527 None
1528 }
1529 })
1530 } else {
1531 None
1532 };
1533 SubmoduleRow {
1534 name: s.name.clone(),
1535 relative_path: s.relative_path.clone(),
1536 files_analyzed: s.files_analyzed,
1537 code_lines: s.code_lines,
1538 comment_lines: s.comment_lines,
1539 blank_lines: s.blank_lines,
1540 total_physical_lines: s.total_physical_lines,
1541 html_url,
1542 }
1543}
1544
1545#[allow(clippy::too_many_lines)]
1549#[allow(clippy::similar_names)]
1550async fn analyze_handler(
1551 State(state): State<AppState>,
1552 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1553 Form(form): Form<AnalyzeForm>,
1554) -> impl IntoResponse {
1555 let Ok(_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
1556 let template = ErrorTemplate {
1557 message: "Server is busy — too many concurrent analyses. Please try again in a moment."
1558 .to_string(),
1559 last_report_url: None,
1560 last_report_label: None,
1561 csp_nonce: csp_nonce.clone(),
1562 };
1563 return (
1564 StatusCode::SERVICE_UNAVAILABLE,
1565 Html(
1566 template
1567 .render()
1568 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
1569 ),
1570 )
1571 .into_response();
1572 };
1573
1574 let mut config = state.base_config.clone();
1575 let resolved_path = resolve_input_path(&form.path);
1576
1577 if state.server_mode {
1578 if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
1579 return resp;
1580 }
1581 }
1582 config.discovery.root_paths = vec![resolved_path];
1583
1584 apply_form_to_config(&mut config, &form);
1585 apply_output_dir_exclusions(
1587 &mut config,
1588 &form.path,
1589 form.output_dir.as_deref().unwrap_or(""),
1590 );
1591
1592 let analysis_result =
1593 tokio::task::spawn_blocking(move || -> Result<(sloc_core::AnalysisRun, String)> {
1594 let run = analyze(&config, "serve")?;
1595 let html = render_html(&run)?;
1596 Ok((run, html))
1597 })
1598 .await
1599 .map_err(|err| anyhow::anyhow!(err.to_string()))
1600 .and_then(|result| result);
1601
1602 let (run, report_html) = match analysis_result {
1603 Ok(value) => value,
1604 Err(err) => {
1605 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
1606 let template = ErrorTemplate {
1607 message: "Analysis failed. Check that the path exists and is readable.".to_string(),
1608 last_report_url: None,
1609 last_report_label: None,
1610 csp_nonce: csp_nonce.clone(),
1611 };
1612 return Html(
1613 template
1614 .render()
1615 .unwrap_or_else(|_| "<pre>Analysis failed.</pre>".to_string()),
1616 )
1617 .into_response();
1618 }
1619 };
1620
1621 let run_id = run.tool.run_id.clone();
1622 tracing::info!(event = "scan_complete", run_id = %run_id,
1623 path = %form.path, files = run.summary_totals.files_analyzed, "Analysis finished");
1624
1625 let prev_entry: Option<RegistryEntry> = {
1628 let reg = state.registry.lock().await;
1629 reg.entries_for_roots(&run.input_roots)
1630 .into_iter()
1631 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
1632 .cloned()
1633 };
1634
1635 let git_branch = run.git_branch.clone();
1637 let git_commit = run.git_commit_short.clone();
1638 let git_author = run.git_commit_author.clone();
1639
1640 let scan_delta = prev_entry.as_ref().and_then(|prev| {
1642 prev.json_path
1643 .as_ref()
1644 .and_then(|p| read_json(p).ok())
1645 .map(|prev_run| compute_delta(&prev_run, &run))
1646 });
1647 let prev_scan_count: usize = {
1648 let reg = state.registry.lock().await;
1649 reg.entries_for_roots(&run.input_roots)
1650 .iter()
1651 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
1652 .count()
1653 };
1654
1655 let output_root = resolve_output_root(form.output_dir.as_deref());
1656
1657 let project_label = sanitize_project_label(&form.path);
1658 let run_dir = output_root.join(format!("{project_label}_{run_id}"));
1659
1660 let artifact_result = persist_run_artifacts(
1661 &run,
1662 &report_html,
1663 &run_dir,
1664 true, form.generate_html.is_some(),
1666 form.generate_pdf.is_some(),
1667 &run.effective_configuration.reporting.report_title,
1668 );
1669
1670 let (artifacts, pending_pdf) = match artifact_result {
1671 Ok(value) => value,
1672 Err(err) => {
1673 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
1674 let template = ErrorTemplate {
1675 message: "Failed to save report artifacts. Check available disk space.".to_string(),
1676 last_report_url: None,
1677 last_report_label: None,
1678 csp_nonce: csp_nonce.clone(),
1679 };
1680 return Html(
1681 template
1682 .render()
1683 .unwrap_or_else(|_| "<pre>Artifact write failed.</pre>".to_string()),
1684 )
1685 .into_response();
1686 }
1687 };
1688
1689 {
1690 let mut map = state.artifacts.lock().await;
1691 map.insert(run_id.clone(), artifacts.clone());
1692 }
1693
1694 {
1696 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
1697 let mut reg = state.registry.lock().await;
1698 reg.add_entry(entry);
1699 let _ = reg.save(&state.registry_path);
1700 }
1701
1702 {
1704 let scan_cfg = build_scan_config_from_form(&form, &run);
1705 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
1706 let _ = fs::write(run_dir.join("scan-config.json"), json);
1707 }
1708 }
1709
1710 spawn_pdf_background(pending_pdf);
1711
1712 let language_rows = run
1713 .totals_by_language
1714 .iter()
1715 .map(|row| LanguageSummaryRow {
1716 language: row.language.display_name().to_string(),
1717 files: row.files,
1718 physical: row.total_physical_lines,
1719 code: row.code_lines,
1720 comments: row.comment_lines,
1721 blank: row.blank_lines,
1722 mixed: row.mixed_lines_separate,
1723 functions: row.functions,
1724 classes: row.classes,
1725 variables: row.variables,
1726 imports: row.imports,
1727 })
1728 .collect::<Vec<_>>();
1729
1730 let files_analyzed = run.per_file_records.len() as u64;
1731 let files_skipped = run.skipped_file_records.len() as u64;
1732 let physical_lines = language_rows.iter().map(|row| row.physical).sum::<u64>();
1733 let code_lines = language_rows.iter().map(|row| row.code).sum::<u64>();
1734 let comment_lines = language_rows.iter().map(|row| row.comments).sum::<u64>();
1735 let blank_lines = language_rows.iter().map(|row| row.blank).sum::<u64>();
1736 let mixed_lines = language_rows.iter().map(|row| row.mixed).sum::<u64>();
1737 let functions = language_rows.iter().map(|row| row.functions).sum::<u64>();
1738 let classes = language_rows.iter().map(|row| row.classes).sum::<u64>();
1739 let variables = language_rows.iter().map(|row| row.variables).sum::<u64>();
1740 let imports = language_rows.iter().map(|row| row.imports).sum::<u64>();
1741
1742 let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
1744 let prev_fa = prev_sum.map(|s| s.files_analyzed);
1745 let prev_fs = prev_sum.map(|s| s.files_skipped);
1746 let prev_pl = prev_sum.map(|s| s.total_physical_lines);
1747 let prev_cl = prev_sum.map(|s| s.code_lines);
1748 let prev_cml = prev_sum.map(|s| s.comment_lines);
1749 let prev_bl = prev_sum.map(|s| s.blank_lines);
1750 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
1751 let prev_fa_str = fmt_prev(prev_fa);
1752 let prev_fs_str = fmt_prev(prev_fs);
1753 let prev_pl_str = fmt_prev(prev_pl);
1754 let prev_cl_str = fmt_prev(prev_cl);
1755 let prev_cml_str = fmt_prev(prev_cml);
1756 let prev_bl_str = fmt_prev(prev_bl);
1757 let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
1758 let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
1759 let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
1760 let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
1761 let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
1762 let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
1763 let delta_fa_class = delta_fa_class.to_string();
1764 let delta_fs_class = delta_fs_class.to_string();
1765 let delta_pl_class = delta_pl_class.to_string();
1766 let delta_cl_class = delta_cl_class.to_string();
1767 let delta_cml_class = delta_cml_class.to_string();
1768 let delta_bl_class = delta_bl_class.to_string();
1769
1770 let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
1772 let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
1773 let (delta_lines_net_str, delta_lines_net_class) =
1774 match (delta_lines_added, delta_lines_removed) {
1775 (Some(a), Some(r)) => {
1776 let net = a - r;
1777 (fmt_delta(net), delta_class(net).to_string())
1778 }
1779 _ => ("—".to_string(), "na".to_string()),
1780 };
1781
1782 let template = ResultTemplate {
1783 report_title: run.effective_configuration.reporting.report_title.clone(),
1784 project_path: form.path,
1785 output_dir: display_path(&artifacts.output_dir),
1786 run_id: run_id.clone(),
1787 files_analyzed,
1788 files_skipped,
1789 physical_lines,
1790 code_lines,
1791 comment_lines,
1792 blank_lines,
1793 mixed_lines,
1794 functions,
1795 classes,
1796 variables,
1797 imports,
1798 html_url: artifacts
1799 .html_path
1800 .as_ref()
1801 .map(|_| format!("/runs/{run_id}/html")),
1802 pdf_url: artifacts
1803 .pdf_path
1804 .as_ref()
1805 .map(|_| format!("/runs/{run_id}/pdf")),
1806 json_url: artifacts
1807 .json_path
1808 .as_ref()
1809 .map(|_| format!("/runs/{run_id}/json")),
1810 html_download_url: artifacts
1811 .html_path
1812 .as_ref()
1813 .map(|_| format!("/runs/{run_id}/html?download=1")),
1814 pdf_download_url: artifacts
1815 .pdf_path
1816 .as_ref()
1817 .map(|_| format!("/runs/{run_id}/pdf?download=1")),
1818 json_download_url: artifacts
1819 .json_path
1820 .as_ref()
1821 .map(|_| format!("/runs/{run_id}/json?download=1")),
1822 html_path: artifacts.html_path.as_ref().map(|path| display_path(path)),
1823 pdf_path: artifacts.pdf_path.as_ref().map(|path| display_path(path)),
1824 json_path: artifacts.json_path.as_ref().map(|path| display_path(path)),
1825 language_rows,
1826 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
1827 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_pst(e.timestamp_utc)),
1828 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
1829 prev_fa_str,
1830 prev_fs_str,
1831 prev_pl_str,
1832 prev_cl_str,
1833 prev_cml_str,
1834 prev_bl_str,
1835 delta_fa_str,
1836 delta_fa_class,
1837 delta_fs_str,
1838 delta_fs_class,
1839 delta_pl_str,
1840 delta_pl_class,
1841 delta_cl_str,
1842 delta_cl_class,
1843 delta_cml_str,
1844 delta_cml_class,
1845 delta_bl_str,
1846 delta_bl_class,
1847 delta_lines_added,
1849 delta_lines_removed,
1850 delta_lines_net_str,
1851 delta_lines_net_class,
1852 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
1853 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
1854 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
1855 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
1856 delta_unmodified_lines: scan_delta.as_ref().map(|d| {
1857 d.file_deltas
1858 .iter()
1859 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
1860 .map(|f| {
1861 #[allow(clippy::cast_sign_loss)]
1862 let n = f.current_code as u64;
1863 n
1864 })
1865 .sum()
1866 }),
1867 git_branch: git_branch.clone(),
1868 git_commit: git_commit.clone(),
1869 git_author: git_author.clone(),
1870 current_scan_number: prev_scan_count + 1,
1871 prev_scan_count,
1872 submodule_rows: run
1873 .submodule_summaries
1874 .iter()
1875 .map(|s| build_submodule_row(s, &run, &run_id, &run_dir, form.generate_html.is_some()))
1876 .collect(),
1877 scan_config_url: format!("/runs/{run_id}/scan-config"),
1878 csp_nonce,
1879 };
1880
1881 Html(
1882 template
1883 .render()
1884 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1885 )
1886 .into_response()
1887}
1888
1889fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
1890 let slug: String = report_title
1891 .chars()
1892 .map(|c| {
1893 if c.is_alphanumeric() || c == '-' {
1894 c.to_ascii_lowercase()
1895 } else {
1896 '_'
1897 }
1898 })
1899 .collect::<String>()
1900 .split('_')
1901 .filter(|s| !s.is_empty())
1902 .collect::<Vec<_>>()
1903 .join("_");
1904
1905 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
1906
1907 if slug.is_empty() {
1908 format!("report_{short_id}.pdf")
1909 } else {
1910 format!("{slug}_{short_id}.pdf")
1911 }
1912}
1913
1914fn serve_html_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
1916 match fs::read_to_string(path) {
1917 Ok(content) => {
1918 if wants_download {
1919 (
1920 [
1921 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
1922 (
1923 header::CONTENT_DISPOSITION,
1924 "attachment; filename=report.html",
1925 ),
1926 ],
1927 content,
1928 )
1929 .into_response()
1930 } else {
1931 Html(content).into_response()
1932 }
1933 }
1934 Err(err) => {
1935 let filename = path.file_name().map_or_else(
1936 || "report.html".to_string(),
1937 |n| n.to_string_lossy().into_owned(),
1938 );
1939 let msg = format!(
1940 "HTML report '{filename}' could not be read.\n\n\
1941 Error: {err}\n\n\
1942 If you moved or renamed the output folder, the stored path is now stale. \
1943 Use 'Open HTML folder' from the results page to browse the output directory."
1944 );
1945 let html = ErrorTemplate {
1946 message: msg,
1947 last_report_url: Some("/view-reports".to_string()),
1948 last_report_label: Some("View Reports".to_string()),
1949 csp_nonce: csp_nonce.to_owned(),
1950 }
1951 .render()
1952 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
1953 (StatusCode::NOT_FOUND, Html(html)).into_response()
1954 }
1955 }
1956}
1957
1958fn serve_pdf_artifact(
1960 path: &Path,
1961 report_title: &str,
1962 run_id: &str,
1963 wants_download: bool,
1964 csp_nonce: &str,
1965) -> Response {
1966 match fs::read(path) {
1967 Ok(bytes) => {
1968 let filename = build_pdf_filename(report_title, run_id);
1969 let disposition = if wants_download {
1970 format!("attachment; filename=\"{filename}\"")
1971 } else {
1972 format!("inline; filename=\"{filename}\"")
1973 };
1974 (
1975 [
1976 (header::CONTENT_TYPE, "application/pdf".to_string()),
1977 (header::CONTENT_DISPOSITION, disposition),
1978 ],
1979 bytes,
1980 )
1981 .into_response()
1982 }
1983 Err(err) => {
1984 let filename = path.file_name().map_or_else(
1985 || "report.pdf".to_string(),
1986 |n| n.to_string_lossy().into_owned(),
1987 );
1988 let msg = format!(
1989 "PDF report '{filename}' could not be read.\n\n\
1990 Error: {err}\n\n\
1991 If you moved or renamed the output folder, the stored path is now stale. \
1992 Use 'Open PDF folder' from the results page to browse the output directory."
1993 );
1994 let html = ErrorTemplate {
1995 message: msg,
1996 last_report_url: Some("/view-reports".to_string()),
1997 last_report_label: Some("View Reports".to_string()),
1998 csp_nonce: csp_nonce.to_owned(),
1999 }
2000 .render()
2001 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
2002 (StatusCode::NOT_FOUND, Html(html)).into_response()
2003 }
2004 }
2005}
2006
2007fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
2009 match fs::read(path) {
2010 Ok(bytes) => {
2011 if wants_download {
2012 (
2013 [
2014 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
2015 (
2016 header::CONTENT_DISPOSITION,
2017 "attachment; filename=result.json",
2018 ),
2019 ],
2020 bytes,
2021 )
2022 .into_response()
2023 } else {
2024 (
2025 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
2026 bytes,
2027 )
2028 .into_response()
2029 }
2030 }
2031 Err(err) => {
2032 let filename = path.file_name().map_or_else(
2033 || "result.json".to_string(),
2034 |n| n.to_string_lossy().into_owned(),
2035 );
2036 let msg = format!(
2037 "JSON result '{filename}' could not be read.\n\n\
2038 Error: {err}\n\n\
2039 If you moved or renamed the output folder, the stored path is now stale. \
2040 Use 'Open JSON folder' from the results page to browse the output directory."
2041 );
2042 let html = ErrorTemplate {
2043 message: msg,
2044 last_report_url: Some("/view-reports".to_string()),
2045 last_report_label: Some("View Reports".to_string()),
2046 csp_nonce: csp_nonce.to_owned(),
2047 }
2048 .render()
2049 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
2050 (StatusCode::NOT_FOUND, Html(html)).into_response()
2051 }
2052 }
2053}
2054
2055fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
2057 let output_dir = entry
2058 .html_path
2059 .as_ref()
2060 .or(entry.json_path.as_ref())
2061 .or(entry.pdf_path.as_ref())
2062 .and_then(|p| p.parent().map(PathBuf::from))
2063 .unwrap_or_default();
2064 let pdf_path = entry.pdf_path.clone().or_else(|| {
2067 let candidate = output_dir.join("report.pdf");
2068 candidate.exists().then_some(candidate)
2069 });
2070 RunArtifacts {
2071 output_dir,
2072 html_path: entry.html_path.clone(),
2073 pdf_path,
2074 json_path: entry.json_path.clone(),
2075 report_title: entry.project_label.clone(),
2076 }
2077}
2078
2079#[allow(clippy::too_many_lines)]
2080async fn artifact_handler(
2081 State(state): State<AppState>,
2082 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2083 AxumPath((run_id, artifact)): AxumPath<(String, String)>,
2084 Query(query): Query<ArtifactQuery>,
2085) -> Response {
2086 let artifact_set = {
2087 let registry = state.artifacts.lock().await;
2088 registry.get(&run_id).cloned()
2089 };
2090
2091 let artifact_set = if let Some(a) = artifact_set {
2094 a
2095 } else {
2096 let reg = state.registry.lock().await;
2097 if let Some(entry) = reg.find_by_run_id(&run_id) {
2098 recover_artifacts_from_registry(entry)
2099 } else {
2100 let error_html = ErrorTemplate {
2101 message: format!(
2102 "Report not found. Run ID {} is not in the scan history. \
2103 The report may have been deleted, or this is an old run from \
2104 before the scan registry was introduced.",
2105 &run_id[..run_id.len().min(8)]
2106 ),
2107 last_report_url: Some("/view-reports".to_string()),
2108 last_report_label: Some("View Reports".to_string()),
2109 csp_nonce: csp_nonce.clone(),
2110 }
2111 .render()
2112 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
2113 return (StatusCode::NOT_FOUND, Html(error_html)).into_response();
2114 }
2115 };
2116
2117 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
2118
2119 match artifact.as_str() {
2120 "html" => {
2121 let Some(path) = artifact_set.html_path else {
2122 return StatusCode::NOT_FOUND.into_response();
2123 };
2124 serve_html_artifact(&path, wants_download, &csp_nonce)
2125 }
2126 "pdf" => {
2127 let Some(path) = artifact_set.pdf_path else {
2128 let msg = "PDF report was not generated for this run, or was not recorded in \
2129 the scan registry. Re-run the analysis with PDF output enabled."
2130 .to_string();
2131 let html = ErrorTemplate {
2132 message: msg,
2133 last_report_url: Some("/view-reports".to_string()),
2134 last_report_label: Some("View Reports".to_string()),
2135 csp_nonce: csp_nonce.clone(),
2136 }
2137 .render()
2138 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
2139 return (StatusCode::NOT_FOUND, Html(html)).into_response();
2140 };
2141 serve_pdf_artifact(
2142 &path,
2143 &artifact_set.report_title,
2144 &run_id,
2145 wants_download,
2146 &csp_nonce,
2147 )
2148 }
2149 "json" => {
2150 let Some(path) = artifact_set.json_path else {
2151 let msg = "JSON result was not generated for this run, or was not recorded in \
2152 the scan registry. Re-run the analysis with JSON output enabled."
2153 .to_string();
2154 let html = ErrorTemplate {
2155 message: msg,
2156 last_report_url: Some("/view-reports".to_string()),
2157 last_report_label: Some("View Reports".to_string()),
2158 csp_nonce: csp_nonce.clone(),
2159 }
2160 .render()
2161 .unwrap_or_else(|_| "<pre>JSON not available.</pre>".to_string());
2162 return (StatusCode::NOT_FOUND, Html(html)).into_response();
2163 };
2164 serve_json_artifact(&path, wants_download, &csp_nonce)
2165 }
2166 "scan-config" => {
2167 let path = artifact_set.output_dir.join("scan-config.json");
2168 fs::read(&path).map_or_else(
2169 |_| StatusCode::NOT_FOUND.into_response(),
2170 |bytes| {
2171 (
2172 [
2173 (
2174 header::CONTENT_TYPE,
2175 "application/json; charset=utf-8".to_string(),
2176 ),
2177 (
2178 header::CONTENT_DISPOSITION,
2179 "attachment; filename=\"scan-config.json\"".to_string(),
2180 ),
2181 ],
2182 bytes,
2183 )
2184 .into_response()
2185 },
2186 )
2187 }
2188 _ if artifact.starts_with("sub_") => {
2189 if artifact.len() > 128
2190 || !artifact
2191 .chars()
2192 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
2193 {
2194 return StatusCode::BAD_REQUEST.into_response();
2195 }
2196 let filename = format!("{artifact}.html");
2197 let path = artifact_set.output_dir.join(&filename);
2198 fs::read_to_string(&path).map_or_else(
2199 |_| {
2200 let html = ErrorTemplate {
2201 message: format!(
2202 "Sub-report '{artifact}' was not found in the run directory.\n\
2203 Re-run the analysis with 'Detect and separate git submodules' \
2204 and HTML output enabled."
2205 ),
2206 last_report_url: Some("/view-reports".to_string()),
2207 last_report_label: Some("View Reports".to_string()),
2208 csp_nonce: csp_nonce.clone(),
2209 }
2210 .render()
2211 .unwrap_or_else(|_| "<pre>Sub-report not found.</pre>".to_string());
2212 (StatusCode::NOT_FOUND, Html(html)).into_response()
2213 },
2214 |content| Html(content).into_response(),
2215 )
2216 }
2217 _ => StatusCode::NOT_FOUND.into_response(),
2218 }
2219}
2220
2221struct HistoryEntryRow {
2224 run_id: String,
2225 run_id_short: String,
2226 timestamp: String,
2227 project_label: String,
2228 project_path: String,
2229 files_analyzed: u64,
2230 files_skipped: u64,
2231 code_lines: u64,
2232 comment_lines: u64,
2233 blank_lines: u64,
2234 functions: u64,
2235 classes: u64,
2236 variables: u64,
2237 imports: u64,
2238 git_branch: String,
2239 git_commit: String,
2240 has_html: bool,
2241 has_json: bool,
2242 has_pdf: bool,
2243}
2244
2245fn fmt_pst(dt: chrono::DateTime<chrono::Utc>) -> String {
2246 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset is always valid"))
2247 .format("%Y-%m-%d %H:%M PST")
2248 .to_string()
2249}
2250
2251fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
2252 reg.entries
2253 .iter()
2254 .map(|e| HistoryEntryRow {
2255 run_id: e.run_id.clone(),
2256 run_id_short: e
2257 .run_id
2258 .split('-')
2259 .next_back()
2260 .unwrap_or(&e.run_id)
2261 .chars()
2262 .take(7)
2263 .collect(),
2264 timestamp: fmt_pst(e.timestamp_utc),
2265 project_label: e.project_label.clone(),
2266 project_path: e
2267 .input_roots
2268 .first()
2269 .map(|s| sanitize_path_str(s))
2270 .unwrap_or_default(),
2271 files_analyzed: e.summary.files_analyzed,
2272 files_skipped: e.summary.files_skipped,
2273 code_lines: e.summary.code_lines,
2274 comment_lines: e.summary.comment_lines,
2275 blank_lines: e.summary.blank_lines,
2276 functions: e.summary.functions,
2277 classes: e.summary.classes,
2278 variables: e.summary.variables,
2279 imports: e.summary.imports,
2280 git_branch: e.git_branch.clone().unwrap_or_default(),
2281 git_commit: e.git_commit.clone().unwrap_or_default(),
2282 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
2283 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
2284 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
2285 })
2286 .collect()
2287}
2288
2289#[derive(Deserialize, Default)]
2290struct HistoryQuery {
2291 linked: Option<String>,
2292}
2293
2294async fn history_handler(
2295 State(state): State<AppState>,
2296 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2297 Query(query): Query<HistoryQuery>,
2298) -> impl IntoResponse {
2299 let mut entries = {
2300 let reg = state.registry.lock().await;
2301 make_history_rows(®)
2302 };
2303 entries.retain(|e| e.has_html);
2304 let total_scans = entries.len();
2305 let linked = query.linked.as_deref() == Some("1");
2306 let template = HistoryTemplate {
2307 entries,
2308 total_scans,
2309 linked,
2310 csp_nonce,
2311 };
2312 Html(
2313 template
2314 .render()
2315 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2316 )
2317 .into_response()
2318}
2319
2320async fn compare_select_handler(
2321 State(state): State<AppState>,
2322 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2323) -> impl IntoResponse {
2324 let mut entries = {
2325 let reg = state.registry.lock().await;
2326 make_history_rows(®)
2327 };
2328 entries.retain(|e| e.has_json);
2329 let total_scans = entries.len();
2330 let template = CompareSelectTemplate {
2331 entries,
2332 total_scans,
2333 csp_nonce,
2334 };
2335 Html(
2336 template
2337 .render()
2338 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2339 )
2340 .into_response()
2341}
2342
2343#[derive(Deserialize, Default)]
2346struct CompareQuery {
2347 a: Option<String>,
2348 b: Option<String>,
2349}
2350
2351struct CompareFileDeltaRow {
2352 relative_path: String,
2353 language: String,
2354 status: String,
2355 baseline_code: i64,
2356 current_code: i64,
2357 code_delta_str: String,
2358 code_delta_class: String,
2359 comment_delta_str: String,
2360 comment_delta_class: String,
2361 total_delta_str: String,
2362 total_delta_class: String,
2363}
2364
2365fn fmt_delta(n: i64) -> String {
2366 if n > 0 {
2367 format!("+{n}")
2368 } else {
2369 format!("{n}")
2370 }
2371}
2372
2373fn delta_class(n: i64) -> &'static str {
2374 use std::cmp::Ordering;
2375 match n.cmp(&0) {
2376 Ordering::Greater => "pos",
2377 Ordering::Less => "neg",
2378 Ordering::Equal => "zero",
2379 }
2380}
2381
2382fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
2384 prev.map_or_else(
2385 || ("—".to_string(), "na"),
2386 |p| {
2387 #[allow(clippy::cast_possible_wrap)]
2388 let d = curr as i64 - p as i64;
2389 (fmt_delta(d), delta_class(d))
2390 },
2391 )
2392}
2393
2394#[allow(clippy::too_many_lines)]
2395async fn compare_handler(
2396 State(state): State<AppState>,
2397 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2398 Query(query): Query<CompareQuery>,
2399) -> impl IntoResponse {
2400 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
2403 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
2404 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
2405 };
2406
2407 let (maybe_a, maybe_b) = {
2408 let reg = state.registry.lock().await;
2409 (
2410 reg.find_by_run_id(&run_id_a).cloned(),
2411 reg.find_by_run_id(&run_id_b).cloned(),
2412 )
2413 };
2414
2415 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
2416 let html = ErrorTemplate {
2417 message: "One or both run IDs were not found in scan history. \
2418 The runs may have been deleted or the registry may have been reset."
2419 .to_string(),
2420 last_report_url: Some("/compare-scans".to_string()),
2421 last_report_label: Some("Compare Scans".to_string()),
2422 csp_nonce: csp_nonce.clone(),
2423 }
2424 .render()
2425 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
2426 return Html(html).into_response();
2427 };
2428
2429 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
2431 (entry_a, entry_b)
2432 } else {
2433 (entry_b, entry_a)
2434 };
2435
2436 if baseline_entry.run_id != run_id_a {
2440 let canonical = format!(
2441 "/compare?a={}&b={}",
2442 baseline_entry.run_id, current_entry.run_id
2443 );
2444 return axum::response::Redirect::to(&canonical).into_response();
2445 }
2446
2447 let (Some(base_json), Some(curr_json)) = (
2448 baseline_entry.json_path.as_ref(),
2449 current_entry.json_path.as_ref(),
2450 ) else {
2451 let html = ErrorTemplate {
2452 message: "Full comparison requires JSON scan data, which was not saved for one or \
2453 both of these runs. JSON is now always saved for new scans — re-run the \
2454 affected projects to enable comparisons."
2455 .to_string(),
2456 last_report_url: Some("/compare-scans".to_string()),
2457 last_report_label: Some("Compare Scans".to_string()),
2458 csp_nonce: csp_nonce.clone(),
2459 }
2460 .render()
2461 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
2462 return Html(html).into_response();
2463 };
2464
2465 let baseline_run = match read_json(base_json) {
2466 Ok(r) => r,
2467 Err(e) => {
2468 let message = if state.server_mode {
2469 "Could not load baseline scan data. The scan output folder may have been moved, \
2470 renamed, or deleted. Re-running the analysis will create fresh comparison data."
2471 .to_string()
2472 } else {
2473 format!(
2474 "Could not load baseline scan data.\n\nPath: {}\n\nError: {e}\n\n\
2475 The scan output folder may have been moved, renamed, or deleted. \
2476 Re-running the analysis for this project will create fresh comparison data.",
2477 base_json.display()
2478 )
2479 };
2480 let html = ErrorTemplate {
2481 message,
2482 last_report_url: Some("/compare-scans".to_string()),
2483 last_report_label: Some("Compare Scans".to_string()),
2484 csp_nonce: csp_nonce.clone(),
2485 }
2486 .render()
2487 .unwrap_or_else(|_| "<pre>Baseline load failed.</pre>".to_string());
2488 return (StatusCode::NOT_FOUND, Html(html)).into_response();
2489 }
2490 };
2491 let current_run = match read_json(curr_json) {
2492 Ok(r) => r,
2493 Err(e) => {
2494 let message = if state.server_mode {
2495 "Could not load current scan data. The scan output folder may have been moved, \
2496 renamed, or deleted. Re-running the analysis will create fresh comparison data."
2497 .to_string()
2498 } else {
2499 format!(
2500 "Could not load current scan data.\n\nPath: {}\n\nError: {e}\n\n\
2501 The scan output folder may have been moved, renamed, or deleted. \
2502 Re-running the analysis for this project will create fresh comparison data.",
2503 curr_json.display()
2504 )
2505 };
2506 let html = ErrorTemplate {
2507 message,
2508 last_report_url: Some("/compare-scans".to_string()),
2509 last_report_label: Some("Compare Scans".to_string()),
2510 csp_nonce: csp_nonce.clone(),
2511 }
2512 .render()
2513 .unwrap_or_else(|_| "<pre>Current load failed.</pre>".to_string());
2514 return (StatusCode::NOT_FOUND, Html(html)).into_response();
2515 }
2516 };
2517
2518 let comparison = compute_delta(&baseline_run, ¤t_run);
2519
2520 let file_rows: Vec<CompareFileDeltaRow> = comparison
2521 .file_deltas
2522 .iter()
2523 .map(|d| CompareFileDeltaRow {
2524 relative_path: d.relative_path.clone(),
2525 language: d.language.clone().unwrap_or_else(|| "—".into()),
2526 status: match d.status {
2527 FileChangeStatus::Added => "added".into(),
2528 FileChangeStatus::Removed => "removed".into(),
2529 FileChangeStatus::Modified => "modified".into(),
2530 FileChangeStatus::Unchanged => "unchanged".into(),
2531 },
2532 baseline_code: d.baseline_code,
2533 current_code: d.current_code,
2534 code_delta_str: fmt_delta(d.code_delta),
2535 code_delta_class: delta_class(d.code_delta).into(),
2536 comment_delta_str: fmt_delta(d.comment_delta),
2537 comment_delta_class: delta_class(d.comment_delta).into(),
2538 total_delta_str: fmt_delta(d.total_delta),
2539 total_delta_class: delta_class(d.total_delta).into(),
2540 })
2541 .collect();
2542
2543 let project_path = baseline_entry
2544 .input_roots
2545 .first()
2546 .map(|s| sanitize_path_str(s))
2547 .unwrap_or_default();
2548 let s = &comparison.summary;
2549 let template = CompareTemplate {
2550 baseline_run_id: baseline_entry.run_id.clone(),
2551 current_run_id: current_entry.run_id.clone(),
2552 baseline_run_id_short: baseline_entry
2553 .run_id
2554 .split('-')
2555 .next_back()
2556 .unwrap_or(&baseline_entry.run_id)
2557 .chars()
2558 .take(7)
2559 .collect(),
2560 current_run_id_short: current_entry
2561 .run_id
2562 .split('-')
2563 .next_back()
2564 .unwrap_or(¤t_entry.run_id)
2565 .chars()
2566 .take(7)
2567 .collect(),
2568 baseline_timestamp: fmt_pst(baseline_entry.timestamp_utc),
2569 current_timestamp: fmt_pst(current_entry.timestamp_utc),
2570 project_path,
2571 baseline_code: s.baseline_code,
2572 current_code: s.current_code,
2573 code_lines_delta_str: fmt_delta(s.code_lines_delta),
2574 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
2575 baseline_files: s.baseline_files,
2576 current_files: s.current_files,
2577 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
2578 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
2579 baseline_comments: s.baseline_comments,
2580 current_comments: s.current_comments,
2581 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
2582 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
2583 files_added: comparison.files_added,
2584 files_removed: comparison.files_removed,
2585 files_modified: comparison.files_modified,
2586 files_unchanged: comparison.files_unchanged,
2587 file_rows,
2588 baseline_git_author: baseline_entry.git_author.clone(),
2589 current_git_author: current_entry.git_author.clone(),
2590 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
2591 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
2592 baseline_git_tags: baseline_entry.git_tags.clone(),
2593 current_git_tags: current_entry.git_tags.clone(),
2594 csp_nonce,
2595 };
2596
2597 Html(
2598 template
2599 .render()
2600 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2601 )
2602 .into_response()
2603}
2604
2605fn format_number(n: u64) -> String {
2613 let s = n.to_string();
2614 let mut out = String::with_capacity(s.len() + s.len() / 3);
2615 let len = s.len();
2616 for (i, c) in s.chars().enumerate() {
2617 if i > 0 && (len - i).is_multiple_of(3) {
2618 out.push(',');
2619 }
2620 out.push(c);
2621 }
2622 out
2623}
2624
2625const fn badge_char_width(c: char) -> f64 {
2626 match c {
2627 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
2628 'm' | 'w' => 9.0,
2629 ' ' => 4.0,
2630 _ => 6.5,
2631 }
2632}
2633
2634#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2635fn badge_text_px(text: &str) -> u32 {
2636 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
2637}
2638
2639fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
2640 let lw = badge_text_px(label) + 20;
2641 let rw = badge_text_px(value) + 20;
2642 let total = lw + rw;
2643 let lx = lw / 2;
2644 let rx = lw + rw / 2;
2645 let le = escape_html(label);
2646 let ve = escape_html(value);
2647 let ce = escape_html(color);
2648 format!(
2649 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
2650 <rect width="{total}" height="20" fill="#555"/>
2651 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
2652 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
2653 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
2654 <text x="{lx}" y="13">{le}</text>
2655 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
2656 <text x="{rx}" y="13">{ve}</text>
2657 </g>
2658</svg>"##
2659 )
2660}
2661
2662#[derive(Deserialize)]
2663struct BadgeQuery {
2664 label: Option<String>,
2665 color: Option<String>,
2666}
2667
2668async fn badge_handler(
2669 State(state): State<AppState>,
2670 AxumPath(metric): AxumPath<String>,
2671 Query(query): Query<BadgeQuery>,
2672) -> Response {
2673 let entry = {
2674 let reg = state.registry.lock().await;
2675 reg.entries.first().cloned()
2676 };
2677
2678 let Some(entry) = entry else {
2679 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
2680 return (
2681 [
2682 (header::CONTENT_TYPE, "image/svg+xml"),
2683 (header::CACHE_CONTROL, "no-cache, max-age=0"),
2684 ],
2685 svg,
2686 )
2687 .into_response();
2688 };
2689
2690 let (default_label, value, default_color) = match metric.as_str() {
2691 "code-lines" => (
2692 "code lines",
2693 format_number(entry.summary.code_lines),
2694 "#4a78ee",
2695 ),
2696 "files" => (
2697 "files analyzed",
2698 format_number(entry.summary.files_analyzed),
2699 "#4a9862",
2700 ),
2701 "comment-lines" => (
2702 "comment lines",
2703 format_number(entry.summary.comment_lines),
2704 "#b35428",
2705 ),
2706 "blank-lines" => (
2707 "blank lines",
2708 format_number(entry.summary.blank_lines),
2709 "#7a5db0",
2710 ),
2711 _ => return StatusCode::NOT_FOUND.into_response(),
2712 };
2713
2714 let label = query.label.as_deref().unwrap_or(default_label);
2715 let color = query.color.as_deref().unwrap_or(default_color);
2716 let svg = render_badge_svg(label, &value, color);
2717
2718 (
2719 [
2720 (header::CONTENT_TYPE, "image/svg+xml"),
2721 (header::CACHE_CONTROL, "no-cache, max-age=0"),
2722 ],
2723 svg,
2724 )
2725 .into_response()
2726}
2727
2728#[derive(Serialize)]
2736struct ApiMetricsResponse {
2737 run_id: String,
2738 timestamp: String,
2739 project: String,
2740 summary: ApiSummaryPayload,
2741 languages: Vec<ApiLanguageRow>,
2742}
2743
2744#[derive(Serialize)]
2745struct ApiSummaryPayload {
2746 files_analyzed: u64,
2747 files_skipped: u64,
2748 code_lines: u64,
2749 comment_lines: u64,
2750 blank_lines: u64,
2751 total_physical_lines: u64,
2752 functions: u64,
2753 classes: u64,
2754 variables: u64,
2755 imports: u64,
2756}
2757
2758#[derive(Serialize)]
2759struct ApiLanguageRow {
2760 name: String,
2761 files: u64,
2762 code_lines: u64,
2763 comment_lines: u64,
2764 blank_lines: u64,
2765 functions: u64,
2766 classes: u64,
2767 variables: u64,
2768 imports: u64,
2769}
2770
2771async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
2772 let entry = {
2773 let reg = state.registry.lock().await;
2774 reg.entries.first().cloned()
2775 };
2776 entry.map_or_else(
2777 || {
2778 (
2779 StatusCode::NOT_FOUND,
2780 Json(serde_json::json!({"error": "no scans recorded yet"})),
2781 )
2782 .into_response()
2783 },
2784 |e| build_metrics_response(&e),
2785 )
2786}
2787
2788async fn api_metrics_run_handler(
2789 State(state): State<AppState>,
2790 AxumPath(run_id): AxumPath<String>,
2791) -> Response {
2792 let entry = {
2793 let reg = state.registry.lock().await;
2794 reg.find_by_run_id(&run_id).cloned()
2795 };
2796 entry.map_or_else(
2797 || {
2798 (
2799 StatusCode::NOT_FOUND,
2800 Json(serde_json::json!({"error": "run not found"})),
2801 )
2802 .into_response()
2803 },
2804 |e| build_metrics_response(&e),
2805 )
2806}
2807
2808fn build_metrics_response(entry: &RegistryEntry) -> Response {
2809 let languages: Vec<ApiLanguageRow> = entry
2810 .json_path
2811 .as_ref()
2812 .and_then(|p| read_json(p).ok())
2813 .map(|run| {
2814 run.totals_by_language
2815 .iter()
2816 .map(|l| ApiLanguageRow {
2817 name: l.language.display_name().to_string(),
2818 files: l.files,
2819 code_lines: l.code_lines,
2820 comment_lines: l.comment_lines,
2821 blank_lines: l.blank_lines,
2822 functions: l.functions,
2823 classes: l.classes,
2824 variables: l.variables,
2825 imports: l.imports,
2826 })
2827 .collect()
2828 })
2829 .unwrap_or_default();
2830
2831 let s = &entry.summary;
2832 Json(ApiMetricsResponse {
2833 run_id: entry.run_id.clone(),
2834 timestamp: entry.timestamp_utc.to_rfc3339(),
2835 project: entry.project_label.clone(),
2836 summary: ApiSummaryPayload {
2837 files_analyzed: s.files_analyzed,
2838 files_skipped: s.files_skipped,
2839 code_lines: s.code_lines,
2840 comment_lines: s.comment_lines,
2841 blank_lines: s.blank_lines,
2842 total_physical_lines: s.total_physical_lines,
2843 functions: s.functions,
2844 classes: s.classes,
2845 variables: s.variables,
2846 imports: s.imports,
2847 },
2848 languages,
2849 })
2850 .into_response()
2851}
2852
2853#[derive(Deserialize)]
2860struct ProjectHistoryQuery {
2861 path: Option<String>,
2862}
2863
2864#[derive(Serialize)]
2865struct ProjectHistoryResponse {
2866 scan_count: usize,
2867 last_scan_id: Option<String>,
2868 last_scan_timestamp: Option<String>,
2869 last_scan_code_lines: Option<u64>,
2870 last_git_branch: Option<String>,
2871 last_git_commit: Option<String>,
2872}
2873
2874async fn project_history_handler(
2875 State(state): State<AppState>,
2876 Query(query): Query<ProjectHistoryQuery>,
2877) -> Response {
2878 let path = query.path.unwrap_or_default();
2879 let resolved = resolve_input_path(&path);
2880 let root_str = resolved.to_string_lossy().replace('\\', "/");
2881
2882 let entries: Vec<_> = {
2883 let reg = state.registry.lock().await;
2884 reg.entries
2885 .iter()
2886 .filter(|e| e.input_roots.iter().any(|r| r == &root_str))
2887 .cloned()
2888 .collect()
2889 };
2890 let scan_count = entries.len();
2891 let last = entries.first();
2892 let last_scan_id = last.map(|e| e.run_id.clone());
2893 let last_scan_timestamp = last.map(|e| fmt_pst(e.timestamp_utc));
2894 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
2895 let last_git_branch = last.and_then(|e| e.git_branch.clone());
2896 let last_git_commit = last.and_then(|e| e.git_commit.clone());
2897
2898 Json(ProjectHistoryResponse {
2899 scan_count,
2900 last_scan_id,
2901 last_scan_timestamp,
2902 last_scan_code_lines,
2903 last_git_branch,
2904 last_git_commit,
2905 })
2906 .into_response()
2907}
2908
2909#[derive(Deserialize)]
2916struct EmbedQuery {
2917 run_id: Option<String>,
2918 theme: Option<String>,
2919}
2920
2921async fn embed_handler(
2922 State(state): State<AppState>,
2923 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2924 Query(query): Query<EmbedQuery>,
2925) -> Response {
2926 let entry = {
2927 let reg = state.registry.lock().await;
2928 query.run_id.as_ref().map_or_else(
2929 || reg.entries.first().cloned(),
2930 |id| reg.find_by_run_id(id).cloned(),
2931 )
2932 };
2933
2934 let Some(entry) = entry else {
2935 return Html(
2936 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
2937 .to_string(),
2938 )
2939 .into_response();
2940 };
2941
2942 let dark = query.theme.as_deref() == Some("dark");
2943 let languages: Vec<(String, u64, u64)> = entry
2944 .json_path
2945 .as_ref()
2946 .and_then(|p| read_json(p).ok())
2947 .map(|run| {
2948 run.totals_by_language
2949 .iter()
2950 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
2951 .collect()
2952 })
2953 .unwrap_or_default();
2954
2955 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
2956}
2957
2958fn render_embed_widget(
2959 entry: &RegistryEntry,
2960 languages: &[(String, u64, u64)],
2961 dark: bool,
2962 csp_nonce: &str,
2963) -> String {
2964 let s = &entry.summary;
2965 let total = s.code_lines + s.comment_lines + s.blank_lines;
2966 let code_pct = s
2967 .code_lines
2968 .checked_mul(100)
2969 .and_then(|n| n.checked_div(total))
2970 .unwrap_or(0);
2971
2972 let (bg, fg, surface, muted, border) = if dark {
2973 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
2974 } else {
2975 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
2976 };
2977
2978 let mut lang_rows = String::new();
2979 for (name, files, code) in languages {
2980 write!(
2981 lang_rows,
2982 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
2983 escape_html(name),
2984 format_number(*files),
2985 format_number(*code),
2986 )
2987 .ok();
2988 }
2989
2990 let lang_table = if lang_rows.is_empty() {
2991 String::new()
2992 } else {
2993 format!(
2994 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
2995 )
2996 };
2997
2998 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
2999 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
3000 let project_esc = escape_html(&entry.project_label);
3001 let code_lines = format_number(s.code_lines);
3002 let comment_lines = format_number(s.comment_lines);
3003 let files = format_number(s.files_analyzed);
3004 let code_raw = s.code_lines;
3005 let comment_raw = s.comment_lines;
3006 let blank_raw = s.blank_lines;
3007
3008 format!(
3009 r#"<!doctype html>
3010<html lang="en">
3011<head>
3012 <meta charset="utf-8">
3013 <meta name="viewport" content="width=device-width,initial-scale=1">
3014 <title>OxideSLOC — {project_esc}</title>
3015 <script src="/static/chart.js"></script>
3016 <style nonce="{csp_nonce}">
3017 *{{box-sizing:border-box;margin:0;padding:0}}
3018 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
3019 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
3020 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
3021 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
3022 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
3023 .card .v{{font-size:18px;font-weight:700}}
3024 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
3025 .row{{display:flex;gap:12px;align-items:flex-start}}
3026 .pie{{width:120px;height:120px;flex-shrink:0}}
3027 .lt{{border-collapse:collapse;width:100%;flex:1}}
3028 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
3029 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
3030 .n{{text-align:right}}
3031 .footer{{margin-top:10px;color:{muted};font-size:10px}}
3032 </style>
3033</head>
3034<body>
3035 <h2>{project_esc}</h2>
3036 <div class="sub">{timestamp} · run {run_short}</div>
3037 <div class="cards">
3038 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
3039 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
3040 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
3041 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
3042 </div>
3043 <div class="row">
3044 <canvas class="pie" id="c"></canvas>
3045 {lang_table}
3046 </div>
3047 <div class="footer">oxide-sloc</div>
3048 <script nonce="{csp_nonce}">
3049 new Chart(document.getElementById('c'),{{
3050 type:'doughnut',
3051 data:{{
3052 labels:['Code','Comments','Blank'],
3053 datasets:[{{
3054 data:[{code_raw},{comment_raw},{blank_raw}],
3055 backgroundColor:['#4a78ee','#b35428','#aaa'],
3056 borderWidth:0
3057 }}]
3058 }},
3059 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
3060 }});
3061 </script>
3062</body>
3063</html>"#
3064 )
3065}
3066
3067fn persist_run_artifacts(
3068 run: &sloc_core::AnalysisRun,
3069 report_html: &str,
3070 run_dir: &Path,
3071 generate_json: bool,
3072 generate_html: bool,
3073 generate_pdf: bool,
3074 report_title: &str,
3075) -> Result<(RunArtifacts, PendingPdf)> {
3076 fs::create_dir_all(run_dir)
3077 .with_context(|| format!("failed to create output directory {}", run_dir.display()))?;
3078
3079 let mut html_path = None;
3080 let mut pdf_path = None;
3081 let mut json_path = None;
3082 let mut pending_pdf: Option<(PathBuf, PathBuf, bool)> = None;
3083
3084 if generate_html {
3085 let path = run_dir.join("report.html");
3086 fs::write(&path, report_html)
3087 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
3088 html_path = Some(path);
3089 }
3090
3091 if generate_json {
3092 let path = run_dir.join("result.json");
3093 let json = serde_json::to_string_pretty(run)
3094 .context("failed to serialize analysis run to JSON")?;
3095 fs::write(&path, json)
3096 .with_context(|| format!("failed to write JSON report to {}", path.display()))?;
3097 json_path = Some(path);
3098 }
3099
3100 if generate_pdf {
3101 let source_html_path = if let Some(existing) = html_path.as_ref() {
3102 existing.clone()
3103 } else {
3104 let temp_html = run_dir.join("_report_rendered.html");
3105 fs::write(&temp_html, report_html).with_context(|| {
3106 format!(
3107 "failed to write temporary HTML report to {}",
3108 temp_html.display()
3109 )
3110 })?;
3111 temp_html
3112 };
3113
3114 let pdf_dest = run_dir.join("report.pdf");
3115 let cleanup_src = !generate_html;
3116 pdf_path = Some(pdf_dest.clone());
3117 pending_pdf = Some((source_html_path, pdf_dest, cleanup_src));
3118 }
3119
3120 Ok((
3121 RunArtifacts {
3122 output_dir: run_dir.to_path_buf(),
3123 html_path,
3124 pdf_path,
3125 json_path,
3126 report_title: report_title.to_string(),
3127 },
3128 pending_pdf,
3129 ))
3130}
3131
3132fn resolve_output_root(raw: Option<&str>) -> PathBuf {
3133 let value = raw.unwrap_or("out/web").trim();
3134 let path = if value.is_empty() {
3135 PathBuf::from("out/web")
3136 } else {
3137 PathBuf::from(value)
3138 };
3139
3140 if path.is_absolute() {
3141 path
3142 } else {
3143 workspace_root().join(path)
3144 }
3145}
3146
3147fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
3149 std::env::var("SLOC_GIT_CLONES_DIR")
3150 .map(PathBuf::from)
3151 .unwrap_or_else(|_| output_root.join("git-clones"))
3152}
3153
3154pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
3157 let safe: String = repo_url
3158 .chars()
3159 .map(|c| {
3160 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
3161 c
3162 } else {
3163 '_'
3164 }
3165 })
3166 .take(80)
3167 .collect();
3168 clones_dir.join(safe)
3169}
3170
3171pub(crate) fn scan_path_to_artifacts(
3174 scan_path: &Path,
3175 base_config: &AppConfig,
3176 label: &str,
3177) -> Result<String> {
3178 let mut config = base_config.clone();
3179 config.discovery.root_paths = vec![scan_path.to_path_buf()];
3180 config.reporting.report_title = label.to_owned();
3181 let run = analyze(&config, "git")?;
3182 let html = render_html(&run)?;
3183 let run_id = run.tool.run_id.clone();
3184 let project_label = sanitize_project_label(label);
3185 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
3186 persist_run_artifacts(&run, &html, &output_dir, true, true, false, label)?;
3187 Ok(run_id)
3188}
3189
3190async fn restart_poll_schedules(state: &AppState) {
3192 let store = state.schedules.lock().await;
3193 let poll_schedules: Vec<_> = store
3194 .schedules
3195 .iter()
3196 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
3197 .cloned()
3198 .collect();
3199 drop(store);
3200 for schedule in poll_schedules {
3201 let interval = schedule.interval_secs.unwrap_or(300);
3202 let st = state.clone();
3203 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
3204 }
3205}
3206
3207fn split_patterns(raw: Option<&str>) -> Vec<String> {
3208 raw.unwrap_or("")
3209 .lines()
3210 .flat_map(|line| line.split(','))
3211 .map(str::trim)
3212 .filter(|part| !part.is_empty())
3213 .map(ToOwned::to_owned)
3214 .collect()
3215}
3216
3217fn build_sub_run(
3218 parent: &AnalysisRun,
3219 sub: &sloc_core::SubmoduleSummary,
3220 parent_path: &str,
3221) -> AnalysisRun {
3222 let sub_files: Vec<_> = parent
3223 .per_file_records
3224 .iter()
3225 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
3226 .cloned()
3227 .collect();
3228 let mut config = parent.effective_configuration.clone();
3229 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
3230 AnalysisRun {
3231 tool: parent.tool.clone(),
3232 environment: parent.environment.clone(),
3233 effective_configuration: config,
3234 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
3235 summary_totals: SummaryTotals {
3236 files_considered: sub.files_analyzed,
3237 files_analyzed: sub.files_analyzed,
3238 files_skipped: 0,
3239 total_physical_lines: sub.total_physical_lines,
3240 code_lines: sub.code_lines,
3241 comment_lines: sub.comment_lines,
3242 blank_lines: sub.blank_lines,
3243 mixed_lines_separate: 0,
3244 functions: 0,
3245 classes: 0,
3246 variables: 0,
3247 imports: 0,
3248 },
3249 totals_by_language: sub.language_summaries.clone(),
3250 per_file_records: sub_files,
3251 skipped_file_records: vec![],
3252 warnings: vec![],
3253 submodule_summaries: vec![],
3254 git_commit_short: parent.git_commit_short.clone(),
3255 git_commit_long: parent.git_commit_long.clone(),
3256 git_branch: parent.git_branch.clone(),
3257 git_commit_author: parent.git_commit_author.clone(),
3258 git_tags: parent.git_tags.clone(),
3259 }
3260}
3261
3262fn sanitize_project_label(raw: &str) -> String {
3263 let candidate = Path::new(raw)
3264 .file_name()
3265 .and_then(|name| name.to_str())
3266 .unwrap_or("project");
3267
3268 let mut value = String::with_capacity(candidate.len());
3269 for ch in candidate.chars() {
3270 if ch.is_ascii_alphanumeric() {
3271 value.push(ch.to_ascii_lowercase());
3272 } else {
3273 value.push('-');
3274 }
3275 }
3276
3277 let compact = value.trim_matches('-').to_string();
3278 if compact.is_empty() {
3279 "project".to_string()
3280 } else {
3281 compact
3282 }
3283}
3284
3285fn strip_unc_prefix(path: PathBuf) -> PathBuf {
3288 let s = path.to_string_lossy();
3289 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
3290 return PathBuf::from(format!(r"\\{rest}"));
3291 }
3292 if let Some(rest) = s.strip_prefix(r"\\?\") {
3293 return PathBuf::from(rest);
3294 }
3295 path
3296}
3297
3298fn display_path(path: &Path) -> String {
3299 let s = path.to_string_lossy();
3300 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
3305 return format!(r"\\{rest}");
3306 }
3307 if let Some(rest) = s.strip_prefix(r"\\?\") {
3308 return rest.to_owned();
3309 }
3310 s.into_owned()
3311}
3312
3313fn sanitize_path_str(s: &str) -> String {
3314 if let Some(rest) = s.strip_prefix("//?/UNC/") {
3318 return format!("//{rest}");
3319 }
3320 if let Some(rest) = s.strip_prefix("//?/") {
3321 return rest.to_owned();
3322 }
3323 display_path(Path::new(s))
3324}
3325
3326fn workspace_root() -> PathBuf {
3327 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
3329 let p = PathBuf::from(root);
3330 if p.is_dir() {
3331 return p;
3332 }
3333 }
3334
3335 if let Ok(exe) = std::env::current_exe() {
3340 if let Some(dir) = exe.parent() {
3341 if dir.join("docs").join("assets").is_dir() {
3342 return dir.to_path_buf();
3343 }
3344 }
3345 }
3346
3347 if let Ok(cwd) = std::env::current_dir() {
3350 if cwd.join("docs").join("assets").is_dir() {
3351 return cwd;
3352 }
3353 }
3354
3355 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
3356}
3357
3358fn resolve_input_path(raw: &str) -> PathBuf {
3359 let trimmed = raw.trim();
3360 if trimmed.is_empty() {
3361 return workspace_root().join("samples").join("basic");
3362 }
3363
3364 let candidate = PathBuf::from(trimmed);
3365 let resolved = if candidate.is_absolute() {
3366 candidate
3367 } else {
3368 let rooted = workspace_root().join(&candidate);
3369 if rooted.exists() {
3370 rooted
3371 } else {
3372 workspace_root().join(candidate)
3373 }
3374 };
3375
3376 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
3379 PathBuf::from(display_path(&canonical))
3380}
3381
3382#[allow(clippy::too_many_lines)]
3383fn build_preview_html(
3384 root: &Path,
3385 include_patterns: &[String],
3386 exclude_patterns: &[String],
3387) -> Result<String> {
3388 if !root.exists() {
3389 return Ok(format!(
3390 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
3391 escape_html(&display_path(root))
3392 ));
3393 }
3394
3395 let _selected = display_path(root);
3396 let mut stats = PreviewStats::default();
3397 let mut rows = Vec::new();
3398 let mut languages = Vec::new();
3399 let mut budget = PreviewBudget {
3400 shown: 0,
3401 max_entries: 600,
3402 max_depth: 9,
3403 };
3404 let mut next_row_id = 1usize;
3405
3406 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
3407 || root.to_string_lossy().into_owned(),
3408 std::string::ToString::to_string,
3409 );
3410 let root_modified = root
3411 .metadata()
3412 .ok()
3413 .and_then(|meta| meta.modified().ok())
3414 .map_or_else(|| "-".to_string(), format_system_time);
3415
3416 rows.push(PreviewRow {
3417 row_id: 0,
3418 parent_row_id: None,
3419 depth: 0,
3420 name: format!("{root_name}/"),
3421 kind: PreviewKind::Dir,
3422 is_dir: true,
3423 language: None,
3424 modified: root_modified,
3425 type_label: "Directory".to_string(),
3426 });
3427 collect_preview_rows(
3428 root,
3429 root,
3430 0,
3431 Some(0),
3432 &mut next_row_id,
3433 &mut budget,
3434 &mut stats,
3435 &mut rows,
3436 &mut languages,
3437 include_patterns,
3438 exclude_patterns,
3439 )?;
3440
3441 let mut out = String::new();
3442 out.push_str(r#"<div class="explorer-wrap">"#);
3443 out.push_str(r#"<div class="explorer-toolbar compact">"#);
3444 out.push_str(r#"<div class="explorer-title-group">"#);
3445 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
3446 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
3447 out.push_str(r"</div></div>");
3448
3449 out.push_str(r#"<div class="scope-stats">"#);
3450 write!(out, 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).ok();
3451 write!(out, 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).ok();
3452 write!(out, 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).ok();
3453 write!(out, 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).ok();
3454 write!(out, 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).ok();
3455 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>"#);
3456 out.push_str(r"</div>");
3457
3458 out.push_str(r#"<div class="scope-info-row">"#);
3459 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
3460 if languages.is_empty() {
3461 out.push_str(
3462 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
3463 );
3464 } else {
3465 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
3466 for language in &languages {
3467 if let Some(icon) = language_icon_file(language) {
3468 write!(out, 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)).ok();
3469 } else if let Some(svg) = language_inline_svg(language) {
3470 write!(out, 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)).ok();
3471 } else {
3472 write!(
3473 out,
3474 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
3475 escape_html(&language.to_ascii_lowercase()),
3476 escape_html(language)
3477 )
3478 .ok();
3479 }
3480 }
3481 }
3482 out.push_str(r"</div></div>");
3483 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>"#);
3484 out.push_str(r"</div>");
3485
3486 out.push_str(r#"<div class="file-explorer-shell">"#);
3487 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>"#);
3488 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>"#);
3489 out.push_str(r#"<div class="file-explorer-tree">"#);
3490 for row in rows {
3491 let status_label = row.kind.label();
3492 let lang_attr = row.language.unwrap_or("");
3493 let toggle_html = if row.is_dir {
3494 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
3495 .to_string()
3496 } else {
3497 r#"<span class="tree-bullet">•</span>"#.to_string()
3498 };
3499 write!(out, 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).ok();
3500 }
3501 if budget.shown >= budget.max_entries {
3502 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>"#);
3503 }
3504 out.push_str(r"</div></div></div>");
3505
3506 Ok(out)
3507}
3508
3509#[derive(Default)]
3510struct PreviewStats {
3511 directories: usize,
3512 files: usize,
3513 supported: usize,
3514 skipped: usize,
3515 unsupported: usize,
3516}
3517
3518struct PreviewRow {
3519 row_id: usize,
3520 parent_row_id: Option<usize>,
3521 depth: usize,
3522 name: String,
3523 kind: PreviewKind,
3524 is_dir: bool,
3525 language: Option<&'static str>,
3526 modified: String,
3527 type_label: String,
3528}
3529
3530#[derive(Copy, Clone)]
3531enum PreviewKind {
3532 Dir,
3533 Supported,
3534 Skipped,
3535 Unsupported,
3536}
3537
3538impl PreviewKind {
3539 const fn filter_key(self) -> &'static str {
3540 match self {
3541 Self::Dir => "dir",
3542 Self::Supported => "supported",
3543 Self::Skipped => "skipped",
3544 Self::Unsupported => "unsupported",
3545 }
3546 }
3547
3548 const fn label(self) -> &'static str {
3549 match self {
3550 Self::Dir => "dir",
3551 Self::Supported => "supported",
3552 Self::Skipped => "skipped by policy",
3553 Self::Unsupported => "unsupported",
3554 }
3555 }
3556
3557 const fn badge_class(self) -> &'static str {
3558 match self {
3559 Self::Dir => "badge badge-dir",
3560 Self::Supported => "badge badge-scan",
3561 Self::Skipped => "badge badge-skip",
3562 Self::Unsupported => "badge badge-unsupported",
3563 }
3564 }
3565
3566 const fn node_class(self) -> &'static str {
3567 match self {
3568 Self::Dir => "tree-node-dir",
3569 Self::Supported => "tree-node-supported",
3570 Self::Skipped => "tree-node-skipped",
3571 Self::Unsupported => "tree-node-unsupported",
3572 }
3573 }
3574}
3575
3576struct PreviewBudget {
3577 shown: usize,
3578 max_entries: usize,
3579 max_depth: usize,
3580}
3581
3582#[allow(clippy::too_many_arguments)]
3585fn handle_preview_dir_entry(
3586 root: &Path,
3587 path: &Path,
3588 name: &str,
3589 modified: String,
3590 depth: usize,
3591 parent_row_id: Option<usize>,
3592 row_id: usize,
3593 next_row_id: &mut usize,
3594 budget: &mut PreviewBudget,
3595 stats: &mut PreviewStats,
3596 rows: &mut Vec<PreviewRow>,
3597 languages: &mut Vec<&'static str>,
3598 include_patterns: &[String],
3599 exclude_patterns: &[String],
3600) -> Result<()> {
3601 let relative = preview_relative_path(root, path);
3602 if should_skip_preview_directory(&relative, exclude_patterns) {
3603 return Ok(());
3604 }
3605 stats.directories += 1;
3606 rows.push(PreviewRow {
3607 row_id,
3608 parent_row_id,
3609 depth: depth + 1,
3610 name: format!("{name}/"),
3611 kind: PreviewKind::Dir,
3612 is_dir: true,
3613 language: None,
3614 modified,
3615 type_label: "Directory".to_string(),
3616 });
3617 budget.shown += 1;
3618 if !matches!(name, ".git" | "node_modules" | "target") {
3619 collect_preview_rows(
3620 root,
3621 path,
3622 depth + 1,
3623 Some(row_id),
3624 next_row_id,
3625 budget,
3626 stats,
3627 rows,
3628 languages,
3629 include_patterns,
3630 exclude_patterns,
3631 )?;
3632 }
3633 Ok(())
3634}
3635
3636#[allow(clippy::too_many_arguments)]
3638fn handle_preview_file_entry(
3639 root: &Path,
3640 path: &Path,
3641 name: &str,
3642 modified: String,
3643 depth: usize,
3644 parent_row_id: Option<usize>,
3645 row_id: usize,
3646 budget: &mut PreviewBudget,
3647 stats: &mut PreviewStats,
3648 rows: &mut Vec<PreviewRow>,
3649 languages: &mut Vec<&'static str>,
3650 include_patterns: &[String],
3651 exclude_patterns: &[String],
3652) {
3653 let relative = preview_relative_path(root, path);
3654 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
3655 return;
3656 }
3657 stats.files += 1;
3658 let kind = classify_preview_file(name);
3659 match kind {
3660 PreviewKind::Supported => stats.supported += 1,
3661 PreviewKind::Skipped => stats.skipped += 1,
3662 PreviewKind::Unsupported => stats.unsupported += 1,
3663 PreviewKind::Dir => {}
3664 }
3665 let language = detect_language_name(name);
3666 if let Some(lang) = language {
3667 if !languages.contains(&lang) {
3668 languages.push(lang);
3669 }
3670 }
3671 rows.push(PreviewRow {
3672 row_id,
3673 parent_row_id,
3674 depth: depth + 1,
3675 name: name.to_owned(),
3676 kind,
3677 is_dir: false,
3678 language,
3679 modified,
3680 type_label: preview_type_label(name, language, kind),
3681 });
3682 budget.shown += 1;
3683}
3684
3685#[allow(clippy::too_many_arguments)]
3686#[allow(clippy::too_many_lines)]
3687fn collect_preview_rows(
3688 root: &Path,
3689 dir: &Path,
3690 depth: usize,
3691 parent_row_id: Option<usize>,
3692 next_row_id: &mut usize,
3693 budget: &mut PreviewBudget,
3694 stats: &mut PreviewStats,
3695 rows: &mut Vec<PreviewRow>,
3696 languages: &mut Vec<&'static str>,
3697 include_patterns: &[String],
3698 exclude_patterns: &[String],
3699) -> Result<()> {
3700 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
3701 return Ok(());
3702 }
3703
3704 let mut entries = fs::read_dir(dir)
3705 .with_context(|| format!("failed to read directory {}", dir.display()))?
3706 .filter_map(std::result::Result::ok)
3707 .collect::<Vec<_>>();
3708 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
3709
3710 for entry in entries {
3711 if budget.shown >= budget.max_entries {
3712 break;
3713 }
3714
3715 let path = entry.path();
3716 let name = entry.file_name().to_string_lossy().into_owned();
3717 let Ok(metadata) = entry.metadata() else {
3718 continue;
3719 };
3720 let row_id = *next_row_id;
3721 *next_row_id += 1;
3722 let modified = metadata
3723 .modified()
3724 .ok()
3725 .map_or_else(|| "-".to_string(), format_system_time);
3726
3727 if metadata.is_dir() {
3728 handle_preview_dir_entry(
3729 root,
3730 &path,
3731 &name,
3732 modified,
3733 depth,
3734 parent_row_id,
3735 row_id,
3736 next_row_id,
3737 budget,
3738 stats,
3739 rows,
3740 languages,
3741 include_patterns,
3742 exclude_patterns,
3743 )?;
3744 continue;
3745 }
3746
3747 if metadata.is_file() {
3748 handle_preview_file_entry(
3749 root,
3750 &path,
3751 &name,
3752 modified,
3753 depth,
3754 parent_row_id,
3755 row_id,
3756 budget,
3757 stats,
3758 rows,
3759 languages,
3760 include_patterns,
3761 exclude_patterns,
3762 );
3763 }
3764 }
3765
3766 Ok(())
3767}
3768
3769fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
3770 if let Some(language) = language {
3771 return format!("{language} source");
3772 }
3773 let lower = name.to_ascii_lowercase();
3774 let ext = Path::new(&lower)
3775 .extension()
3776 .and_then(|e| e.to_str())
3777 .unwrap_or("");
3778 match kind {
3779 PreviewKind::Skipped => {
3780 if lower.ends_with(".min.js") {
3781 "Minified asset".to_string()
3782 } else if [
3783 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
3784 ]
3785 .contains(&ext)
3786 {
3787 "Binary or archive".to_string()
3788 } else {
3789 "Skipped file".to_string()
3790 }
3791 }
3792 PreviewKind::Unsupported => {
3793 if ext.is_empty() {
3794 "Unsupported file".to_string()
3795 } else {
3796 format!("{} file", ext.to_ascii_uppercase())
3797 }
3798 }
3799 PreviewKind::Supported => "Supported source".to_string(),
3800 PreviewKind::Dir => "Directory".to_string(),
3801 }
3802}
3803
3804fn format_system_time(time: SystemTime) -> String {
3805 #[allow(clippy::cast_possible_wrap)]
3806 let secs = match time.duration_since(UNIX_EPOCH) {
3807 Ok(duration) => duration.as_secs() as i64,
3808 Err(_) => return "-".to_string(),
3809 };
3810 let days = secs.div_euclid(86_400);
3811 let secs_of_day = secs.rem_euclid(86_400);
3812 let (year, month, day) = civil_from_days(days);
3813 let hour = secs_of_day / 3_600;
3814 let minute = (secs_of_day % 3_600) / 60;
3815 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
3816}
3817
3818#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3819fn civil_from_days(days: i64) -> (i32, u32, u32) {
3820 let z = days + 719_468;
3821 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
3822 let doe = z - era * 146_097;
3823 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
3824 let y = yoe + era * 400;
3825 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
3826 let mp = (5 * doy + 2) / 153;
3827 let d = doy - (153 * mp + 2) / 5 + 1;
3828 let m = mp + if mp < 10 { 3 } else { -9 };
3829 let year = y + i64::from(m <= 2);
3830 (year as i32, m as u32, d as u32)
3831}
3832
3833#[allow(clippy::case_sensitive_file_extension_comparisons)]
3836fn detect_language_name(name: &str) -> Option<&'static str> {
3837 let lower = name.to_ascii_lowercase();
3838 if lower.ends_with(".c") || lower.ends_with(".h") {
3839 Some("C")
3840 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
3841 .iter()
3842 .any(|s| lower.ends_with(s))
3843 {
3844 Some("C++")
3845 } else if lower.ends_with(".cs") {
3846 Some("C#")
3847 } else if lower.ends_with(".py") {
3848 Some("Python")
3849 } else if lower.ends_with(".sh") {
3850 Some("Shell")
3851 } else if [".ps1", ".psm1", ".psd1"]
3852 .iter()
3853 .any(|s| lower.ends_with(s))
3854 {
3855 Some("PowerShell")
3856 } else {
3857 None
3858 }
3859}
3860
3861fn language_icon_file(language: &str) -> Option<&'static str> {
3862 match language {
3863 "C" => Some("c.png"),
3864 "C++" => Some("cpp.png"),
3865 "C#" => Some("c-sharp.png"),
3866 "Python" => Some("python.png"),
3867 "Shell" => Some("shell.png"),
3868 "PowerShell" => Some("powershell.png"),
3869 "JavaScript" => Some("java-script.png"),
3870 "HTML" => Some("html-5.png"),
3871 "Java" => Some("java.png"),
3872 "Visual Basic" => Some("visual-basic.png"),
3873 _ => None,
3874 }
3875}
3876
3877fn language_inline_svg(language: &str) -> Option<&'static str> {
3882 match language {
3883 "Go" => Some(
3884 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>"##,
3885 ),
3886 "Rust" => Some(
3887 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>"##,
3888 ),
3889 "TypeScript" => Some(
3890 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>"##,
3891 ),
3892 _ => None,
3893 }
3894}
3895
3896#[allow(clippy::case_sensitive_file_extension_comparisons)]
3899fn classify_preview_file(name: &str) -> PreviewKind {
3900 let lower = name.to_ascii_lowercase();
3901
3902 let scannable = [
3903 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
3904 ".psm1", ".psd1",
3905 ]
3906 .iter()
3907 .any(|suffix| lower.ends_with(suffix));
3908
3909 if scannable {
3910 PreviewKind::Supported
3911 } else if lower.ends_with(".min.js")
3912 || lower.ends_with(".lock")
3913 || lower.ends_with(".png")
3914 || lower.ends_with(".jpg")
3915 || lower.ends_with(".jpeg")
3916 || lower.ends_with(".gif")
3917 || lower.ends_with(".zip")
3918 || lower.ends_with(".pdf")
3919 || lower.ends_with(".pyc")
3920 || lower.ends_with(".xz")
3921 || lower.ends_with(".tar")
3922 || lower.ends_with(".gz")
3923 {
3924 PreviewKind::Skipped
3925 } else {
3926 PreviewKind::Unsupported
3927 }
3928}
3929
3930fn preview_relative_path(root: &Path, path: &Path) -> String {
3931 path.strip_prefix(root)
3932 .ok()
3933 .unwrap_or(path)
3934 .to_string_lossy()
3935 .replace('\\', "/")
3936 .trim_matches('/')
3937 .to_string()
3938}
3939
3940fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
3941 if relative.is_empty() {
3942 return false;
3943 }
3944
3945 exclude_patterns.iter().any(|pattern| {
3946 wildcard_match(pattern, relative)
3947 || wildcard_match(pattern, &format!("{relative}/"))
3948 || wildcard_match(pattern, &format!("{relative}/placeholder"))
3949 })
3950}
3951
3952fn should_include_preview_file(
3953 relative: &str,
3954 include_patterns: &[String],
3955 exclude_patterns: &[String],
3956) -> bool {
3957 if relative.is_empty() {
3958 return true;
3959 }
3960
3961 let included = include_patterns.is_empty()
3962 || include_patterns
3963 .iter()
3964 .any(|pattern| wildcard_match(pattern, relative));
3965 let excluded = exclude_patterns
3966 .iter()
3967 .any(|pattern| wildcard_match(pattern, relative));
3968
3969 included && !excluded
3970}
3971
3972fn wildcard_match(pattern: &str, candidate: &str) -> bool {
3973 let pattern = pattern.trim().replace('\\', "/");
3974 let candidate = candidate.trim().replace('\\', "/");
3975 let p = pattern.as_bytes();
3976 let c = candidate.as_bytes();
3977 let mut pi = 0usize;
3978 let mut ci = 0usize;
3979 let mut star: Option<usize> = None;
3980 let mut star_match = 0usize;
3981
3982 while ci < c.len() {
3983 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
3984 pi += 1;
3985 ci += 1;
3986 } else if pi < p.len() && p[pi] == b'*' {
3987 while pi < p.len() && p[pi] == b'*' {
3988 pi += 1;
3989 }
3990 star = Some(pi);
3991 star_match = ci;
3992 } else if let Some(star_pi) = star {
3993 star_match += 1;
3994 ci = star_match;
3995 pi = star_pi;
3996 } else {
3997 return false;
3998 }
3999 }
4000
4001 while pi < p.len() && p[pi] == b'*' {
4002 pi += 1;
4003 }
4004
4005 pi == p.len()
4006}
4007
4008fn escape_html(value: &str) -> String {
4009 value
4010 .replace('&', "&")
4011 .replace('<', "<")
4012 .replace('>', ">")
4013 .replace('"', """)
4014 .replace('\'', "'")
4015}
4016
4017#[derive(Clone)]
4018struct LanguageSummaryRow {
4019 language: String,
4020 files: u64,
4021 physical: u64,
4022 code: u64,
4023 comments: u64,
4024 blank: u64,
4025 mixed: u64,
4026 functions: u64,
4027 classes: u64,
4028 variables: u64,
4029 imports: u64,
4030}
4031
4032#[derive(Clone)]
4033struct SubmoduleRow {
4034 name: String,
4035 relative_path: String,
4036 files_analyzed: u64,
4037 code_lines: u64,
4038 comment_lines: u64,
4039 blank_lines: u64,
4040 total_physical_lines: u64,
4041 html_url: Option<String>,
4042}
4043
4044#[derive(Template)]
4045#[template(
4046 source = r##"
4047<!doctype html>
4048<html lang="en">
4049<head>
4050 <meta charset="utf-8">
4051 <title>OxideSLOC | samples/basic</title>
4052 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
4053 <style nonce="{{ csp_nonce }}">
4054 :root {
4055 --bg: #efe9e2;
4056 --surface: #fcfaf7;
4057 --surface-2: #f7f0e8;
4058 --surface-3: #efe3d5;
4059 --line: #dfcfbf;
4060 --line-strong: #cfb29c;
4061 --text: #2f241c;
4062 --muted: #6f6257;
4063 --muted-2: #917f71;
4064 --nav: #b85d33;
4065 --nav-2: #7a371b;
4066 --accent: #2563eb;
4067 --accent-2: #1d4ed8;
4068 --oxide: #b85d33;
4069 --oxide-2: #8f4220;
4070 --success-bg: #eaf9ee;
4071 --success-text: #1c8746;
4072 --warn-bg: #fff2d8;
4073 --warn-text: #926000;
4074 --danger-bg: #fdeaea;
4075 --danger-text: #b33b3b;
4076 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
4077 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
4078 --radius: 14px;
4079 }
4080
4081 body.dark-theme {
4082 --bg: #1b1511;
4083 --surface: #261c17;
4084 --surface-2: #2d221d;
4085 --surface-3: #372922;
4086 --line: #524238;
4087 --line-strong: #6c5649;
4088 --text: #f5ece6;
4089 --muted: #c7b7aa;
4090 --muted-2: #aa9485;
4091 --nav: #b85d33;
4092 --nav-2: #7a371b;
4093 --accent: #6f9bff;
4094 --accent-2: #4a78ee;
4095 --oxide: #d37a4c;
4096 --oxide-2: #b35428;
4097 --success-bg: #163927;
4098 --success-text: #8fe2a8;
4099 --warn-bg: #3c2d11;
4100 --warn-text: #f3cb75;
4101 --danger-bg: #3d1f1f;
4102 --danger-text: #ff9f9f;
4103 --shadow: 0 14px 28px rgba(0,0,0,0.28);
4104 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
4105 }
4106
4107 * { box-sizing: border-box; }
4108 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); }
4109 html { overflow-y: scroll; }
4110 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
4111 .top-nav, .page, .loading { position: relative; z-index: 2; }
4112 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
4113 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
4114 .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); }
4115 .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; }
4116 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
4117 .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)); }
4118 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
4119 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
4120 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
4121 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
4122 .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; }
4123 .nav-project-pill.visible { display:inline-flex; }
4124 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
4125 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; }
4126 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: wrap; }
4127 .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; }
4128 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
4129 .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; }
4130 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
4131 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
4132 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
4133 .theme-toggle .icon-sun { display:none; }
4134 body.dark-theme .theme-toggle .icon-sun { display:block; }
4135 body.dark-theme .theme-toggle .icon-moon { display:none; }
4136 .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; }
4137 .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;}
4138 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; flex: 1; width: 100%; }
4139 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
4140 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
4141 .workbench-box { border: 1px solid var(--line-strong); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
4142 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
4143 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; }
4144 .wb-stats-header { padding: 10px 24px 0; }
4145 .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
4146 .ws-left { display:flex; align-items:center; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
4147 .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); }
4148 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
4149 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
4150 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
4151 .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; }
4152 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
4153 .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; }
4154 .ws-badge:hover .ws-lang-tooltip { display:block; }
4155 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:9px; }
4156 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
4157 .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; }
4158 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
4159 .ws-divider { display: none; }
4160 .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%; }
4161 .ws-path-link:hover { color:var(--oxide); }
4162 body.dark-theme .ws-path-link { color:var(--oxide); }
4163 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
4164 .ws-stat-output .ws-value { overflow:hidden; display:block; }
4165 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
4166 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
4167 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
4168 .ws-mini-box-lg { flex:2 1 0; }
4169 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
4170 .ws-mini-box-br { flex:1.5 1 0; }
4171 .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); }
4172 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
4173 .path-scope-grid { display:grid; grid-template-columns: 1fr 1px auto; gap:0; align-items:stretch; }
4174 .path-scope-grid .input-group { width:100%; align-self:start; }
4175 .path-scope-sep { background:var(--line); margin:4px 14px; }
4176 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
4177 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
4178 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
4179 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
4180 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
4181 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
4182 .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; }
4183 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
4184 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
4185 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
4186 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
4187 .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; }
4188 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
4189 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
4190 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
4191 .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; }
4192 .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); }
4193 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
4194 .side-info-card { padding: 18px; }
4195 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
4196 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
4197 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
4198 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
4199 .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); }
4200 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
4201 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
4202 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
4203 .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; }
4204 .layout { display:grid; grid-template-columns: 218px minmax(0, 1fr); gap: 18px; align-items:stretch; min-height: calc(100vh - 57px); }
4205 .layout[data-active-step="4"] { align-items: start; min-height: auto; }
4206 .side-stack { display:grid; gap: 16px; align-items:start; align-self: stretch; width: 218px; max-width: 218px; }
4207 .step-nav { padding: 20px 16px; position: sticky; top: 57px; z-index: 25; }
4208 .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); }
4209 .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; }
4210 .step-button:hover { background: var(--surface-2); }
4211 .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); }
4212 .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; }
4213 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
4214 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
4215 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
4216 .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); }
4217 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
4218 .step-nav-sum-row:last-child { border-bottom:none; }
4219 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
4220 .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; }
4221 .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
4222 .quick-scan-section { padding: 10px 4px 14px; }
4223 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
4224 .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; }
4225 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
4226 .quick-scan-btn:active { transform:translateY(0); }
4227 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
4228 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
4229 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
4230 @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);} }
4231 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
4232 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
4233 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
4234 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
4235 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
4236 .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; }
4237 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
4238 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
4239 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
4240 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
4241 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
4242 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
4243 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
4244 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
4245 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
4246 .card-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
4247 .card-body { padding: 22px; }
4248 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
4249 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
4250 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
4251 .section { margin-bottom: 22px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
4252 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
4253 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
4254 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
4255 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
4256 .field { min-width:0; }
4257 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
4258 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; }
4259 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); }
4260 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
4261 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); }
4262 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
4263 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
4264 .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; }
4265 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
4266 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
4267 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
4268 .input-group.compact { grid-template-columns: 1fr auto auto; }
4269 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
4270 .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)); }
4271 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
4272 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
4273 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
4274 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
4275 .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; }
4276 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
4277 .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; }
4278 .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); }
4279 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
4280 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
4281 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
4282 button.secondary { background: var(--surface); }
4283 .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); }
4284 .section + .wizard-actions { border-top: none; padding-top: 0; }
4285 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
4286 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
4287 .field-help-grid.coupled-help { margin-top: 12px; }
4288 .field-help-grid.preset-grid { align-items: start; }
4289 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:stretch; margin-bottom: 16px; }
4290 .preset-inline-row .field { margin: 0; }
4291 .preset-inline-row .explainer-card { margin: 0; }
4292 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
4293 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
4294 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
4295 .output-field-row .field { margin: 0; }
4296 .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; }
4297 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
4298 .step3-subtitle { margin-bottom: 28px; }
4299 .counting-intro { margin-bottom: 22px; max-width: none; }
4300 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
4301 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
4302 .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; }
4303 .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; }
4304 .section-spacer-top { margin-top: 28px; }
4305 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
4306 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
4307 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
4308 .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); }
4309 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
4310 .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; }
4311 .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; }
4312 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
4313 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
4314 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
4315 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
4316 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
4317 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
4318 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
4319 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; }
4320 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
4321 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
4322 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
4323 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
4324 .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); }
4325 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
4326 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
4327 .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; }
4328 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
4329 .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; }
4330 .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; }
4331 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
4332 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
4333 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
4334 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
4335 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
4336 .advanced-rule-description strong { color: var(--text); }
4337 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
4338 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
4339 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
4340 .review-link:hover { text-decoration: underline; }
4341 .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; }
4342 .artifact-card { position:relative; padding: 16px; cursor:pointer; }
4343 .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
4344 .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; }
4345 .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
4346 .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; }
4347 .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
4348 .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
4349 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
4350 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
4351 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
4352 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
4353 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
4354 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
4355 .review-card ul { padding-left: 18px; margin: 0; }
4356 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
4357 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
4358 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
4359 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
4360 .review-card { min-height: 200px; }
4361 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
4362 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
4363 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
4364 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
4365 .lang-overflow-chip { position:relative; cursor:default; }
4366 .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; }
4367 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
4368 .git-inline-row { align-items:start; }
4369 .mixed-line-card { display:flex; flex-direction:column; }
4370 .preset-inline-row .toggle-card { justify-content: center; }
4371 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
4372 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
4373 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
4374 .explorer-title { font-size: 18px; font-weight: 850; }
4375 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
4376 .explorer-subtitle.wide { max-width: none; }
4377 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
4378 .better-spacing { align-items:flex-start; justify-content:flex-end; }
4379 .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; }
4380 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
4381 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
4382 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
4383 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
4384 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
4385 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
4386 .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; }
4387 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
4388 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
4389 .scope-stat-button.supported { background: var(--success-bg); }
4390 .scope-stat-button.skipped { background: var(--warn-bg); }
4391 .scope-stat-button.unsupported { background: var(--danger-bg); }
4392 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
4393 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
4394 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
4395 [data-tooltip] { position: relative; }
4396 [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); }
4397 [data-tooltip]:hover::after { display: block; }
4398 .scope-stat-button[data-tooltip] { cursor: pointer; }
4399 .badge[data-tooltip] { cursor: help; }
4400 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
4401 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
4402 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
4403 .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; }
4404 .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; }
4405 code { display:inline-block; margin-top:0; padding:2px 7px; }
4406 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
4407 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
4408 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
4409 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
4410 .language-pill.muted-pill { color: var(--muted); }
4411 button.language-pill { appearance:none; cursor:pointer; }
4412 .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); }
4413 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
4414 .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; }
4415 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
4416 .file-explorer-search-row { margin-left: auto; }
4417 .explorer-filter-select { min-width: 170px; width: 170px; }
4418 .explorer-search { min-width: 300px; width: 300px; }
4419 .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); }
4420 .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; }
4421 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
4422 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
4423 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
4424 .file-explorer-tree { max-height: 560px; overflow:auto; }
4425 .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); }
4426 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
4427 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
4428 .tree-row.hidden-by-filter { display:none !important; }
4429 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 9px 0; }
4430 .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; }
4431 .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; }
4432 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
4433 .tree-bullet { color: var(--muted-2); width: 28px; text-align:center; flex: 0 0 28px; font-size: 14px; }
4434 .tree-node { display:inline-flex; align-items:center; min-width:0; }
4435 .tree-node-dir { color: var(--text); font-weight: 800; }
4436 .tree-node-supported { color: var(--success-text); }
4437 .tree-node-skipped { color: var(--warn-text); }
4438 .tree-node-unsupported { color: var(--danger-text); }
4439 .tree-node-more { color: var(--muted-2); font-style: italic; }
4440 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 13px; }
4441 .tree-status-cell { display:flex; justify-content:flex-start; }
4442 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
4443 .loading { position: fixed; inset: 0; display:none; align-items:center; justify-content:center; background: rgba(17,24,39,0.28); z-index: 100; }
4444 .loading.active { display:flex; }
4445 .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; }
4446 .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; }
4447 @keyframes spin { to { transform: rotate(360deg);} }
4448 .progress-bar { width:100%; height:8px; margin-top:14px; background: var(--surface-3); border-radius:999px; overflow:hidden; }
4449 .progress-bar span { display:block; width:42%; height:100%; background: linear-gradient(90deg, var(--accent), #6b8cff); animation: pulseBar 1.4s ease-in-out infinite; }
4450 @keyframes pulseBar { 0% { transform: translateX(-35%); width:25%; } 50% { transform: translateX(130%); width:44%; } 100% { transform: translateX(250%); width:25%; } }
4451 .hidden { display:none !important; }
4452 .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; }
4453 .site-footer a { color: var(--muted-2); font-weight: 700; text-decoration: none; }
4454 .site-footer a:hover { color: var(--text); text-decoration: underline; }
4455 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
4456 @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; } .side-stack { width: auto; max-width: none; } .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; } }
4457 .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;}
4458 @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));}}
4459 </style>
4460</head>
4461<body>
4462 <div class="background-watermarks" aria-hidden="true">
4463 <img src="/images/logo/logo-text.png" alt="" />
4464 <img src="/images/logo/logo-text.png" alt="" />
4465 <img src="/images/logo/logo-text.png" alt="" />
4466 <img src="/images/logo/logo-text.png" alt="" />
4467 <img src="/images/logo/logo-text.png" alt="" />
4468 <img src="/images/logo/logo-text.png" alt="" />
4469 <img src="/images/logo/logo-text.png" alt="" />
4470 <img src="/images/logo/logo-text.png" alt="" />
4471 <img src="/images/logo/logo-text.png" alt="" />
4472 <img src="/images/logo/logo-text.png" alt="" />
4473 <img src="/images/logo/logo-text.png" alt="" />
4474 <img src="/images/logo/logo-text.png" alt="" />
4475 <img src="/images/logo/logo-text.png" alt="" />
4476 <img src="/images/logo/logo-text.png" alt="" />
4477 </div>
4478 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
4479 <div class="top-nav">
4480 <div class="top-nav-inner">
4481 <a class="brand" href="/">
4482 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
4483 <div class="brand-copy">
4484 <div class="brand-title">OxideSLOC</div>
4485 <div class="brand-subtitle">Local analysis workbench</div>
4486 </div>
4487 </a>
4488 <div class="nav-project-slot">
4489 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
4490 <span class="nav-project-label">Project</span>
4491 <span class="nav-project-value" id="nav-project-title">samples/basic</span>
4492 </div>
4493 </div>
4494 <div class="nav-status">
4495 <a class="nav-pill" href="/">Home</a>
4496 <a class="nav-pill" href="/view-reports">View Reports</a>
4497 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
4498 <div class="server-status-wrap">
4499 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
4500 <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>
4501 </div>
4502 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
4503 <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>
4504 <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>
4505 </button>
4506 </div>
4507 </div>
4508 </div>
4509
4510 <div class="loading" id="loading">
4511 <div class="loading-card">
4512 <div class="spinner"></div>
4513 <h2>Scanning project...</h2>
4514 <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>
4515 <div class="progress-bar"><span></span></div>
4516 </div>
4517 </div>
4518
4519 <div class="page">
4520 <div class="workbench-strip">
4521 <div class="workbench-box wb-stats">
4522 <div class="wb-stats-header">
4523 <span class="wb-stats-title">Analysis session</span>
4524 </div>
4525 <div class="ws-left">
4526 <div class="ws-stat">
4527 <span class="ws-label">Analyzers</span>
4528 <span class="ws-value">
4529 <span class="ws-badge">41 languages
4530 <div class="ws-lang-tooltip">
4531 <div class="ws-lang-tooltip-hdr">41 supported languages</div>
4532 <div class="ws-lang-grid">
4533 <span class="ws-lang-item">Assembly</span>
4534 <span class="ws-lang-item">C</span>
4535 <span class="ws-lang-item">C++</span>
4536 <span class="ws-lang-item">C#</span>
4537 <span class="ws-lang-item">Clojure</span>
4538 <span class="ws-lang-item">CSS</span>
4539 <span class="ws-lang-item">Dart</span>
4540 <span class="ws-lang-item">Dockerfile</span>
4541 <span class="ws-lang-item">Elixir</span>
4542 <span class="ws-lang-item">Erlang</span>
4543 <span class="ws-lang-item">F#</span>
4544 <span class="ws-lang-item">Go</span>
4545 <span class="ws-lang-item">Groovy</span>
4546 <span class="ws-lang-item">Haskell</span>
4547 <span class="ws-lang-item">HTML</span>
4548 <span class="ws-lang-item">Java</span>
4549 <span class="ws-lang-item">JavaScript</span>
4550 <span class="ws-lang-item">Julia</span>
4551 <span class="ws-lang-item">Kotlin</span>
4552 <span class="ws-lang-item">Lua</span>
4553 <span class="ws-lang-item">Makefile</span>
4554 <span class="ws-lang-item">Nim</span>
4555 <span class="ws-lang-item">Obj-C</span>
4556 <span class="ws-lang-item">OCaml</span>
4557 <span class="ws-lang-item">Perl</span>
4558 <span class="ws-lang-item">PHP</span>
4559 <span class="ws-lang-item">PowerShell</span>
4560 <span class="ws-lang-item">Python</span>
4561 <span class="ws-lang-item">R</span>
4562 <span class="ws-lang-item">Ruby</span>
4563 <span class="ws-lang-item">Rust</span>
4564 <span class="ws-lang-item">Scala</span>
4565 <span class="ws-lang-item">SCSS</span>
4566 <span class="ws-lang-item">Shell</span>
4567 <span class="ws-lang-item">SQL</span>
4568 <span class="ws-lang-item">Svelte</span>
4569 <span class="ws-lang-item">Swift</span>
4570 <span class="ws-lang-item">TypeScript</span>
4571 <span class="ws-lang-item">Vue</span>
4572 <span class="ws-lang-item">XML</span>
4573 <span class="ws-lang-item">Zig</span>
4574 </div>
4575 </div>
4576 </span>
4577 </span>
4578 </div>
4579 <div class="ws-divider"></div>
4580 <div class="ws-stat"><span class="ws-label">Mode</span><span class="ws-value">Localhost workbench</span></div>
4581 <div class="ws-divider"></div>
4582 <div class="ws-stat"><span class="ws-label">Active project</span><span class="ws-value" id="live-report-title">—</span></div>
4583 <div class="ws-divider"></div>
4584 <div class="ws-stat ws-stat-output">
4585 <span class="ws-label">Output</span>
4586 <span class="ws-value">
4587 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
4588 <span id="ws-output-root">project/sloc</span>
4589 </button>
4590 </span>
4591 </div>
4592 </div>
4593 </div>
4594 <div class="workbench-box ws-history-group">
4595 <div class="ws-history-label">Scan history</div>
4596 <div class="ws-history-inner">
4597 <div class="ws-mini-box ws-mini-box-sm">
4598 <div class="ws-mini-label">Scans</div>
4599 <div class="ws-mini-value" id="ws-scan-count">—</div>
4600 </div>
4601 <div class="ws-mini-box ws-mini-box-lg">
4602 <div class="ws-mini-label">Last Scan</div>
4603 <div class="ws-mini-value" id="ws-last-scan">—</div>
4604 </div>
4605 <div class="ws-mini-box ws-mini-box-br">
4606 <div class="ws-mini-label">Branch</div>
4607 <div class="ws-mini-value" id="ws-branch">—</div>
4608 </div>
4609 </div>
4610 </div>
4611 </div>
4612
4613 <div class="layout">
4614 <aside class="side-stack">
4615 <section class="step-nav">
4616 <h3>Guided scan setup</h3>
4617 <button type="button" class="step-button active" data-step-target="1"><span class="step-num">1</span><span>Select project</span></button>
4618 <button type="button" class="step-button" data-step-target="2"><span class="step-num">2</span><span>Counting rules</span></button>
4619 <button type="button" class="step-button" data-step-target="3"><span class="step-num">3</span><span>Outputs and reports</span></button>
4620 <button type="button" class="step-button" data-step-target="4"><span class="step-num">4</span><span>Review and run</span></button>
4621
4622 <div class="step-nav-info" id="step-nav-info">
4623 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
4624 <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>
4625 </div>
4626
4627 <div class="quick-scan-divider"></div>
4628 <div class="quick-scan-section">
4629 <div class="quick-scan-label">No customization needed?</div>
4630 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
4631 <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>
4632 Quick Scan
4633 </button>
4634 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
4635 </div>
4636 </section>
4637
4638 </aside>
4639
4640 <section class="card">
4641 <div class="card-header">
4642 <div class="card-title-row">
4643 <div>
4644 <h1 class="card-title">Guided scan configuration</h1>
4645 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
4646 </div>
4647 <div class="wizard-progress" aria-label="Scan setup progress">
4648 <div class="wizard-progress-top">
4649 <span class="wizard-progress-label">Setup progress</span>
4650 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
4651 </div>
4652 <div class="wizard-progress-track">
4653 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
4654 </div>
4655 </div>
4656 </div>
4657 </div>
4658 <div class="card-body">
4659 <form method="post" action="/analyze" id="analyze-form">
4660 <div class="wizard-step active" data-step="1">
4661 <div class="section">
4662 <div class="section-kicker">Step 1</div>
4663 <h2>Select project and preview scope</h2>
4664 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
4665 <div class="field" style="margin:10px 0 0;">
4666 <label for="path">Project path</label>
4667 <div class="path-scope-grid">
4668 <div class="input-group">
4669 <input id="path" name="path" type="text" value="samples/basic" placeholder="/path/to/repository" required />
4670 <button type="button" class="mini-button oxide" id="browse-path">Browse</button>
4671 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
4672 </div>
4673 <div class="path-scope-sep"></div>
4674 <div class="scope-legend-row">
4675 <span class="scope-legend-label">Scope legend:</span>
4676 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
4677 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
4678 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
4679 </div>
4680 </div>
4681 <div class="hint">Browse opens the native folder picker through the Rust backend, so you do not need to type local paths manually.</div>
4682 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
4683 </div>
4684
4685 <div style="height:1px;background:var(--line);margin:28px 0;"></div>
4686
4687 <div id="preview-panel" style="margin-top:0;">
4688 <div class="preview-error">Loading preview...</div>
4689 </div>
4690 </div>
4691
4692 <div class="section">
4693 <div class="field-grid">
4694 <div class="field">
4695 <label for="include_globs">Include globs</label>
4696 <textarea id="include_globs" name="include_globs" placeholder="examples: src/**/*.py scripts/*.sh"></textarea>
4697 <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>
4698 </div>
4699 <div class="field">
4700 <label for="exclude_globs">Exclude globs</label>
4701 <textarea id="exclude_globs" name="exclude_globs" placeholder="examples: vendor/** **/*.min.js"></textarea>
4702 <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>
4703 </div>
4704 </div>
4705 <div class="glob-guidance-grid">
4706 <div class="glob-guidance-card">
4707 <strong>How to read them</strong>
4708 <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>
4709 </div>
4710 <div class="glob-guidance-card">
4711 <strong>Common include examples</strong>
4712 <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
4713 </div>
4714 <div class="glob-guidance-card">
4715 <strong>Common exclude examples</strong>
4716 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
4717 </div>
4718 </div>
4719 </div>
4720
4721 <div class="section" style="margin-top:14px;">
4722 <div class="preset-inline-row git-inline-row">
4723 <div class="toggle-card" style="margin:0;">
4724 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
4725 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
4726 <label class="checkbox">
4727 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
4728 <div>
4729 <span>Detect and separate git submodules</span>
4730 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
4731 </div>
4732 </label>
4733 </div>
4734 <div class="explainer-card prominent" style="margin:0;">
4735 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
4736 <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>
4737 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
4738 path = libs/core
4739 url = https://github.com/org/core.git
4740
4741[submodule "libs/ui"]
4742 path = libs/ui
4743 url = https://github.com/org/ui.git</div>
4744 </div>
4745 </div>
4746 </div>
4747
4748 <div class="wizard-actions">
4749 <div class="left"></div>
4750 <div class="right">
4751 <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
4752 </div>
4753 </div>
4754 </div>
4755
4756 <div class="wizard-step" data-step="2">
4757 <div class="section">
4758 <div class="section-kicker">Step 2</div>
4759 <h2>Choose counting behavior</h2>
4760 <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>
4761 <div class="subsection-bar">Primary line classification</div>
4762 <div class="preset-inline-row" style="align-items:start;">
4763 <div class="toggle-card mixed-line-card" style="margin:0;">
4764 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
4765 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
4766 <select id="mixed_line_policy" name="mixed_line_policy">
4767 <option value="code_only">Code only</option>
4768 <option value="code_and_comment">Code and comment</option>
4769 <option value="comment_only">Comment only</option>
4770 <option value="separate_mixed_category">Separate mixed category</option>
4771 </select>
4772 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
4773 </div>
4774 <div class="explainer-card prominent" style="margin:0;">
4775 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
4776 <div class="explainer-body" id="mixed-policy-description"></div>
4777 <div class="code-sample" id="mixed-policy-example"></div>
4778 </div>
4779 </div>
4780 </div>
4781
4782 <div class="subsection-bar">Additional scan rules</div>
4783 <div class="scan-rules-grid">
4784 <div class="preset-inline-row">
4785 <div class="toggle-card" style="margin:0;">
4786 <div class="field-help-title">Generated files</div>
4787 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
4788 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
4789 </div>
4790 <div class="explainer-card prominent" style="margin:0;">
4791 <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>
4792 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
4793# Files matching codegen patterns are excluded:
4794# *.generated.cs *.pb.go *.g.dart</div>
4795 </div>
4796 </div>
4797 <div class="preset-inline-row">
4798 <div class="toggle-card" style="margin:0;">
4799 <div class="field-help-title">Minified files</div>
4800 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
4801 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
4802 </div>
4803 <div class="explainer-card prominent" style="margin:0;">
4804 <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>
4805 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
4806# Heuristic: very long lines + low whitespace ratio
4807# jquery.min.js bundle.min.css → skipped</div>
4808 </div>
4809 </div>
4810 <div class="preset-inline-row">
4811 <div class="toggle-card" style="margin:0;">
4812 <div class="field-help-title">Vendor directories</div>
4813 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
4814 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
4815 </div>
4816 <div class="explainer-card prominent" style="margin:0;">
4817 <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>
4818 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
4819# Directories named vendor/ node_modules/ third_party/
4820# → entire subtree is excluded from totals</div>
4821 </div>
4822 </div>
4823 <div class="preset-inline-row">
4824 <div class="toggle-card" style="margin:0;">
4825 <div class="field-help-title">Lockfiles and manifests</div>
4826 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
4827 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
4828 </div>
4829 <div class="explainer-card prominent" style="margin:0;">
4830 <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>
4831 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
4832# Files like package-lock.json Cargo.lock yarn.lock
4833# → skipped unless this is enabled</div>
4834 </div>
4835 </div>
4836 <div class="preset-inline-row">
4837 <div class="toggle-card" style="margin:0;">
4838 <div class="field-help-title">Binary handling</div>
4839 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
4840 <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>
4841 </div>
4842 <div class="explainer-card prominent" style="margin:0;">
4843 <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>
4844 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
4845# Detected via long lines + low whitespace heuristic
4846# .png .exe .so → skipped silently</div>
4847 </div>
4848 </div>
4849 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
4850 <div class="toggle-card" style="margin:0;">
4851 <div class="field-help-title">Python docstrings</div>
4852 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
4853 <label class="checkbox">
4854 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
4855 <span>Count as comment-style lines</span>
4856 </label>
4857 </div>
4858 <div class="explainer-card prominent" style="margin:0;">
4859 <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>
4860 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
4861 </div>
4862 </div>
4863 </div>
4864 <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px;">
4865 <div class="always-tracked-tip">
4866 <div class="always-tracked-tip-icon">ℹ</div>
4867 <div class="always-tracked-tip-body">
4868 <div class="field-help-title">Always tracked — not configurable</div>
4869 <h4>Comment and blank-line basics</h4>
4870 <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>
4871 </div>
4872 </div>
4873 <div class="always-tracked-tip">
4874 <div class="always-tracked-tip-icon">→</div>
4875 <div class="always-tracked-tip-body">
4876 <div class="field-help-title">What these settings change</div>
4877 <h4>Lines on the boundary</h4>
4878 <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>
4879 </div>
4880 </div>
4881 </div>
4882
4883 <div class="wizard-actions">
4884 <div class="left">
4885 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
4886 </div>
4887 <div class="right">
4888 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
4889 </div>
4890 </div>
4891 </div>
4892
4893 <div class="wizard-step" data-step="3">
4894 <div class="section">
4895 <div class="section-kicker">Step 3</div>
4896 <h2>Output and report identity</h2>
4897 <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>
4898 <div class="preset-inline-row" style="align-items:start;">
4899 <div class="toggle-card" style="margin:0;">
4900 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
4901 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
4902 <select id="scan_preset">
4903 <option value="balanced">Balanced local scan</option>
4904 <option value="code_focused">Code focused</option>
4905 <option value="comment_audit">Comment audit</option>
4906 <option value="deep_review">Deep review</option>
4907 </select>
4908 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
4909 </div>
4910 <div class="explainer-card">
4911 <div class="field-help-title">Selected scan preset</div>
4912 <div class="explainer-body" id="scan-preset-description"></div>
4913 <div class="preset-summary-row" id="scan-preset-summary"></div>
4914 <div class="code-sample" id="scan-preset-example"></div>
4915 <div class="preset-note" id="scan-preset-note"></div>
4916 </div>
4917 </div>
4918 <hr class="step3-separator" />
4919 <div class="preset-inline-row" style="align-items:start;">
4920 <div class="toggle-card" style="margin:0;">
4921 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
4922 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
4923 <select id="artifact_preset">
4924 <option value="review">Review bundle</option>
4925 <option value="full">Full bundle</option>
4926 <option value="html_only">HTML only</option>
4927 <option value="machine">Machine bundle</option>
4928 </select>
4929 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
4930 </div>
4931 <div class="explainer-card">
4932 <div class="field-help-title">Selected artifact preset</div>
4933 <div class="explainer-body" id="artifact-preset-description"></div>
4934 <div class="preset-summary-row" id="artifact-preset-summary"></div>
4935 <div class="code-sample" id="artifact-preset-example"></div>
4936 </div>
4937 </div>
4938 </div>
4939
4940 <div class="section section-spacer-top">
4941 <div class="output-field-row">
4942 <div class="field">
4943 <label for="output_dir">Output directory</label>
4944 <div class="input-group compact">
4945 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
4946 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
4947 <button type="button" class="mini-button" id="use-default-output">Use default</button>
4948 </div>
4949 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
4950 </div>
4951 <div class="output-field-aside">
4952 <strong>Where reports land</strong>
4953 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.
4954 </div>
4955 </div>
4956 </div>
4957
4958 <div class="section section-spacer-top">
4959 <div class="output-field-row">
4960 <div class="field">
4961 <label for="report_title">Report title</label>
4962 <input id="report_title" name="report_title" type="text" value="samples/basic" placeholder="Project report title" />
4963 <div class="hint">Appears in HTML and PDF output headers.</div>
4964 </div>
4965 <div class="output-field-aside">
4966 <strong>Shown in exported artifacts</strong>
4967 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.
4968 </div>
4969 </div>
4970 </div>
4971
4972 <div class="section">
4973 <div class="section-kicker">Artifacts</div>
4974 <div class="artifact-grid">
4975 <div class="artifact-card selected" data-artifact="html">
4976 <div class="marker">✓</div>
4977 <div class="artifact-icon">H</div>
4978 <h4>HTML report</h4>
4979 <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
4980 <div class="artifact-tags">
4981 <span class="soft-chip">Best for visual review</span>
4982 <span class="soft-chip">Embeddable preview</span>
4983 </div>
4984 <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
4985 </div>
4986 <div class="artifact-card selected" data-artifact="pdf">
4987 <div class="marker">✓</div>
4988 <div class="artifact-icon">P</div>
4989 <h4>PDF export</h4>
4990 <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
4991 <div class="artifact-tags">
4992 <span class="soft-chip">Portable snapshot</span>
4993 <span class="soft-chip">Good for handoff</span>
4994 </div>
4995 <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
4996 </div>
4997 <div class="artifact-card selected" data-artifact="json" style="opacity:0.75;pointer-events:none;">
4998 <div class="marker" style="background:var(--oxide);border-color:var(--oxide);color:#fff;">✓</div>
4999 <div class="artifact-icon">J</div>
5000 <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--oxide-2);">Always on</span></h4>
5001 <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
5002 <div class="artifact-tags">
5003 <span class="soft-chip">Required for compare</span>
5004 <span class="soft-chip">Auto-enabled</span>
5005 </div>
5006 <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
5007 </div>
5008 </div>
5009 <div class="hint" style="margin-top:16px;">Artifact cards are selectable. Presets above can also toggle them for common workflows.</div>
5010 </div>
5011
5012 <div class="wizard-actions">
5013 <div class="left">
5014 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
5015 </div>
5016 <div class="right">
5017 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
5018 </div>
5019 </div>
5020 </div>
5021
5022 <div class="wizard-step" data-step="4">
5023 <div class="section">
5024 <div class="section-kicker">Step 4</div>
5025 <h2>Review selections and run</h2>
5026 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
5027 <div class="review-grid">
5028 <div class="review-card highlight">
5029 <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>
5030 <ul id="review-scan-summary"></ul>
5031 </div>
5032 <div class="review-card highlight">
5033 <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>
5034 <ul id="review-count-summary"></ul>
5035 </div>
5036 <div class="review-card">
5037 <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>
5038 <ul id="review-artifact-summary"></ul>
5039 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
5040 </div>
5041 <div class="review-card">
5042 <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>
5043 <ul id="review-preview-summary"></ul>
5044 </div>
5045 </div>
5046 </div>
5047
5048 <div class="wizard-actions">
5049 <div class="left">
5050 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
5051 </div>
5052 <div class="right">
5053 <button type="submit" id="submit-button" class="primary">Run analysis</button>
5054 </div>
5055 </div>
5056 </div></form>
5057 </div>
5058 </section>
5059 </div>
5060 </div>
5061
5062 <script nonce="{{ csp_nonce }}">
5063 (function () {
5064 var form = document.getElementById("analyze-form");
5065 var loading = document.getElementById("loading");
5066 var submitButton = document.getElementById("submit-button");
5067 var pathInput = document.getElementById("path");
5068 var outputDirInput = document.getElementById("output_dir");
5069 var reportTitleInput = document.getElementById("report_title");
5070 var previewPanel = document.getElementById("preview-panel");
5071 var refreshButton = document.getElementById("refresh-preview");
5072 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
5073 var useSamplePath = document.getElementById("use-sample-path");
5074 var useDefaultOutput = document.getElementById("use-default-output");
5075 var browsePath = document.getElementById("browse-path");
5076 var browseOutputDir = document.getElementById("browse-output-dir");
5077 var themeToggle = document.getElementById("theme-toggle");
5078 var mixedLinePolicy = document.getElementById("mixed_line_policy");
5079 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
5080 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
5081 var scanPreset = document.getElementById("scan_preset");
5082 var artifactPreset = document.getElementById("artifact_preset");
5083 var includeGlobsInput = document.getElementById("include_globs");
5084 var excludeGlobsInput = document.getElementById("exclude_globs");
5085 var liveReportTitle = document.getElementById("live-report-title");
5086 var navProjectPill = document.getElementById("nav-project-pill");
5087 var navProjectTitle = document.getElementById("nav-project-title");
5088 var reportTitlePreview = null;
5089 var wizardProgressFill = document.getElementById("wizard-progress-fill");
5090 var wizardProgressValue = document.getElementById("wizard-progress-value");
5091 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
5092 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
5093 var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
5094 var reportTitleTouched = false;
5095 var currentStep = 1;
5096 var previewTimer = null;
5097 var quickScanBtn = document.getElementById("quick-scan-btn");
5098
5099 if (quickScanBtn) {
5100 quickScanBtn.addEventListener("click", function () {
5101 var pathVal = pathInput ? pathInput.value.trim() : "";
5102 if (!pathVal) {
5103 alert("Please enter or browse to a project path first.");
5104 return;
5105 }
5106 quickScanBtn.disabled = true;
5107 quickScanBtn.textContent = "Scanning...";
5108 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
5109 if (loading) loading.classList.add("active");
5110 if (form) form.submit();
5111 });
5112 }
5113
5114 var mixedPolicyInfo = {
5115 code_only: {
5116 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.",
5117 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'
5118 },
5119 code_and_comment: {
5120 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.",
5121 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'
5122 },
5123 comment_only: {
5124 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.",
5125 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'
5126 },
5127 separate_mixed_category: {
5128 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.",
5129 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'
5130 }
5131 };
5132
5133 var scanPresetInfo = {
5134 balanced: {
5135 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.",
5136 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
5137 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
5138 note: "Best when you want a stable local overview before making deeper adjustments.",
5139 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
5140 },
5141 code_focused: {
5142 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
5143 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
5144 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
5145 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
5146 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
5147 },
5148 comment_audit: {
5149 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
5150 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
5151 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
5152 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
5153 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
5154 },
5155 deep_review: {
5156 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
5157 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
5158 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
5159 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
5160 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
5161 }
5162 };
5163
5164 var artifactPresetInfo = {
5165 review: {
5166 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.",
5167 chips: ["HTML", "PDF"],
5168 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
5169 },
5170 full: {
5171 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.",
5172 chips: ["HTML", "PDF", "JSON"],
5173 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
5174 },
5175 html_only: {
5176 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.",
5177 chips: ["HTML only", "Fast local review"],
5178 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
5179 },
5180 machine: {
5181 description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
5182 chips: ["HTML", "JSON"],
5183 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
5184 }
5185 };
5186
5187 function applyTheme(theme) {
5188 if (theme === "dark") document.body.classList.add("dark-theme");
5189 else document.body.classList.remove("dark-theme");
5190 }
5191
5192 function loadSavedTheme() {
5193 var saved = null;
5194 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
5195 applyTheme(saved === "dark" ? "dark" : "light");
5196 }
5197
5198 function updateScrollProgress() {
5199 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
5200 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
5201 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
5202 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
5203 var step = Math.min(Math.max(currentStep, 1), 4);
5204 var base = stepBase[step];
5205 var end = stepEnd[step];
5206
5207 var scrollFrac = 0;
5208 var activePanel = document.querySelector(".wizard-step.active");
5209 if (activePanel) {
5210 var scrollTop = window.scrollY || window.pageYOffset || 0;
5211 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
5212 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
5213 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
5214 var scrolled = scrollTop + viewH - panelTop;
5215 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
5216 }
5217
5218 var percent = Math.round(base + (end - base) * scrollFrac);
5219 percent = Math.min(end, Math.max(base, percent));
5220 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
5221 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
5222 }
5223
5224 function updateWizardProgress() {
5225 updateScrollProgress();
5226 }
5227
5228 var stepDescriptions = [
5229 "Choose a project folder, apply scope filters, and preview which files will be counted.",
5230 "Configure how mixed code-plus-comment lines and docstrings are classified.",
5231 "Pick your output formats, scan preset, and where reports are saved.",
5232 "Review all settings and launch the analysis."
5233 ];
5234
5235 function updateStepNav(step) {
5236 var infoLabel = document.getElementById("step-nav-info-label");
5237 var infoDesc = document.getElementById("step-nav-info-desc");
5238 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
5239 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
5240
5241 }
5242
5243 function setStep(step, pushHistory) {
5244 currentStep = step;
5245 stepPanels.forEach(function (panel) {
5246 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
5247 });
5248 stepButtons.forEach(function (button) {
5249 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
5250 });
5251 var layoutEl = document.querySelector(".layout");
5252 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
5253 updateWizardProgress();
5254 updateStepNav(step);
5255
5256 if (pushHistory !== false) {
5257 try {
5258 history.pushState({ wizardStep: step }, "", "#step" + step);
5259 } catch (e) {}
5260 }
5261
5262 window.scrollTo({ top: 0, behavior: "instant" });
5263 }
5264
5265 window.addEventListener("popstate", function (e) {
5266 if (e.state && e.state.wizardStep) {
5267 setStep(e.state.wizardStep, false);
5268 } else {
5269 var hashMatch = location.hash.match(/^#step([1-4])$/);
5270 if (hashMatch) setStep(Number(hashMatch[1]), false);
5271 }
5272 });
5273
5274 function inferTitleFromPath(value) {
5275 if (!value) return "project";
5276 var cleaned = value.replace(/[\/\\]+$/, "");
5277 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
5278 return parts.length ? parts[parts.length - 1] : value;
5279 }
5280
5281 function updateReportTitleFromPath() {
5282 var inferred = inferTitleFromPath(pathInput.value || "samples/basic");
5283 if (!reportTitleTouched) {
5284 reportTitleInput.value = inferred;
5285 }
5286 var title = reportTitleInput.value || inferred;
5287 if (liveReportTitle) liveReportTitle.textContent = title;
5288 if (reportTitlePreview) reportTitlePreview.textContent = title;
5289 document.title = "OxideSLOC | " + title;
5290
5291 var projectPath = (pathInput.value || "").trim();
5292 if (navProjectPill && navProjectTitle) {
5293 if (projectPath.length > 0) {
5294 navProjectTitle.textContent = inferred;
5295 navProjectPill.classList.add("visible");
5296 } else {
5297 navProjectTitle.textContent = "";
5298 navProjectPill.classList.remove("visible");
5299 }
5300 }
5301 }
5302
5303 function updateMixedPolicyUI() {
5304 var key = mixedLinePolicy.value || "code_only";
5305 var info = mixedPolicyInfo[key];
5306 document.getElementById("mixed-policy-description").textContent = info.description;
5307 document.getElementById("mixed-policy-example").textContent = info.example;
5308 }
5309
5310 function updatePythonDocstringUI() {
5311 var checked = !!pythonDocstrings.checked;
5312 document.getElementById("python-docstring-example").textContent = checked
5313 ? 'def greet():\n """Greet the user.""" ← comment\n print("hi")'
5314 : 'def greet():\n """Greet the user.""" ← not counted\n print("hi")';
5315 document.getElementById("python-docstring-live-help").textContent = checked
5316 ? "Enabled: docstrings contribute to comment-style totals."
5317 : "Disabled: docstrings are not counted as comment content.";
5318 }
5319
5320 function renderPresetChips(targetId, chips) {
5321 var target = document.getElementById(targetId);
5322 if (!target) return;
5323 target.innerHTML = (chips || []).map(function (chip) {
5324 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
5325 }).join('');
5326 }
5327
5328 function updatePresetDescriptions() {
5329 var scanInfo = scanPresetInfo[scanPreset.value];
5330 var artifactInfo = artifactPresetInfo[artifactPreset.value];
5331 document.getElementById("scan-preset-description").textContent = scanInfo.description;
5332 document.getElementById("scan-preset-example").textContent = scanInfo.example;
5333 document.getElementById("scan-preset-note").textContent = scanInfo.note;
5334 document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
5335 document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
5336 renderPresetChips("scan-preset-summary", scanInfo.chips);
5337 renderPresetChips("artifact-preset-summary", artifactInfo.chips);
5338 }
5339
5340 function applyScanPreset() {
5341 var info = scanPresetInfo[scanPreset.value];
5342 if (!info || !info.apply) return;
5343 mixedLinePolicy.value = info.apply.mixed;
5344 pythonDocstrings.checked = !!info.apply.docstrings;
5345 document.getElementById("generated_file_detection").value = info.apply.generated;
5346 document.getElementById("minified_file_detection").value = info.apply.minified;
5347 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
5348 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
5349 document.getElementById("binary_file_behavior").value = info.apply.binary;
5350 updateMixedPolicyUI();
5351 updatePythonDocstringUI();
5352 }
5353
5354 function applyArtifactPreset() {
5355 var enabled = { html: false, pdf: false, json: false };
5356 if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
5357 if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; enabled.json = true; }
5358 if (artifactPreset.value === "html_only") { enabled.html = true; }
5359 if (artifactPreset.value === "machine") { enabled.json = true; enabled.html = true; }
5360
5361 artifactCards.forEach(function (card) {
5362 var artifact = card.getAttribute("data-artifact");
5363 var checked = !!enabled[artifact];
5364 var checkbox = card.querySelector(".artifact-checkbox");
5365 checkbox.checked = checked;
5366 card.classList.toggle("selected", checked);
5367 });
5368 }
5369
5370 function toggleArtifactCard(card) {
5371 var checkbox = card.querySelector(".artifact-checkbox");
5372 checkbox.checked = !checkbox.checked;
5373 card.classList.toggle("selected", checkbox.checked);
5374 }
5375
5376 function updateReview() {
5377 var scanSummary = document.getElementById("review-scan-summary");
5378 var countSummary = document.getElementById("review-count-summary");
5379 var artifactSummary = document.getElementById("review-artifact-summary");
5380 var outputSummary = document.getElementById("review-output-summary");
5381 var previewSummary = document.getElementById("review-preview-summary");
5382 var readinessSummary = document.getElementById("review-readiness-summary");
5383 var includeText = document.getElementById("include_globs").value.trim();
5384 var excludeText = document.getElementById("exclude_globs").value.trim();
5385 var sidePathPreview = document.getElementById("side-path-preview");
5386 var sideOutputPreview = document.getElementById("side-output-preview");
5387 var sideTitlePreview = document.getElementById("side-title-preview");
5388
5389 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "samples/basic"; }
5390 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
5391 if (sideTitlePreview) {
5392 var rt = document.getElementById("report_title");
5393 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
5394 }
5395
5396 scanSummary.innerHTML = ""
5397 + "<li>Path: " + escapeHtml(pathInput.value || "samples/basic") + "</li>"
5398 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
5399 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
5400
5401 countSummary.innerHTML = ""
5402 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
5403 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
5404 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
5405 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
5406 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
5407 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
5408 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
5409 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
5410
5411 var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.querySelector("h4").textContent; });
5412 artifactSummary.innerHTML = ""
5413 + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
5414 + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
5415
5416 outputSummary.innerHTML = ""
5417 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
5418 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value || "samples/basic")) + "</li>";
5419
5420 if (previewSummary) {
5421 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
5422 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
5423 var statMap = {};
5424 statButtons.forEach(function (button) {
5425 var valueNode = button.querySelector('.scope-stat-value');
5426 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
5427 });
5428 previewSummary.innerHTML = ''
5429 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
5430 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
5431 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
5432 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
5433 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
5434 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
5435
5436 if (readinessSummary) {
5437 var selectedArtifactsCount = selectedArtifacts.length;
5438 readinessSummary.innerHTML = ''
5439 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
5440 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
5441 + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
5442 + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
5443 }
5444 }
5445 }
5446
5447 function escapeHtml(value) {
5448 return String(value)
5449 .replace(/&/g, "&")
5450 .replace(/</g, "<")
5451 .replace(/>/g, ">")
5452 .replace(/"/g, """)
5453 .replace(/'/g, "'");
5454 }
5455
5456 function isPythonVisible() {
5457 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
5458 }
5459
5460 function syncPythonVisibility() {
5461 var html = previewPanel.textContent || "";
5462 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
5463 pythonWraps.forEach(function (node) {
5464 node.classList.toggle("hidden", !hasPython);
5465 });
5466 }
5467
5468 function attachPreviewInteractions() {
5469 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
5470 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
5471 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
5472 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
5473 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
5474 var searchInput = previewPanel.querySelector("#explorer-search");
5475 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
5476 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
5477 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
5478 var activeFilter = "all";
5479 var activeLanguage = "";
5480 var searchTerm = "";
5481 var currentSortKey = null;
5482 var currentSortOrder = "asc";
5483 var childRows = {};
5484
5485 rows.forEach(function (row) {
5486 var parentId = row.getAttribute("data-parent-id") || "";
5487 var rowId = row.getAttribute("data-row-id") || "";
5488 if (!childRows[parentId]) childRows[parentId] = [];
5489 childRows[parentId].push(rowId);
5490 });
5491
5492 function rowById(id) {
5493 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
5494 }
5495
5496 function hasCollapsedAncestor(row) {
5497 var parentId = row.getAttribute("data-parent-id");
5498 while (parentId) {
5499 var parent = rowById(parentId);
5500 if (!parent) break;
5501 if (parent.getAttribute("data-expanded") === "false") return true;
5502 parentId = parent.getAttribute("data-parent-id");
5503 }
5504 return false;
5505 }
5506
5507 function updateToggleGlyph(row) {
5508 var toggle = row.querySelector(".tree-toggle");
5509 if (!toggle) return;
5510 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
5511 }
5512
5513 function rowSortValue(row, key) {
5514 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
5515 }
5516
5517 function updateSortButtons() {
5518 sortButtons.forEach(function (button) {
5519 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
5520 var indicator = button.querySelector(".tree-sort-indicator");
5521 button.classList.toggle("active", isActive);
5522 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
5523 if (indicator) {
5524 indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
5525 }
5526 });
5527 }
5528
5529 function sortSiblingRows() {
5530 if (!treeContainer) {
5531 updateSortButtons();
5532 return;
5533 }
5534
5535 var rowMap = {};
5536 var childrenMap = {};
5537 rows.forEach(function (row) {
5538 var rowId = row.getAttribute("data-row-id");
5539 var parentId = row.getAttribute("data-parent-id") || "";
5540 rowMap[rowId] = row;
5541 if (!childrenMap[parentId]) childrenMap[parentId] = [];
5542 childrenMap[parentId].push(rowId);
5543 });
5544
5545 Object.keys(childrenMap).forEach(function (parentId) {
5546 if (!parentId) return;
5547 childrenMap[parentId].sort(function (a, b) {
5548 var rowA = rowMap[a];
5549 var rowB = rowMap[b];
5550 if (!currentSortKey) {
5551 return Number(a) - Number(b);
5552 }
5553 var valueA = rowSortValue(rowA, currentSortKey);
5554 var valueB = rowSortValue(rowB, currentSortKey);
5555 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
5556 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
5557 var fallbackA = rowSortValue(rowA, "name");
5558 var fallbackB = rowSortValue(rowB, "name");
5559 if (fallbackA < fallbackB) return -1;
5560 if (fallbackA > fallbackB) return 1;
5561 return Number(a) - Number(b);
5562 });
5563 });
5564
5565 var orderedIds = [];
5566 function pushChildren(parentId) {
5567 (childrenMap[parentId] || []).forEach(function (childId) {
5568 orderedIds.push(childId);
5569 pushChildren(childId);
5570 });
5571 }
5572
5573 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
5574 orderedIds.push(topId);
5575 pushChildren(topId);
5576 });
5577
5578 orderedIds.forEach(function (id) {
5579 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
5580 });
5581 updateSortButtons();
5582 }
5583
5584 function updateLanguageButtons() {
5585 languageButtons.forEach(function (button) {
5586 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
5587 var isActive = languageValue === activeLanguage;
5588 button.classList.toggle("active", isActive);
5589 });
5590 }
5591
5592 function rowSelfMatches(row) {
5593 var kind = row.getAttribute("data-kind");
5594 var status = row.getAttribute("data-status");
5595 var language = (row.getAttribute("data-language") || "").toLowerCase();
5596 var name = row.getAttribute("data-name-lower") || "";
5597 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
5598 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
5599 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
5600 var passesLanguage = !activeLanguage || language === activeLanguage;
5601 return passesFilter && passesSearch && passesLanguage;
5602 }
5603
5604 function hasMatchingDescendant(rowId) {
5605 return (childRows[rowId] || []).some(function (childId) {
5606 var childRow = rowById(childId);
5607 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
5608 });
5609 }
5610
5611 function rowMatches(row) {
5612 if (rowSelfMatches(row)) return true;
5613 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
5614 }
5615
5616 function resetViewState() {
5617 activeFilter = "all";
5618 activeLanguage = "";
5619 searchTerm = "";
5620 currentSortKey = null;
5621 currentSortOrder = "asc";
5622 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
5623 if (searchInput) searchInput.value = "";
5624 if (filterSelect) filterSelect.value = "all";
5625 updateLanguageButtons();
5626 }
5627
5628 function applyVisibility() {
5629 rows.forEach(function (row) {
5630 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
5631 row.classList.toggle("hidden-by-filter", !visible);
5632 row.style.display = visible ? "grid" : "none";
5633 });
5634 buttons.forEach(function (button) {
5635 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
5636 });
5637 if (filterSelect) filterSelect.value = activeFilter;
5638 }
5639
5640 buttons.forEach(function (button) {
5641 button.addEventListener("click", function () {
5642 var filterValue = button.getAttribute("data-filter") || "all";
5643 if (filterValue === "reset-view") {
5644 resetViewState();
5645 sortSiblingRows();
5646 applyVisibility();
5647 return;
5648 }
5649 activeFilter = filterValue;
5650 applyVisibility();
5651 });
5652 });
5653
5654 rows.forEach(function (row) {
5655 updateToggleGlyph(row);
5656 var toggle = row.querySelector(".tree-toggle");
5657 if (toggle) {
5658 toggle.addEventListener("click", function () {
5659 var expanded = row.getAttribute("data-expanded") !== "false";
5660 row.setAttribute("data-expanded", expanded ? "false" : "true");
5661 updateToggleGlyph(row);
5662 applyVisibility();
5663 });
5664 }
5665 });
5666
5667 actionButtons.forEach(function (button) {
5668 button.addEventListener("click", function () {
5669 var action = button.getAttribute("data-explorer-action");
5670 if (action === "expand-all") {
5671 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
5672 } else if (action === "collapse-all") {
5673 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
5674 } else if (action === "clear-filters") {
5675 resetViewState();
5676 }
5677 sortSiblingRows();
5678 applyVisibility();
5679 });
5680 });
5681
5682 if (filterSelect) {
5683 filterSelect.addEventListener("change", function () {
5684 activeFilter = filterSelect.value || "all";
5685 applyVisibility();
5686 });
5687 }
5688
5689 languageButtons.forEach(function (button) {
5690 button.addEventListener("click", function () {
5691 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
5692 updateLanguageButtons();
5693 applyVisibility();
5694 });
5695 });
5696
5697 sortButtons.forEach(function (button) {
5698 button.addEventListener("click", function () {
5699 var sortKey = button.getAttribute("data-sort-key");
5700 if (currentSortKey === sortKey) {
5701 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
5702 } else {
5703 currentSortKey = sortKey;
5704 currentSortOrder = "asc";
5705 }
5706 sortSiblingRows();
5707 applyVisibility();
5708 });
5709 });
5710
5711 if (searchInput) {
5712 searchInput.addEventListener("input", function () {
5713 searchTerm = searchInput.value.trim().toLowerCase();
5714 applyVisibility();
5715 });
5716 }
5717
5718 updateLanguageButtons();
5719 sortSiblingRows();
5720 applyVisibility();
5721 }
5722
5723 function loadPreview() {
5724 if (!previewPanel || !pathInput) return;
5725 var path = pathInput.value || "samples/basic";
5726 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
5727 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
5728 previewPanel.innerHTML = '<div class="preview-error">Refreshing preview...</div>';
5729 var previewUrl = "/preview?path=" + encodeURIComponent(path)
5730 + "&include_globs=" + encodeURIComponent(includeValue)
5731 + "&exclude_globs=" + encodeURIComponent(excludeValue);
5732 fetch(previewUrl)
5733 .then(function (response) { return response.text(); })
5734 .then(function (html) {
5735 previewPanel.innerHTML = html;
5736 attachPreviewInteractions();
5737 syncPythonVisibility();
5738 updateReview();
5739 setTimeout(collapseLanguagePills, 50);
5740 })
5741 .catch(function (err) {
5742 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
5743 });
5744 }
5745
5746 function pickDirectory(targetInput, kind) {
5747 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
5748 if (browseButton) browseButton.disabled = true;
5749
5750 if (previewPanel && targetInput === pathInput) {
5751 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
5752 }
5753
5754 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
5755 .then(function (response) { return response.json(); })
5756 .then(function (data) {
5757 if (data && data.selected_path) {
5758 targetInput.value = data.selected_path;
5759
5760 if (targetInput === pathInput) {
5761 updateReportTitleFromPath();
5762 autoSetOutputDir(data.selected_path);
5763 fetchProjectHistory(data.selected_path);
5764 loadPreview();
5765 }
5766
5767 updateReview();
5768 } else if (targetInput === pathInput) {
5769 // Cancelled — keep existing value and refresh preview with current path
5770 loadPreview();
5771 }
5772 })
5773 .catch(function () {
5774 window.alert("Directory picker request failed.");
5775 if (previewPanel && targetInput === pathInput) {
5776 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
5777 }
5778 })
5779 .finally(function () {
5780 if (browseButton) browseButton.disabled = false;
5781 });
5782 }
5783
5784 if (themeToggle) {
5785 themeToggle.addEventListener("click", function () {
5786 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
5787 applyTheme(nextTheme);
5788 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
5789 });
5790 }
5791
5792 stepButtons.forEach(function (button) {
5793 button.addEventListener("click", function () {
5794 setStep(Number(button.getAttribute("data-step-target")));
5795 });
5796 });
5797
5798 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
5799 button.addEventListener("click", function () {
5800 setStep(Number(button.getAttribute("data-step-target")) || 1);
5801 });
5802 });
5803
5804 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
5805 button.addEventListener("click", function () {
5806 updateReview();
5807 setStep(Number(button.getAttribute("data-next")));
5808 });
5809 });
5810
5811 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
5812 button.addEventListener("click", function () {
5813 setStep(Number(button.getAttribute("data-prev")));
5814 });
5815 });
5816
5817 if (useSamplePath) {
5818 useSamplePath.addEventListener("click", function () {
5819 pathInput.value = "samples/basic";
5820 updateReportTitleFromPath();
5821 loadPreview();
5822 });
5823 }
5824
5825 if (useDefaultOutput) {
5826 useDefaultOutput.addEventListener("click", function () {
5827 delete outputDirInput.dataset.userEdited;
5828 autoSetOutputDir(pathInput ? pathInput.value : "");
5829 updateReview();
5830 });
5831 }
5832
5833 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
5834 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
5835
5836 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
5837
5838 // ── Language pill overflow: collapse to "+N more" chip ─────────────
5839 function collapseLanguagePills() {
5840 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
5841 rows.forEach(function(row) {
5842 // Remove any previous overflow chip
5843 var prev = row.querySelector('.lang-overflow-chip');
5844 if (prev) prev.remove();
5845 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
5846 pills.forEach(function(p) { p.style.display = ''; });
5847 if (!pills.length) return;
5848
5849 // Measure after restoring all pills
5850 var containerRight = row.getBoundingClientRect().right;
5851 var hidden = [];
5852 for (var i = pills.length - 1; i >= 1; i--) {
5853 var rect = pills[i].getBoundingClientRect();
5854 if (rect.right > containerRight + 2) {
5855 hidden.unshift(pills[i]);
5856 pills[i].style.display = 'none';
5857 } else {
5858 break;
5859 }
5860 }
5861
5862 if (hidden.length) {
5863 var chip = document.createElement('button');
5864 chip.type = 'button';
5865 chip.className = 'language-pill lang-overflow-chip';
5866 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
5867 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
5868 row.appendChild(chip);
5869 }
5870 });
5871 }
5872
5873 // Run after preview loads (preview panel populates language pills)
5874 var _origLoadPreviewCb = window.__previewLoaded;
5875 document.addEventListener('previewLoaded', collapseLanguagePills);
5876 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
5877 setTimeout(collapseLanguagePills, 400);
5878
5879 // ── Project history & output dir auto-set ──────────────────────────
5880 var wsOutputRoot = document.getElementById("ws-output-root");
5881 var wsScanCount = document.getElementById("ws-scan-count");
5882 var wsLastScan = document.getElementById("ws-last-scan");
5883 var historyBadge = document.getElementById("path-history-badge");
5884 var historyTimer = null;
5885
5886 var wsOutputLink = document.getElementById("ws-output-link");
5887 function syncStripOutputRoot() {
5888 var val = outputDirInput ? outputDirInput.value : "";
5889 var display = val || "project/sloc";
5890 if (wsOutputRoot) wsOutputRoot.textContent = display;
5891 if (wsOutputLink) wsOutputLink.dataset.folder = val;
5892 }
5893
5894 function autoSetOutputDir(projectPath) {
5895 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
5896 if (!projectPath || !projectPath.trim()) return;
5897 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
5898 outputDirInput.value = cleaned + "/sloc";
5899 syncStripOutputRoot();
5900 updateReview();
5901 }
5902
5903 var wsBranch = document.getElementById("ws-branch");
5904
5905 function fetchProjectHistory(projectPath) {
5906 if (!projectPath || !projectPath.trim()) {
5907 if (wsScanCount) wsScanCount.textContent = "—";
5908 if (wsLastScan) wsLastScan.textContent = "—";
5909 if (wsBranch) wsBranch.textContent = "—";
5910 if (historyBadge) historyBadge.style.display = "none";
5911 return;
5912 }
5913 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
5914 .then(function (r) { return r.ok ? r.json() : null; })
5915 .then(function (data) {
5916 if (!data) return;
5917 var countStr = data.scan_count > 0
5918 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
5919 : "never";
5920 var tsStr = data.last_scan_timestamp
5921 ? data.last_scan_timestamp.replace(" UTC","")
5922 : "—";
5923 if (wsScanCount) wsScanCount.textContent = countStr;
5924 if (wsLastScan) wsLastScan.textContent = tsStr;
5925 if (wsBranch) wsBranch.textContent = data.last_git_branch || "—";
5926 if (data.scan_count > 0) {
5927 if (historyBadge) {
5928 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
5929 historyBadge.textContent = data.scan_count + " previous scan" +
5930 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
5931 "Last: " + (data.last_scan_timestamp || "—") +
5932 " — " + (data.last_scan_code_lines ? Number(data.last_scan_code_lines).toLocaleString() : "?") + " code lines.";
5933 historyBadge.className = "path-history-badge found";
5934 historyBadge.style.display = "";
5935 }
5936 } else {
5937 if (historyBadge) historyBadge.style.display = "none";
5938 }
5939 })
5940 .catch(function () {});
5941 }
5942
5943 function onPathChange() {
5944 var val = pathInput ? pathInput.value : "";
5945 updateReportTitleFromPath();
5946 autoSetOutputDir(val);
5947 clearTimeout(historyTimer);
5948 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
5949 if (previewTimer) clearTimeout(previewTimer);
5950 previewTimer = setTimeout(loadPreview, 280);
5951 }
5952
5953 if (pathInput) {
5954 pathInput.addEventListener("input", onPathChange);
5955 }
5956
5957 if (outputDirInput) {
5958 outputDirInput.addEventListener("input", function () {
5959 outputDirInput.dataset.userEdited = "1";
5960 syncStripOutputRoot();
5961 updateReview();
5962 });
5963 }
5964
5965 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
5966 if (!node) return;
5967 node.addEventListener("input", function () {
5968 updateReview();
5969 if (previewTimer) clearTimeout(previewTimer);
5970 previewTimer = setTimeout(loadPreview, 280);
5971 });
5972 });
5973
5974 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
5975 var node = document.getElementById(id);
5976 if (node) node.addEventListener("change", updateReview);
5977 });
5978
5979 if (reportTitleInput) {
5980 reportTitleInput.addEventListener("input", function () {
5981 reportTitleTouched = reportTitleInput.value.trim().length > 0;
5982 updateReportTitleFromPath();
5983 updateReview();
5984 });
5985 }
5986
5987 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
5988 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
5989 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); });
5990 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); });
5991
5992 artifactCards.forEach(function (card) {
5993 card.addEventListener("click", function () {
5994 toggleArtifactCard(card);
5995 updateReview();
5996 });
5997 });
5998
5999 if (form && loading && submitButton) {
6000 form.addEventListener("submit", function () {
6001 submitButton.disabled = true;
6002 submitButton.textContent = "Scanning...";
6003 loading.classList.add("active");
6004 });
6005 }
6006
6007 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
6008 btn.addEventListener('click', function () {
6009 var folder = btn.getAttribute('data-folder') || btn.dataset.folder || '';
6010 if (!folder) return;
6011 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
6012 });
6013 });
6014
6015 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
6016 if (wsOutputLink) {
6017 wsOutputLink.addEventListener('click', function () {
6018 var folder = wsOutputLink.dataset.folder || '';
6019 if (!folder) return;
6020 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
6021 });
6022 }
6023
6024 loadSavedTheme();
6025 updateMixedPolicyUI();
6026 updatePythonDocstringUI();
6027 applyScanPreset();
6028 updatePresetDescriptions();
6029 applyArtifactPreset();
6030 updateReview();
6031 updateScrollProgress(); // initialise bar to 0% (step 1)
6032 window.addEventListener("scroll", updateScrollProgress, { passive: true });
6033 onPathChange(); // seed output dir, history badge, and preview from initial path
6034 loadPreview();
6035 updateStepNav(1);
6036
6037 // Restore step from URL hash on initial load (e.g., back-forward cache)
6038 (function() {
6039 var hashMatch = location.hash.match(/^#step([1-4])$/);
6040 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
6041 })();
6042
6043 (function randomizeWatermarks() {
6044 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
6045 if (!wms.length) return;
6046 var placed = [];
6047 function tooClose(top, left) {
6048 for (var i = 0; i < placed.length; i++) {
6049 var dt = Math.abs(placed[i][0] - top);
6050 var dl = Math.abs(placed[i][1] - left);
6051 if (dt < 16 && dl < 12) return true;
6052 }
6053 return false;
6054 }
6055 function pick(leftBand) {
6056 for (var attempt = 0; attempt < 50; attempt++) {
6057 var top = Math.random() * 88 + 2;
6058 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6059 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
6060 }
6061 var top = Math.random() * 88 + 2;
6062 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6063 placed.push([top, left]);
6064 return [top, left];
6065 }
6066 var half = Math.floor(wms.length / 2);
6067 wms.forEach(function (img, i) {
6068 var pos = pick(i < half);
6069 var size = Math.floor(Math.random() * 80 + 110);
6070 var rot = (Math.random() * 360).toFixed(1);
6071 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
6072 img.style.cssText = "width:" + size + "px;top:" + pos[0].toFixed(1) + "%;left:" + pos[1].toFixed(1) + "%;transform:rotate(" + rot + "deg);opacity:" + op + ";";
6073 });
6074 })();
6075
6076 (function spawnCodeParticles() {
6077 var container = document.getElementById('code-particles');
6078 if (!container) return;
6079 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'];
6080 for (var i = 0; i < 38; i++) {
6081 (function(idx) {
6082 var el = document.createElement('span');
6083 el.className = 'code-particle';
6084 el.textContent = snippets[idx % snippets.length];
6085 var left = Math.random() * 94 + 2;
6086 var top = Math.random() * 88 + 6;
6087 var dur = (Math.random() * 10 + 9).toFixed(1);
6088 var delay = (Math.random() * 18).toFixed(1);
6089 var rot = (Math.random() * 26 - 13).toFixed(1);
6090 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
6091 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
6092 container.appendChild(el);
6093 })(i);
6094 }
6095 })();
6096 })();
6097 </script>
6098 <script nonce="{{ csp_nonce }}">
6099 (function () {
6100 var raw = {{ prefill_json|safe }};
6101 if (!raw || typeof raw !== 'object' || !raw.path) return;
6102 function setVal(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
6103 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
6104 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
6105 setVal('path-input', raw.path || '');
6106 setVal('include-globs', raw.include_globs || '');
6107 setVal('exclude-globs', raw.exclude_globs || '');
6108 setVal('output-dir', raw.output_dir || '');
6109 setVal('report-title', raw.report_title || '');
6110 if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
6111 setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
6112 setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
6113 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
6114 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
6115 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
6116 if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
6117 setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
6118 setChecked('generate-html', raw.generate_html !== false);
6119 setChecked('generate-pdf', !!raw.generate_pdf);
6120 // Trigger dynamic UI updates after pre-fill.
6121 setTimeout(function () {
6122 var pathEl = document.getElementById('path-input');
6123 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
6124 var policyEl = document.getElementById('mixed-line-policy');
6125 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
6126 }, 80);
6127 })();
6128 </script>
6129 <footer class="site-footer">
6130 oxide-sloc v{{ version }} — local source line analysis workbench ·
6131 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
6132 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
6133 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
6134 </footer>
6135</body>
6136</html>
6137"##,
6138 ext = "html"
6139)]
6140struct IndexTemplate {
6141 version: &'static str,
6142 prefill_json: String,
6143 csp_nonce: String,
6144}
6145
6146#[derive(Template)]
6149#[template(
6150 source = r##"
6151<!doctype html>
6152<html lang="en">
6153<head>
6154 <meta charset="utf-8">
6155 <meta name="viewport" content="width=device-width, initial-scale=1">
6156 <title>OxideSLOC — Source Line Analysis Workbench</title>
6157 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
6158 <style nonce="{{ csp_nonce }}">
6159 :root {
6160 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
6161 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
6162 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
6163 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
6164 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
6165 }
6166 body.dark-theme {
6167 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
6168 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
6169 }
6170 *{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);}
6171 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
6172 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
6173 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
6174 .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;}
6175 @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));}}
6176 .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);}
6177 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
6178 .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));}
6179 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
6180 .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;}
6181 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
6182 .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;}
6183 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
6184 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
6185 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
6186 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
6187 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
6188 .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;}
6189 .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;}
6190 .page{max-width:1100px;margin:0 auto;padding:48px 24px 60px;position:relative;z-index:1;}
6191 .hero{text-align:center;margin-bottom:52px;}
6192 .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;}
6193 @keyframes logoBob{0%,100%{transform:translateY(0) scale(1);}40%{transform:translateY(-18px) scale(1.07);}60%{transform:translateY(-14px) scale(1.05);}}
6194 .hero-title{font-size:51px;font-weight:900;letter-spacing:-0.04em;margin:0 0 10px;
6195 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
6196 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
6197 animation:titleShimmer 4s linear infinite;}
6198 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
6199 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;}
6200 .hero-subtitle{font-size:18px;color:var(--muted);line-height:1.6;max-width:600px;margin:0 auto;animation:fadeSlideUp 0.9s ease both;}
6201 @keyframes fadeSlideUp{from{opacity:0;transform:translateY(18px);}to{opacity:1;transform:translateY(0);}}
6202 .action-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-bottom:32px;}
6203 @media(max-width:760px){.action-grid{grid-template-columns:1fr 1fr;}}
6204 @media(max-width:480px){.action-grid{grid-template-columns:1fr;}}
6205 .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;}
6206 .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;}
6207 @keyframes cardRise{from{opacity:0;transform:translateY(24px);}to{opacity:1;transform:translateY(0);}}
6208 .action-card:hover{transform:translateY(-6px) scale(1.012);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
6209 .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);}
6210 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
6211 .action-card-icon svg{width:26px;height:26px;stroke:currentColor;fill:none;stroke-width:2;}
6212 .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);}
6213 .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);}
6214 .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);}
6215 .action-card-title{font-size:20px;font-weight:850;letter-spacing:-0.02em;margin:0 0 8px;}
6216 .action-card-desc{font-size:14px;color:var(--muted);line-height:1.6;margin:0 0 20px;flex:1;}
6217 .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;}
6218 body.dark-theme .action-card-cta{color:var(--oxide);}
6219 .action-card.view .action-card-cta{color:var(--accent-2);}
6220 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
6221 .action-card.compare .action-card-cta{color:#7c3aed;}
6222 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
6223 .action-card:hover .action-card-cta{gap:12px;}
6224 .divider{height:1px;background:var(--line);margin:40px 0;}
6225 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:16px;}
6226 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
6227 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
6228 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:18px 20px;text-align:center;position:relative;cursor:default;
6229 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
6230 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
6231 .info-chip-val{font-size:22px;font-weight:900;color:var(--oxide);}
6232 body.dark-theme .info-chip-val{color:var(--oxide);}
6233 .info-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
6234 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
6235 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
6236 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
6237 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
6238 border:6px solid transparent;border-top-color:var(--text);}
6239 .info-chip:hover .info-chip-tip{display:block;}
6240 .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
6241 .site-footer a{color:var(--muted);}
6242 </style>
6243</head>
6244<body>
6245 <div class="background-watermarks" aria-hidden="true">
6246 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6247 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6248 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6249 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6250 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6251 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6252 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6253 </div>
6254 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
6255 <div class="top-nav">
6256 <div class="top-nav-inner">
6257 <a class="brand" href="/">
6258 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
6259 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Source line analysis workbench</div></div>
6260 </a>
6261 <div class="nav-right">
6262 <a class="nav-pill" href="/">Home</a>
6263 <a class="nav-pill" href="/view-reports">View Reports</a>
6264 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
6265 <div class="server-status-wrap">
6266 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
6267 <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>
6268 </div>
6269 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
6270 <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>
6271 <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>
6272 </button>
6273 </div>
6274 </div>
6275 </div>
6276
6277 <div class="page">
6278 <div class="hero">
6279 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
6280 <h1 class="hero-title">OxideSLOC</h1>
6281 <p class="hero-subtitle">A fast, self-contained source line analysis workbench. Count code, track history, and compare scan snapshots — no setup required.</p>
6282 </div>
6283
6284 <div class="action-grid">
6285 <a class="action-card scan" href="/scan-setup">
6286 <div class="action-card-icon">
6287 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
6288 </div>
6289 <div class="action-card-title">Scan Project</div>
6290 <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>
6291 <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>
6292 </a>
6293
6294 <a class="action-card view" href="/view-reports">
6295 <div class="action-card-icon">
6296 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
6297 </div>
6298 <div class="action-card-title">View Reports</div>
6299 <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>
6300 <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>
6301 </a>
6302
6303 <a class="action-card compare" href="/compare-scans">
6304 <div class="action-card-icon">
6305 <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>
6306 </div>
6307 <div class="action-card-title">Compare Scans</div>
6308 <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>
6309 <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>
6310 </a>
6311 </div>
6312
6313 <div class="divider"></div>
6314
6315 <div class="info-strip">
6316 <div class="info-chip">
6317 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
6318 <div class="info-chip-val">41</div>
6319 <div class="info-chip-label">Languages</div>
6320 </div>
6321 <div class="info-chip">
6322 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
6323 <div class="info-chip-val">100%</div>
6324 <div class="info-chip-label">Self-contained</div>
6325 </div>
6326 <div class="info-chip">
6327 <div class="info-chip-tip">Self-contained HTML reports with<br>light/dark theme — share without a server</div>
6328 <div class="info-chip-val">HTML</div>
6329 <div class="info-chip-label">Exportable reports</div>
6330 </div>
6331 <div class="info-chip">
6332 <div class="info-chip-tip">Detects .gitmodules and produces<br>per-submodule breakdowns automatically</div>
6333 <div class="info-chip-val">Git</div>
6334 <div class="info-chip-label">Submodule support</div>
6335 </div>
6336 <div class="info-chip">
6337 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
6338 <div class="info-chip-val">IEEE</div>
6339 <div class="info-chip-label">1045-1992</div>
6340 </div>
6341 </div>
6342 </div>
6343
6344 <footer class="site-footer">
6345 oxide-sloc — local source line analysis workbench ·
6346 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
6347 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
6348 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
6349 </footer>
6350
6351 <script nonce="{{ csp_nonce }}">
6352 (function () {
6353 var storageKey = 'oxide-sloc-theme';
6354 var body = document.body;
6355 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
6356 var toggle = document.getElementById('theme-toggle');
6357 if (toggle) toggle.addEventListener('click', function () {
6358 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
6359 body.classList.toggle('dark-theme', next === 'dark');
6360 try { localStorage.setItem(storageKey, next); } catch(e) {}
6361 });
6362 (function randomizeWatermarks() {
6363 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6364 if (!wms.length) return;
6365 var placed = [];
6366 function tooClose(top, left) {
6367 for (var i = 0; i < placed.length; i++) {
6368 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
6369 if (dt < 16 && dl < 12) return true;
6370 }
6371 return false;
6372 }
6373 function pick(leftBand) {
6374 for (var attempt = 0; attempt < 50; attempt++) {
6375 var top = Math.random() * 88 + 2;
6376 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6377 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
6378 }
6379 var top = Math.random() * 88 + 2;
6380 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6381 placed.push([top, left]); return [top, left];
6382 }
6383 var half = Math.floor(wms.length / 2);
6384 wms.forEach(function (img, i) {
6385 var pos = pick(i < half);
6386 var size = Math.floor(Math.random() * 100 + 120);
6387 var rot = (Math.random() * 360).toFixed(1);
6388 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
6389 img.style.cssText = 'width:' + size + 'px;top:' + pos[0].toFixed(1) + '%;left:' + pos[1].toFixed(1) + '%;transform:rotate(' + rot + 'deg);opacity:' + op + ';';
6390 });
6391 })();
6392
6393 (function spawnCodeParticles() {
6394 var container = document.getElementById('code-particles');
6395 if (!container) return;
6396 var snippets = [
6397 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
6398 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
6399 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
6400 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
6401 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
6402 ];
6403 var count = 38;
6404 for (var i = 0; i < count; i++) {
6405 (function(idx) {
6406 var el = document.createElement('span');
6407 el.className = 'code-particle';
6408 var text = snippets[idx % snippets.length];
6409 el.textContent = text;
6410 var left = Math.random() * 94 + 2;
6411 var top = Math.random() * 88 + 6;
6412 var dur = (Math.random() * 10 + 9).toFixed(1);
6413 var delay = (Math.random() * 18).toFixed(1);
6414 var rot = (Math.random() * 26 - 13).toFixed(1);
6415 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
6416 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;'
6417 + '--rot:' + rot + 'deg;--op:' + op + ';'
6418 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
6419 container.appendChild(el);
6420 })(i);
6421 }
6422 })();
6423 })();
6424 </script>
6425</body>
6426</html>
6427"##,
6428 ext = "html"
6429)]
6430struct SplashTemplate {
6431 csp_nonce: String,
6432}
6433
6434#[derive(Template)]
6437#[template(
6438 source = r##"
6439<!doctype html>
6440<html lang="en">
6441<head>
6442 <meta charset="utf-8">
6443 <meta name="viewport" content="width=device-width, initial-scale=1">
6444 <title>OxideSLOC — Start a Scan</title>
6445 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
6446 <style nonce="{{ csp_nonce }}">
6447 :root {
6448 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
6449 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
6450 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
6451 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
6452 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
6453 }
6454 body.dark-theme {
6455 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
6456 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
6457 }
6458 *{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);}
6459 .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);}
6460 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
6461 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
6462 .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));}
6463 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
6464 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
6465 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;}
6466 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
6467 .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;}
6468 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
6469 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
6470 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
6471 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
6472 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
6473 .page{max-width:960px;margin:0 auto;padding:40px 24px 64px;position:relative;z-index:1;}
6474 .page-header{text-align:center;margin-bottom:32px;}
6475 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
6476 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
6477 .breadcrumb{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted);margin-bottom:28px;}
6478 .breadcrumb a{color:var(--muted);text-decoration:none;} .breadcrumb a:hover{color:var(--oxide);}
6479 .breadcrumb svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2;}
6480 /* Cards */
6481 .option-grid{display:flex;flex-direction:column;gap:16px;}
6482 .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;}
6483 .option-card:hover{border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
6484 /* Two-column layout inside each card */
6485 .card-body{display:grid;grid-template-columns:1fr 240px;gap:24px;align-items:center;}
6486 .card-left{display:flex;align-items:flex-start;gap:16px;min-width:0;}
6487 .option-icon{width:46px;height:46px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex:0 0 auto;}
6488 .option-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
6489 .option-icon.new-scan{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 6px 18px rgba(184,80,40,0.28);}
6490 .option-icon.load-config{background:linear-gradient(135deg,#3b82f6,#1d4ed8);color:#fff;box-shadow:0 6px 18px rgba(59,130,246,0.28);}
6491 .option-icon.rescan{background:linear-gradient(135deg,#8b5cf6,#6d28d9);color:#fff;box-shadow:0 6px 18px rgba(139,92,246,0.28);}
6492 .card-text{min-width:0;}
6493 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 4px;}
6494 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
6495 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
6496 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
6497 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
6498 /* Right CTA column */
6499 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
6500 .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;}
6501 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
6502 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
6503 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
6504 body.dark-theme .btn-secondary{color:var(--oxide);}
6505 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
6506 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
6507 /* File input overlay — must be full-width so it aligns with other card-right buttons */
6508 .file-input-wrap{position:relative;width:100%;}
6509 .file-input-wrap .btn{width:100%;}
6510 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
6511 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
6512 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
6513 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
6514 .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;}
6515 @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));}}
6516 /* Recent list (card 3 — full-width section below header) */
6517 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
6518 .recent-list{display:flex;flex-direction:column;gap:8px;}
6519 .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;}
6520 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
6521 .recent-item-info{flex:1;min-width:0;}
6522 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
6523 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
6524 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
6525 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
6526 .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
6527 .site-footer a{color:var(--muted);}
6528 @media(max-width:680px){
6529 .card-body{grid-template-columns:1fr;}
6530 .card-right{flex-direction:row;flex-wrap:wrap;}
6531 .btn{flex:1;}
6532 }
6533 </style>
6534</head>
6535<body>
6536 <div class="background-watermarks" aria-hidden="true">
6537 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6538 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6539 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6540 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6541 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6542 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6543 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6544 </div>
6545 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
6546 <div class="top-nav">
6547 <div class="top-nav-inner">
6548 <a class="brand" href="/">
6549 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
6550 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Source line analysis workbench</div></div>
6551 </a>
6552 <div class="nav-right">
6553 <a class="nav-pill" href="/">Home</a>
6554 <a class="nav-pill" href="/view-reports">View Reports</a>
6555 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
6556 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
6557 <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>
6558 <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>
6559 </button>
6560 </div>
6561 </div>
6562 </div>
6563
6564 <div class="page">
6565 <div class="breadcrumb">
6566 <a href="/">Home</a>
6567 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
6568 <span>Scan Setup</span>
6569 </div>
6570
6571 <div class="page-header">
6572 <h1>How would you like to scan?</h1>
6573 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
6574 </div>
6575
6576 <div class="option-grid">
6577
6578 <!-- Option 1: New scan -->
6579 <div class="option-card">
6580 <div class="card-body">
6581 <div class="card-left">
6582 <div class="option-icon new-scan">
6583 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
6584 </div>
6585 <div class="card-text">
6586 <div class="option-title">Start a new scan</div>
6587 <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>
6588 <ul class="feature-list">
6589 <li>Live project scope preview before you run</li>
6590 <li>4 line-counting modes with interactive examples</li>
6591 <li>HTML, PDF, and JSON output — your choice</li>
6592 <li>IEEE 1045-1992 compliant physical SLOC counting</li>
6593 </ul>
6594 </div>
6595 </div>
6596 <div class="card-right">
6597 <a class="btn btn-primary" href="/scan">
6598 Configure & scan
6599 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
6600 </a>
6601 <p class="card-tip">Full 4-step setup · all options</p>
6602 </div>
6603 </div>
6604 </div>
6605
6606 <!-- Option 2: Load from config file -->
6607 <div class="option-card">
6608 <div class="card-body">
6609 <div class="card-left">
6610 <div class="option-icon load-config">
6611 <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>
6612 </div>
6613 <div class="card-text">
6614 <div class="option-title">Load a saved config</div>
6615 <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>
6616 <ul class="feature-list">
6617 <li>All 15 settings restored from the file</li>
6618 <li>Fully editable — change path or output dir</li>
6619 <li>Works with any scan-config.json</li>
6620 </ul>
6621 </div>
6622 </div>
6623 <div class="card-right">
6624 <div class="file-input-wrap">
6625 <button class="btn btn-secondary" id="load-config-btn" type="button">
6626 <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>
6627 Choose config file
6628 </button>
6629 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
6630 </div>
6631 <p class="card-tip" id="config-file-name">Exported after every scan</p>
6632 </div>
6633 </div>
6634 </div>
6635
6636 <!-- Option 3: Re-scan recent project -->
6637 <div class="option-card" id="recent-card">
6638 <div class="card-body">
6639 <div class="card-left" style="grid-column:1/-1;">
6640 <div class="option-icon rescan">
6641 <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>
6642 </div>
6643 <div class="card-text">
6644 <div class="option-title">Re-scan a recent project</div>
6645 <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>
6646 <ul class="feature-list">
6647 <li>All 15+ settings restored from the saved config</li>
6648 <li>Path and output dir are editable before running</li>
6649 <li>Only scans with a saved config appear here</li>
6650 </ul>
6651 </div>
6652 </div>
6653 </div>
6654 <div class="section-divider"></div>
6655 <div class="recent-list" id="recent-list">
6656 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
6657 </div>
6658 </div>
6659
6660 </div>
6661 </div>
6662
6663 <footer class="site-footer">
6664 oxide-sloc — local source line analysis workbench ·
6665 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
6666 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
6667 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
6668 </footer>
6669
6670 <script nonce="{{ csp_nonce }}">
6671 (function () {
6672 var storageKey = 'oxide-sloc-theme';
6673 var body = document.body;
6674 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
6675 var toggle = document.getElementById('theme-toggle');
6676 if (toggle) toggle.addEventListener('click', function () {
6677 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
6678 body.classList.toggle('dark-theme', next === 'dark');
6679 try { localStorage.setItem(storageKey, next); } catch(e) {}
6680 });
6681
6682 (function randomizeWatermarks() {
6683 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6684 if (!wms.length) return;
6685 var placed = [];
6686 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; }
6687 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]; }
6688 var half = Math.floor(wms.length / 2);
6689 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 + ';'; });
6690 })();
6691 (function spawnCodeParticles() {
6692 var container = document.getElementById('code-particles');
6693 if (!container) return;
6694 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'];
6695 var count = 38;
6696 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); }
6697 })();
6698
6699 // Recent scans data injected from server
6700 var recentScans = {{ recent_scans_json|safe }};
6701
6702 function configToParams(cfg) {
6703 var p = new URLSearchParams();
6704 p.set('prefilled', '1');
6705 if (cfg.path) p.set('path', cfg.path);
6706 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
6707 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
6708 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
6709 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
6710 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
6711 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
6712 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
6713 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
6714 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
6715 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
6716 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
6717 if (cfg.report_title) p.set('report_title', cfg.report_title);
6718 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
6719 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
6720 return p;
6721 }
6722
6723 // Build recent scan list (capped at 3 visible entries)
6724 var list = document.getElementById('recent-list');
6725 var noNote = document.getElementById('no-recent-note');
6726 var hasAny = false;
6727 var MAX_RECENT = 3;
6728 if (Array.isArray(recentScans)) {
6729 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
6730 var shown = 0;
6731 validEntries.forEach(function (entry) {
6732 if (shown >= MAX_RECENT) return;
6733 shown++;
6734 hasAny = true;
6735 var item = document.createElement('div');
6736 item.className = 'recent-item';
6737 item.title = 'Restore all settings and open wizard';
6738 item.innerHTML =
6739 '<div class="recent-item-info">' +
6740 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
6741 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' · ' + escHtml(entry.timestamp || '') + '</div>' +
6742 '</div>' +
6743 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
6744 item.addEventListener('click', function () {
6745 var params = configToParams(entry.config);
6746 window.location.href = '/scan?' + params.toString();
6747 });
6748 list.appendChild(item);
6749 });
6750 if (validEntries.length > MAX_RECENT) {
6751 var moreEl = document.createElement('div');
6752 moreEl.className = 'recent-more-link';
6753 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
6754 list.appendChild(moreEl);
6755 }
6756 }
6757 if (hasAny && noNote) noNote.style.display = 'none';
6758
6759 // Config file loader
6760 var fileInput = document.getElementById('config-file-input');
6761 var fileName = document.getElementById('config-file-name');
6762 if (fileInput) {
6763 fileInput.addEventListener('change', function () {
6764 var file = fileInput.files && fileInput.files[0];
6765 if (!file) return;
6766 if (fileName) fileName.textContent = '✓ ' + file.name;
6767 var reader = new FileReader();
6768 reader.onload = function (e) {
6769 try {
6770 var cfg = JSON.parse(e.target.result);
6771 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
6772 var params = configToParams(cfg);
6773 window.location.href = '/scan?' + params.toString();
6774 } catch (err) {
6775 alert('Could not parse config file: ' + err.message);
6776 }
6777 };
6778 reader.readAsText(file);
6779 });
6780 }
6781
6782 function escHtml(s) {
6783 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
6784 }
6785 })();
6786 </script>
6787</body>
6788</html>
6789"##,
6790 ext = "html"
6791)]
6792struct ScanSetupTemplate {
6793 recent_scans_json: String,
6794 csp_nonce: String,
6795}
6796
6797#[derive(Template)]
6798#[template(
6799 source = r##"
6800<!doctype html>
6801<html lang="en">
6802<head>
6803 <meta charset="utf-8">
6804 <meta name="viewport" content="width=device-width, initial-scale=1">
6805 <title>OxideSLOC | {{ report_title }} | Report</title>
6806 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
6807 <style nonce="{{ csp_nonce }}">
6808 :root {
6809 --radius: 18px;
6810 --bg: #f5efe8;
6811 --surface: rgba(255,255,255,0.82);
6812 --surface-2: #fbf7f2;
6813 --surface-3: #efe6dc;
6814 --line: #e6d0bf;
6815 --line-strong: #dcb89f;
6816 --text: #43342d;
6817 --muted: #7b675b;
6818 --muted-2: #a08777;
6819 --nav: #b85d33;
6820 --nav-2: #7a371b;
6821 --accent: #6f9bff;
6822 --accent-2: #4a78ee;
6823 --oxide: #d37a4c;
6824 --oxide-2: #b35428;
6825 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
6826 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
6827 --success-bg: #e8f5ed;
6828 --success-text: #1a8f47;
6829 --info-bg: #eef3ff;
6830 --info-text: #4467d8;
6831 }
6832
6833 body.dark-theme {
6834 --bg: #1b1511;
6835 --surface: #261c17;
6836 --surface-2: #2d221d;
6837 --surface-3: #372922;
6838 --line: #524238;
6839 --line-strong: #6c5649;
6840 --text: #f5ece6;
6841 --muted: #c7b7aa;
6842 --muted-2: #aa9485;
6843 --nav: #b85d33;
6844 --nav-2: #7a371b;
6845 --accent: #6f9bff;
6846 --accent-2: #4a78ee;
6847 --oxide: #d37a4c;
6848 --oxide-2: #b35428;
6849 --shadow: 0 18px 42px rgba(0,0,0,0.28);
6850 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
6851 --success-bg: #163927;
6852 --success-text: #8fe2a8;
6853 --info-bg: #1c2847;
6854 --info-text: #a9c1ff;
6855 }
6856
6857 * { box-sizing: border-box; }
6858 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); }
6859 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
6860 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
6861 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
6862 .top-nav, .page { position: relative; z-index: 2; }
6863 .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); }
6864 .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; }
6865 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
6866 .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)); }
6867 .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; }
6868 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
6869 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
6870 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
6871 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
6872 .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; }
6873 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
6874 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; }
6875 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
6876 .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); }
6877 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
6878 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
6879 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
6880 .theme-toggle .icon-sun { display:none; }
6881 body.dark-theme .theme-toggle .icon-sun { display:block; }
6882 body.dark-theme .theme-toggle .icon-moon { display:none; }
6883 .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; }
6884 .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;}
6885 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
6886 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
6887 .hero, .panel { padding: 22px; }
6888 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
6889 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
6890 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
6891 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
6892 .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; }
6893 .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
6894 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
6895 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
6896 .delta-chip.pos { background:#e6f4ea; color:#1e7e34; }
6897 .delta-chip.neg { background:#fde8e8; color:#b91c1c; }
6898 .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
6899 .delta-card-inline { background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:6px 12px; text-align:center; min-width:80px; }
6900 .delta-card-val { font-size:16px; font-weight:800; }
6901 .delta-card-val.pos { color:#1e7e34; }
6902 .delta-card-val.neg { color:#b91c1c; }
6903 .delta-card-val.mod { color:#b35428; }
6904 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
6905 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
6906 .compare-ts { font-size:13px; color:var(--muted); }
6907 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
6908 .compare-arrow { color: var(--muted); }
6909 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
6910 .action-card { padding: 12px 14px 14px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); display:flex; flex-direction:column; align-items:center; justify-content:center; }
6911 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
6912 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
6913 .button, .copy-button {
6914 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;
6915 }
6916 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
6917 .path-list { display: grid; grid-template-columns: 1fr 0.6fr 1.4fr; gap: 10px; margin-top: 18px; }
6918 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
6919 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
6920 .path-item strong { display: block; margin-bottom: 6px; }
6921 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
6922 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
6923 .path-subitem { flex: 1; }
6924 .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); }
6925 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); }
6926 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
6927 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
6928 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
6929 th:first-child, td:first-child { width: 28%; }
6930 th { color: var(--muted); font-weight: 700; }
6931 tr:last-child td { border-bottom: none; }
6932 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
6933 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
6934 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
6935 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
6936 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
6937 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
6938 .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; }
6939 .soft-chip.success { background: var(--success-bg); color: var(--success-text); }
6940 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
6941 .muted { color: var(--muted); }
6942 .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; }
6943 .site-footer a { color: var(--muted-2); font-weight: 700; text-decoration: none; }
6944 .site-footer a:hover { color: var(--text); text-decoration: underline; }
6945 .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; }
6946 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
6947 .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; }
6948 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
6949 /* Submodule panel */
6950 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
6951 /* Metrics tables stack */
6952 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
6953 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
6954 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
6955 .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)); }
6956 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
6957 /* Metrics table */
6958 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
6959 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
6960 .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; }
6961 .metrics-table thead th:not(:first-child) { text-align: right; }
6962 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
6963 .metrics-table tbody tr:last-child td { border-bottom: none; }
6964 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
6965 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
6966 .metrics-table tbody tr:hover td { background: var(--surface-2); }
6967 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
6968 .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; }
6969 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
6970 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
6971 .mt-val-pos { color: #1e7e34; font-weight: 700; }
6972 .mt-val-neg { color: #b91c1c; font-weight: 700; }
6973 .mt-val-zero { color: var(--muted); }
6974 .mt-val-mod { color: var(--oxide-2); }
6975 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
6976 @media (max-width: 1180px) {
6977 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
6978 .nav-project-slot, .nav-status { justify-content:flex-start; }
6979 .hero-top { flex-direction: column; }
6980 }
6981 .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;}
6982 @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));}}
6983 </style>
6984</head>
6985<body>
6986 <div class="background-watermarks" aria-hidden="true">
6987 <img src="/images/logo/logo-text.png" alt="" />
6988 <img src="/images/logo/logo-text.png" alt="" />
6989 <img src="/images/logo/logo-text.png" alt="" />
6990 <img src="/images/logo/logo-text.png" alt="" />
6991 <img src="/images/logo/logo-text.png" alt="" />
6992 <img src="/images/logo/logo-text.png" alt="" />
6993 <img src="/images/logo/logo-text.png" alt="" />
6994 <img src="/images/logo/logo-text.png" alt="" />
6995 <img src="/images/logo/logo-text.png" alt="" />
6996 <img src="/images/logo/logo-text.png" alt="" />
6997 <img src="/images/logo/logo-text.png" alt="" />
6998 <img src="/images/logo/logo-text.png" alt="" />
6999 <img src="/images/logo/logo-text.png" alt="" />
7000 <img src="/images/logo/logo-text.png" alt="" />
7001 </div>
7002 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7003 <div class="top-nav">
7004 <div class="top-nav-inner">
7005 <a class="brand" href="/">
7006 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
7007 <div class="brand-copy">
7008 <div class="brand-title">OxideSLOC</div>
7009 <div class="brand-subtitle">Local analysis workbench</div>
7010 </div>
7011 </a>
7012 <div class="nav-project-slot">
7013 <div class="nav-project-pill"><span class="nav-project-label">Project</span><span class="nav-project-value">{{ report_title }}</span></div>
7014 </div>
7015 <div class="nav-status">
7016 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
7017 <a class="nav-pill" href="/view-reports" style="text-decoration:none;">View Reports</a>
7018 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
7019 <div class="server-status-wrap">
7020 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
7021 <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>
7022 </div>
7023 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
7024 <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>
7025 <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>
7026 </button>
7027 </div>
7028 </div>
7029 </div>
7030
7031 <div class="page">
7032 <section class="hero">
7033 <div class="hero-top">
7034 <div>
7035 <div class="soft-chip success">Run finished successfully</div>
7036 <h1 class="hero-title">{{ report_title }}</h1>
7037 <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>
7038 </div>
7039 <div class="hero-quick-actions">
7040 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
7041 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
7042 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
7043 </div>
7044 </div>
7045
7046 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
7047 <div class="compare-banner">
7048 <div class="compare-banner-body">
7049 <div class="compare-banner-meta">
7050 <span class="compare-label">Previous scan</span>
7051 <span class="compare-ts">{{ prev_ts }}</span>
7052 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
7053 {% if let Some(prev_code) = prev_run_code_lines %}
7054 <div class="compare-banner-stats" style="margin-top:4px;">
7055 <span>Code before: <strong>{{ prev_code }}</strong></span>
7056 <span class="compare-arrow">→</span>
7057 <span>Code now: <strong>{{ code_lines }}</strong></span>
7058 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
7059 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−{{ removed }} removed</span>{% endif %}
7060 </div>
7061 {% endif %}
7062 </div>
7063 {% if delta_lines_added.is_some() %}
7064 <div class="delta-cards-inline">
7065 <div class="delta-card-inline">
7066 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
7067 <div class="delta-card-lbl">lines added</div>
7068 </div>
7069 <div class="delta-card-inline">
7070 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v }}{% else %}—{% endif %}</div>
7071 <div class="delta-card-lbl">lines removed</div>
7072 </div>
7073 <div class="delta-card-inline">
7074 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
7075 <div class="delta-card-lbl">unmodified lines</div>
7076 </div>
7077 <div class="delta-card-inline">
7078 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
7079 <div class="delta-card-lbl">files modified</div>
7080 </div>
7081 <div class="delta-card-inline">
7082 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
7083 <div class="delta-card-lbl">files added</div>
7084 </div>
7085 <div class="delta-card-inline">
7086 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
7087 <div class="delta-card-lbl">files removed</div>
7088 </div>
7089 <div class="delta-card-inline">
7090 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
7091 <div class="delta-card-lbl">files unchanged</div>
7092 </div>
7093 </div>
7094 {% else %}
7095 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
7096 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
7097 </p>
7098 {% endif %}
7099 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
7100 </div>
7101 </div>
7102 {% endif %}{% endif %}
7103
7104 <div class="action-grid">
7105 <div class="action-card">
7106 <h3>HTML report</h3>
7107 <div class="action-buttons">
7108 {% match html_url %}
7109 {% when Some with (url) %}
7110 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
7111 {% when None %}{% endmatch %}
7112 {% match html_download_url %}
7113 {% when Some with (url) %}
7114 <a class="button secondary" href="{{ url }}">Download HTML</a>
7115 {% when None %}{% endmatch %}
7116 {% match html_path %}
7117 {% when Some with (_path) %}{% when None %}{% endmatch %}
7118 </div>
7119 </div>
7120 <div class="action-card">
7121 <h3>PDF report</h3>
7122 <div class="action-buttons">
7123 {% match pdf_url %}
7124 {% when Some with (url) %}
7125 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open PDF</a>
7126 {% when None %}{% endmatch %}
7127 {% match pdf_download_url %}
7128 {% when Some with (url) %}
7129 <a class="button secondary" href="{{ url }}">Download PDF</a>
7130 {% when None %}{% endmatch %}
7131 {% match pdf_path %}
7132 {% when Some with (_path) %}{% when None %}{% endmatch %}
7133 </div>
7134 </div>
7135 <div class="action-card">
7136 <h3>JSON result</h3>
7137 <div class="action-buttons">
7138 {% match json_url %}
7139 {% when Some with (url) %}
7140 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
7141 {% when None %}{% endmatch %}
7142 {% match json_download_url %}
7143 {% when Some with (url) %}
7144 <a class="button secondary" href="{{ url }}">Download JSON</a>
7145 {% when None %}{% endmatch %}
7146 {% match json_path %}
7147 {% when Some with (_path) %}{% when None %}
7148 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
7149 {% endmatch %}
7150 </div>
7151 </div>
7152 <div class="action-card">
7153 <h3>Scan config</h3>
7154 <div class="action-buttons">
7155 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
7156 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
7157 </div>
7158 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
7159 </div>
7160 </div>
7161 {% if !submodule_rows.is_empty() %}
7162 <div class="submodule-panel">
7163 <div class="toolbar-row">
7164 <div>
7165 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
7166 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
7167 </div>
7168 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
7169 </div>
7170 <div style="overflow:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
7171 <table style="width:100%;border-collapse:collapse;font-size:14px;">
7172 <thead>
7173 <tr>
7174 <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>
7175 <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>
7176 <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>
7177 <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>
7178 <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>
7179 <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>
7180 <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>
7181 <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>
7182 </tr>
7183 </thead>
7184 <tbody>
7185 {% for row in submodule_rows %}
7186 <tr>
7187 <td style="padding:10px 14px;border-bottom:1px solid var(--line);font-weight:700;"><strong>{{ row.name }}</strong></td>
7188 <td style="padding:10px 14px;border-bottom:1px solid var(--line);"><code style="font-size:12px;">{{ row.relative_path }}</code></td>
7189 <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.files_analyzed }}</td>
7190 <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.total_physical_lines }}</td>
7191 <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.code_lines }}</td>
7192 <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.comment_lines }}</td>
7193 <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.blank_lines }}</td>
7194 <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>
7195 </tr>
7196 {% endfor %}
7197 </tbody>
7198 </table>
7199 </div>
7200 </div>
7201 {% endif %}
7202
7203 <div class="metrics-tables-stack">
7204
7205 <div class="metrics-table-wrap">
7206 <div class="metrics-table-title">Files</div>
7207 <table class="metrics-table">
7208 <thead>
7209 <tr>
7210 <th>Metric</th>
7211 <th>This Run</th>
7212 <th>Previous</th>
7213 <th>Change</th>
7214 </tr>
7215 </thead>
7216 <tbody>
7217 <tr>
7218 <td>Files analyzed</td>
7219 <td class="mt-val-large">{{ files_analyzed }}</td>
7220 <td>{{ prev_fa_str }}</td>
7221 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
7222 </tr>
7223 <tr>
7224 <td>Files skipped</td>
7225 <td>{{ files_skipped }}</td>
7226 <td>{{ prev_fs_str }}</td>
7227 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
7228 </tr>
7229 <tr>
7230 <td>Files modified</td>
7231 <td class="mt-val-na">—</td>
7232 <td class="mt-val-na">—</td>
7233 <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>
7234 </tr>
7235 <tr>
7236 <td>Files unchanged</td>
7237 <td class="mt-val-na">—</td>
7238 <td class="mt-val-na">—</td>
7239 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
7240 </tr>
7241 </tbody>
7242 </table>
7243 </div>
7244
7245 <div class="metrics-table-wrap">
7246 <div class="metrics-table-title">Line Counts</div>
7247 <table class="metrics-table">
7248 <thead>
7249 <tr>
7250 <th>Metric</th>
7251 <th>This Run</th>
7252 <th>Previous</th>
7253 <th>Change</th>
7254 </tr>
7255 </thead>
7256 <tbody>
7257 <tr>
7258 <td>Physical lines</td>
7259 <td class="mt-val-large">{{ physical_lines }}</td>
7260 <td>{{ prev_pl_str }}</td>
7261 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
7262 </tr>
7263 <tr>
7264 <td>Code lines</td>
7265 <td class="mt-val-large">{{ code_lines }}</td>
7266 <td>{{ prev_cl_str }}</td>
7267 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
7268 </tr>
7269 <tr>
7270 <td>Comment lines</td>
7271 <td>{{ comment_lines }}</td>
7272 <td>{{ prev_cml_str }}</td>
7273 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
7274 </tr>
7275 <tr>
7276 <td>Blank lines</td>
7277 <td>{{ blank_lines }}</td>
7278 <td>{{ prev_bl_str }}</td>
7279 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
7280 </tr>
7281 <tr>
7282 <td>Mixed (separate)</td>
7283 <td>{{ mixed_lines }}</td>
7284 <td class="mt-val-na">—</td>
7285 <td class="mt-val-na">—</td>
7286 </tr>
7287 </tbody>
7288 </table>
7289 </div>
7290
7291 <div class="metrics-tables-lower">
7292 <div class="metrics-table-wrap">
7293 <div class="metrics-table-title">Code Structure</div>
7294 <table class="metrics-table">
7295 <thead>
7296 <tr>
7297 <th>Metric</th>
7298 <th>This Run</th>
7299 </tr>
7300 </thead>
7301 <tbody>
7302 <tr>
7303 <td>Functions</td>
7304 <td>{{ functions }}</td>
7305 </tr>
7306 <tr>
7307 <td>Classes / Types</td>
7308 <td>{{ classes }}</td>
7309 </tr>
7310 <tr>
7311 <td>Variables</td>
7312 <td>{{ variables }}</td>
7313 </tr>
7314 <tr>
7315 <td>Imports</td>
7316 <td>{{ imports }}</td>
7317 </tr>
7318 </tbody>
7319 </table>
7320 </div>
7321
7322 <div class="metrics-table-wrap">
7323 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
7324 <table class="metrics-table">
7325 <thead>
7326 <tr>
7327 <th>Metric</th>
7328 <th>Change</th>
7329 </tr>
7330 </thead>
7331 <tbody>
7332 <tr>
7333 <td>Lines added</td>
7334 <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>
7335 </tr>
7336 <tr>
7337 <td>Lines removed</td>
7338 <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>
7339 </tr>
7340 <tr>
7341 <td>Lines modified (net)</td>
7342 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
7343 </tr>
7344 <tr>
7345 <td>Lines unmodified</td>
7346 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
7347 </tr>
7348 </tbody>
7349 </table>
7350 </div>
7351 </div>
7352
7353 </div>
7354
7355 <div class="path-list">
7356 <div class="path-item">
7357 <div class="path-item-label">Project path</div>
7358 <code>{{ project_path }}</code>
7359 </div>
7360 <div class="path-item">
7361 <div class="path-item-label">Git branch</div>
7362 {% if let Some(branch) = git_branch %}
7363 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
7364 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
7365 {% else %}
7366 <code style="color:var(--muted)">—</code>
7367 {% endif %}
7368 </div>
7369 <div class="path-item path-item-split">
7370 <div class="path-subitem">
7371 <div class="path-item-label">Output folder</div>
7372 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
7373 </div>
7374 <div class="path-subitem" style="border-top:1px solid var(--line);padding-top:8px;margin-top:8px;">
7375 <div class="path-item-label">Run ID</div>
7376 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
7377 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
7378 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
7379 </div>
7380 </div>
7381 </div>
7382 </div>
7383 </section>
7384
7385 <section class="panel" style="margin-bottom: 18px;">
7386 <div class="toolbar-row">
7387 <div>
7388 <h2>Language breakdown</h2>
7389 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
7390 </div>
7391 </div>
7392 <table>
7393 <thead>
7394 <tr>
7395 <th>Language</th>
7396 <th>Files</th>
7397 <th>Physical</th>
7398 <th>Code</th>
7399 <th>Comments</th>
7400 <th>Blank</th>
7401 <th>Mixed</th>
7402 <th>Functions</th>
7403 <th>Classes</th>
7404 <th>Variables</th>
7405 <th>Imports</th>
7406 </tr>
7407 </thead>
7408 <tbody>
7409 {% for row in language_rows %}
7410 <tr>
7411 <td>{{ row.language }}</td>
7412 <td>{{ row.files }}</td>
7413 <td>{{ row.physical }}</td>
7414 <td>{{ row.code }}</td>
7415 <td>{{ row.comments }}</td>
7416 <td>{{ row.blank }}</td>
7417 <td>{{ row.mixed }}</td>
7418 <td>{{ row.functions }}</td>
7419 <td>{{ row.classes }}</td>
7420 <td>{{ row.variables }}</td>
7421 <td>{{ row.imports }}</td>
7422 </tr>
7423 {% endfor %}
7424 </tbody>
7425 </table>
7426 </section>
7427
7428 </div>
7429
7430 <script nonce="{{ csp_nonce }}">
7431 (function () {
7432 var body = document.body;
7433 var themeToggle = document.getElementById('theme-toggle');
7434 var storageKey = 'oxide-sloc-theme';
7435
7436 function applyTheme(theme) {
7437 body.classList.toggle('dark-theme', theme === 'dark');
7438 }
7439
7440 function loadSavedTheme() {
7441 try {
7442 var saved = localStorage.getItem(storageKey);
7443 if (saved === 'dark' || saved === 'light') {
7444 applyTheme(saved);
7445 }
7446 } catch (e) {}
7447 }
7448
7449 if (themeToggle) {
7450 themeToggle.addEventListener('click', function () {
7451 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
7452 applyTheme(nextTheme);
7453 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
7454 });
7455 }
7456
7457 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
7458 button.addEventListener('click', function () {
7459 var value = button.getAttribute('data-copy-value') || '';
7460 if (!value) return;
7461 if (navigator.clipboard && navigator.clipboard.writeText) {
7462 navigator.clipboard.writeText(value).catch(function () {});
7463 }
7464 });
7465 });
7466
7467 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
7468 btn.addEventListener('click', function () {
7469 var folder = btn.getAttribute('data-folder') || '';
7470 if (!folder) return;
7471 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
7472 });
7473 });
7474
7475 loadSavedTheme();
7476
7477 (function randomizeWatermarks() {
7478 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
7479 if (!wms.length) return;
7480 var placed = [];
7481 function tooClose(top, left) {
7482 for (var i = 0; i < placed.length; i++) {
7483 var dt = Math.abs(placed[i][0] - top);
7484 var dl = Math.abs(placed[i][1] - left);
7485 if (dt < 20 && dl < 18) return true;
7486 }
7487 return false;
7488 }
7489 function pick(leftBand) {
7490 for (var attempt = 0; attempt < 50; attempt++) {
7491 var top = Math.random() * 85 + 5;
7492 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
7493 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
7494 }
7495 var top = Math.random() * 85 + 5;
7496 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
7497 placed.push([top, left]);
7498 return [top, left];
7499 }
7500 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
7501 var half = Math.floor(wms.length / 2);
7502 wms.forEach(function (img, i) {
7503 var pos = pick(i < half);
7504 var size = Math.floor(Math.random() * 100 + 160);
7505 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
7506 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
7507 img.style.cssText = "width:" + size + "px;top:" + pos[0].toFixed(1) + "%;left:" + pos[1].toFixed(1) + "%;transform:rotate(" + rot.toFixed(1) + "deg);opacity:" + op + ";";
7508 });
7509 })();
7510
7511 (function spawnCodeParticles() {
7512 var container = document.getElementById('code-particles');
7513 if (!container) return;
7514 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'];
7515 for (var i = 0; i < 38; i++) {
7516 (function(idx) {
7517 var el = document.createElement('span');
7518 el.className = 'code-particle';
7519 el.textContent = snippets[idx % snippets.length];
7520 var left = Math.random() * 94 + 2;
7521 var top = Math.random() * 88 + 6;
7522 var dur = (Math.random() * 10 + 9).toFixed(1);
7523 var delay = (Math.random() * 18).toFixed(1);
7524 var rot = (Math.random() * 26 - 13).toFixed(1);
7525 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7526 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
7527 container.appendChild(el);
7528 })(i);
7529 }
7530 })();
7531 })();
7532 </script>
7533 <footer class="site-footer">
7534 oxide-sloc — local source line analysis workbench ·
7535 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7536 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7537 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7538 </footer>
7539</body>
7540</html>
7541"##,
7542 ext = "html"
7543)]
7544struct ResultTemplate {
7545 report_title: String,
7546 project_path: String,
7547 output_dir: String,
7548 run_id: String,
7549 files_analyzed: u64,
7550 files_skipped: u64,
7551 physical_lines: u64,
7552 code_lines: u64,
7553 comment_lines: u64,
7554 blank_lines: u64,
7555 mixed_lines: u64,
7556 functions: u64,
7557 classes: u64,
7558 variables: u64,
7559 imports: u64,
7560 html_url: Option<String>,
7561 pdf_url: Option<String>,
7562 json_url: Option<String>,
7563 html_download_url: Option<String>,
7564 pdf_download_url: Option<String>,
7565 json_download_url: Option<String>,
7566 html_path: Option<String>,
7567 pdf_path: Option<String>,
7568 json_path: Option<String>,
7569 language_rows: Vec<LanguageSummaryRow>,
7570 prev_run_id: Option<String>,
7571 prev_run_timestamp: Option<String>,
7572 prev_run_code_lines: Option<u64>,
7573 prev_fa_str: String,
7575 prev_fs_str: String,
7576 prev_pl_str: String,
7577 prev_cl_str: String,
7578 prev_cml_str: String,
7579 prev_bl_str: String,
7580 delta_fa_str: String,
7582 delta_fa_class: String,
7583 delta_fs_str: String,
7584 delta_fs_class: String,
7585 delta_pl_str: String,
7586 delta_pl_class: String,
7587 delta_cl_str: String,
7588 delta_cl_class: String,
7589 delta_cml_str: String,
7590 delta_cml_class: String,
7591 delta_bl_str: String,
7592 delta_bl_class: String,
7593 delta_lines_added: Option<i64>,
7595 delta_lines_removed: Option<i64>,
7596 delta_lines_net_str: String,
7597 delta_lines_net_class: String,
7598 delta_files_added: Option<usize>,
7599 delta_files_removed: Option<usize>,
7600 delta_files_modified: Option<usize>,
7601 delta_files_unchanged: Option<usize>,
7602 delta_unmodified_lines: Option<u64>,
7603 git_branch: Option<String>,
7605 git_commit: Option<String>,
7606 git_author: Option<String>,
7607 prev_scan_count: usize,
7609 current_scan_number: usize,
7610 submodule_rows: Vec<SubmoduleRow>,
7612 scan_config_url: String,
7613 csp_nonce: String,
7614}
7615
7616#[derive(Template)]
7617#[template(
7618 source = r##"
7619<!doctype html>
7620<html lang="en">
7621<head>
7622 <meta charset="utf-8">
7623 <meta name="viewport" content="width=device-width, initial-scale=1">
7624 <title>OxideSLOC | Error</title>
7625 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7626 <style nonce="{{ csp_nonce }}">
7627 :root {
7628 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
7629 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7630 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#4a78ee;
7631 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7632 }
7633 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
7634 *{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);}
7635 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7636 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
7637 .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);}
7638 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
7639 .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));}
7640 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
7641 .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;}
7642 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
7643 .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;}
7644 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
7645 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
7646 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
7647 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
7648 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
7649 .page{max-width:1720px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
7650 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
7651 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
7652 .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;}
7653 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
7654 .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);}
7655 .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;}
7656 .btn-secondary:hover{background:var(--line);}
7657 .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;}
7658 .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;}
7659 .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;}
7660 @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));}}
7661 </style>
7662</head>
7663<body>
7664 <div class="background-watermarks" aria-hidden="true">
7665 <img src="/images/logo/logo-text.png" alt="" style="width:320px;top:-40px;left:-60px;transform:rotate(-12deg);" />
7666 <img src="/images/logo/logo-text.png" alt="" style="width:280px;top:120px;right:-50px;transform:rotate(8deg);" />
7667 <img src="/images/logo/logo-text.png" alt="" style="width:260px;bottom:60px;left:30px;transform:rotate(15deg);" />
7668 <img src="/images/logo/logo-text.png" alt="" style="width:300px;bottom:-20px;right:80px;transform:rotate(-6deg);" />
7669 <img src="/images/logo/logo-text.png" alt="" style="width:240px;top:50%;left:45%;transform:rotate(22deg);" />
7670 <img src="/images/logo/logo-text.png" alt="" style="width:270px;top:10%;left:35%;transform:rotate(-18deg);" />
7671 </div>
7672 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7673 <div class="top-nav">
7674 <div class="top-nav-inner">
7675 <a class="brand" href="/">
7676 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
7677 <div class="brand-copy">
7678 <div class="brand-title">OxideSLOC</div>
7679 <div class="brand-subtitle">Local analysis workbench</div>
7680 </div>
7681 </a>
7682 <div class="nav-right">
7683 <a class="nav-pill" href="/">Home</a>
7684 <a class="nav-pill" href="/view-reports">View Reports</a>
7685 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7686 <div class="server-status-wrap">
7687 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
7688 <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>
7689 </div>
7690 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7691 <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>
7692 <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>
7693 </button>
7694 </div>
7695 </div>
7696 </div>
7697
7698 <div class="page">
7699 <div class="panel">
7700 <h1>Analysis failed</h1>
7701 <div class="error-box">{{ message }}</div>
7702 <div class="actions">
7703 <a class="btn-primary" href="/scan">Back to setup</a>
7704 {% if let Some(report_url) = last_report_url %}
7705 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
7706 {% endif %}
7707 <a class="btn-secondary" href="/view-reports">View Reports</a>
7708 </div>
7709 </div>
7710 </div>
7711 <script nonce="{{ csp_nonce }}">
7712 (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");});})();
7713 (function spawnCodeParticles() {
7714 var container = document.getElementById('code-particles');
7715 if (!container) return;
7716 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'];
7717 for (var i = 0; i < 38; i++) {
7718 (function(idx) {
7719 var el = document.createElement('span');
7720 el.className = 'code-particle';
7721 el.textContent = snippets[idx % snippets.length];
7722 var left = Math.random() * 94 + 2;
7723 var top = Math.random() * 88 + 6;
7724 var dur = (Math.random() * 10 + 9).toFixed(1);
7725 var delay = (Math.random() * 18).toFixed(1);
7726 var rot = (Math.random() * 26 - 13).toFixed(1);
7727 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7728 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
7729 container.appendChild(el);
7730 })(i);
7731 }
7732 })();
7733 </script>
7734</body>
7735</html>
7736"##,
7737 ext = "html"
7738)]
7739struct ErrorTemplate {
7740 message: String,
7741 last_report_url: Option<String>,
7743 last_report_label: Option<String>,
7745 csp_nonce: String,
7746}
7747
7748#[derive(Template)]
7751#[template(
7752 source = r##"
7753<!doctype html>
7754<html lang="en">
7755<head>
7756 <meta charset="utf-8">
7757 <meta name="viewport" content="width=device-width, initial-scale=1">
7758 <title>OxideSLOC | View Reports</title>
7759 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7760 <style nonce="{{ csp_nonce }}">
7761 :root {
7762 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7763 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7764 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
7765 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7766 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fdeaea;
7767 }
7768 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
7769 *{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);}
7770 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7771 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
7772 .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);}
7773 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
7774 .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));}
7775 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
7776 .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;}
7777 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
7778 .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;}
7779 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
7780 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
7781 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
7782 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
7783 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
7784 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
7785 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
7786 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
7787 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
7788 .panel-meta{font-size:13px;color:var(--muted);}
7789 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
7790 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
7791 .per-page-label{font-size:13px;color:var(--muted);}
7792 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;}
7793 .filter-input{min-width:180px;cursor:text;}
7794 .table-wrap{width:100%;overflow-x:auto;}
7795 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
7796 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;}
7797 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
7798 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
7799 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
7800 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
7801 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
7802 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
7803 tr:last-child td{border-bottom:none;}
7804 tr:hover td{background:var(--surface-2);}
7805 .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);}
7806 .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);}
7807 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
7808 .metric-num{font-weight:700;color:var(--text);}
7809 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
7810 .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;}
7811 .btn:hover{background:var(--line);}
7812 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
7813 .btn.primary:hover{opacity:.9;}
7814 .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;}
7815 .btn-back:hover{background:var(--line);}
7816 .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;}
7817 .export-btn:hover{background:var(--line);}
7818 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
7819 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
7820 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
7821 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
7822 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
7823 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
7824 .pagination-info{font-size:13px;color:var(--muted);}
7825 .pagination-btns{display:flex;gap:6px;}
7826 .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;}
7827 .pg-btn:hover:not(:disabled){background:var(--line);}
7828 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
7829 .pg-btn:disabled{opacity:.35;cursor:default;}
7830 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
7831 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
7832 .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;}
7833 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
7834 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
7835 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
7836 .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);}
7837 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
7838 .stat-chip:hover .stat-chip-tip{opacity:1;}
7839 .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
7840 .site-footer a{color:var(--muted);}
7841 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
7842 .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%;}
7843 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
7844 .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;}
7845 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
7846 .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;}
7847 .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;}
7848 .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;}
7849 @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));}}
7850 </style>
7851</head>
7852<body>
7853 <div class="background-watermarks" aria-hidden="true">
7854 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7855 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7856 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7857 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7858 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7859 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7860 </div>
7861 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7862 <div class="top-nav">
7863 <div class="top-nav-inner">
7864 <a class="brand" href="/">
7865 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7866 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
7867 </a>
7868 <div class="nav-right">
7869 <a class="nav-pill" href="/">Home</a>
7870 <a class="nav-pill" href="/view-reports">View Reports</a>
7871 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7872 <div class="server-status-wrap">
7873 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
7874 <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>
7875 </div>
7876 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7877 <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>
7878 <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>
7879 </button>
7880 </div>
7881 </div>
7882 </div>
7883
7884 <div class="page">
7885 {% if linked %}
7886 <div class="toast-success">
7887 <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>
7888 Report linked successfully — it now appears in the list below.
7889 </div>
7890 {% endif %}
7891 {% if total_scans > 0 %}
7892 <div class="summary-strip">
7893 <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>
7894 <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>
7895 <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>
7896 <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>
7897 </div>
7898 {% endif %}
7899
7900 <section class="panel">
7901 <div class="panel-header">
7902 <div>
7903 <h1>View Reports</h1>
7904 <p class="panel-meta">{{ total_scans }} report(s) available. Click any row to open it.</p>
7905 </div>
7906 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
7907 <div class="export-group">
7908 <button type="button" class="export-btn" onclick="exportHistoryCsv()">
7909 <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>
7910 Export CSV
7911 </button>
7912 <button type="button" class="export-btn" onclick="exportHistoryXls()">
7913 <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>
7914 Export Excel
7915 </button>
7916 </div>
7917 <a class="btn-back" href="/">
7918 <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>
7919 Home
7920 </a>
7921 </div>
7922 </div>
7923
7924 <div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;flex-wrap:wrap;">
7925 <span class="locate-label" style="white-space:nowrap;">Have a saved report on disk? Browse to link it here.</span>
7926 {% if !entries.is_empty() %}
7927 <div style="margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
7928 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…" oninput="applyFilters()">
7929 <select class="filter-select" id="branch-filter" onchange="applyFilters()"><option value="">All branches</option></select>
7930 <button type="button" class="btn" onclick="resetView()">↻ Reset view</button>
7931 </div>
7932 {% endif %}
7933 </div>
7934 <div style="margin-bottom:14px;">
7935 <button type="button" class="btn" onclick="browseReport()">
7936 <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>
7937 Browse for Report…
7938 </button>
7939 </div>
7940
7941 {% if entries.is_empty() %}
7942 <div class="empty-state">
7943 <strong>No reports with viewable HTML yet</strong>
7944 Run a new analysis from the <a href="/scan">scan page</a>, or use the browse button above to link an existing report.
7945 </div>
7946 {% else %}
7947 <div class="table-wrap">
7948 <table id="history-table">
7949 <colgroup>
7950 <col style="width:155px">
7951 <col style="width:160px">
7952 <col style="width:115px">
7953 <col style="width:88px">
7954 <col style="width:88px">
7955 <col style="width:88px">
7956 <col style="width:72px">
7957 <col style="width:80px">
7958 <col style="width:76px">
7959 <col style="width:80px">
7960 <col style="width:72px">
7961 <col style="width:92px">
7962 <col style="width:92px">
7963 <col style="width:160px">
7964 </colgroup>
7965 <thead>
7966 <tr id="history-thead">
7967 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7968 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7969 <th>Run ID<div class="col-resize-handle"></div></th>
7970 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7971 <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>
7972 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7973 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7974 <th>Functions<div class="col-resize-handle"></div></th>
7975 <th>Classes<div class="col-resize-handle"></div></th>
7976 <th>Variables<div class="col-resize-handle"></div></th>
7977 <th>Imports<div class="col-resize-handle"></div></th>
7978 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7979 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
7980 <th>Report<div class="col-resize-handle"></div></th>
7981 </tr>
7982 </thead>
7983 <tbody id="history-tbody">
7984 {% for entry in entries %}
7985 <tr class="history-row" data-run="{{ entry.run_id }}"
7986 data-timestamp="{{ entry.timestamp }}"
7987 data-project="{{ entry.project_label }}"
7988 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
7989 data-skipped="{{ entry.files_skipped }}"
7990 data-comments="{{ entry.comment_lines }}"
7991 data-blank="{{ entry.blank_lines }}"
7992 data-branch="{{ entry.git_branch }}"
7993 data-commit="{{ entry.git_commit }}"
7994 style="cursor:pointer;"
7995 onclick="window.open('/runs/{{ entry.run_id }}/html', '_blank')">
7996 <td>{{ entry.timestamp }}</td>
7997 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
7998 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
7999 <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
8000 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
8001 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
8002 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
8003 <td><span class="metric-num">{{ entry.functions }}</span></td>
8004 <td><span class="metric-num">{{ entry.classes }}</span></td>
8005 <td><span class="metric-num">{{ entry.variables }}</span></td>
8006 <td><span class="metric-num">{{ entry.imports }}</span></td>
8007 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
8008 <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>
8009 <td style="overflow:visible;white-space:normal;">
8010 <div class="actions-cell">
8011 <a class="btn primary" href="/runs/{{ entry.run_id }}/html" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="View HTML report">View</a>
8012 {% 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 %}
8013 </div>
8014 </td>
8015 </tr>
8016 {% endfor %}
8017 </tbody>
8018 </table>
8019 </div>
8020 <div class="pagination">
8021 <span class="pagination-info" id="pagination-info"></span>
8022 <div class="pagination-btns" id="pagination-btns"></div>
8023 <div style="display:flex;align-items:center;gap:8px;">
8024 <span class="per-page-label">Show</span>
8025 <select class="per-page" id="per-page-sel" onchange="setPerPage(this.value)">
8026 <option value="10">10 per page</option>
8027 <option value="25" selected>25 per page</option>
8028 <option value="50">50 per page</option>
8029 <option value="100">100 per page</option>
8030 </select>
8031 <span class="per-page-label" id="page-range-label"></span>
8032 </div>
8033 </div>
8034 {% endif %}
8035 </section>
8036 </div>
8037
8038 <footer class="site-footer">
8039 oxide-sloc — local source line analysis workbench ·
8040 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8041 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8042 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
8043 </footer>
8044
8045 <script nonce="{{ csp_nonce }}">
8046 (function () {
8047 // ── Theme ──────────────────────────────────────────────────────────────
8048 var storageKey = 'oxide-sloc-theme';
8049 var body = document.body;
8050 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
8051 var toggle = document.getElementById('theme-toggle');
8052 if (toggle) toggle.addEventListener('click', function () {
8053 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
8054 body.classList.toggle('dark-theme', next === 'dark');
8055 try { localStorage.setItem(storageKey, next); } catch(e) {}
8056 });
8057
8058 // ── State ─────────────────────────────────────────────────────────────
8059 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
8060 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
8061 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
8062
8063 // Aggregate stats from first (most recent) row
8064 if (allRows.length) {
8065 var first = allRows[0];
8066 var ce = document.getElementById('agg-code'); if (ce) ce.textContent = Number(first.dataset.code).toLocaleString();
8067 var fe = document.getElementById('agg-files'); if (fe) fe.textContent = first.dataset.files;
8068 var se = document.getElementById('agg-skipped'); if (se) se.textContent = first.dataset.skipped;
8069 }
8070
8071 // ── Branch filter population ──────────────────────────────────────────
8072 (function() {
8073 var branches = {};
8074 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
8075 var sel = document.getElementById('branch-filter');
8076 if (sel) Object.keys(branches).sort().forEach(function(b) {
8077 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
8078 });
8079 })();
8080
8081 // ── Filter ────────────────────────────────────────────────────────────
8082 function getFilteredRows() {
8083 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
8084 var branch = ((document.getElementById('branch-filter') || {}).value || '');
8085 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
8086 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
8087 if (branch && (r.dataset.branch || '') !== branch) return false;
8088 return true;
8089 });
8090 }
8091
8092 // ── Pagination ────────────────────────────────────────────────────────
8093 function renderPage() {
8094 var filtered = getFilteredRows();
8095 var total = filtered.length;
8096 var totalPages = Math.max(1, Math.ceil(total / perPage));
8097 currentPage = Math.min(currentPage, totalPages);
8098 var start = (currentPage - 1) * perPage;
8099 var end = Math.min(start + perPage, total);
8100 var shown = {};
8101 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
8102 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
8103 r.style.display = shown[r.dataset.run] ? '' : 'none';
8104 });
8105 var rl = document.getElementById('page-range-label');
8106 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
8107 var info = document.getElementById('pagination-info');
8108 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
8109 var btns = document.getElementById('pagination-btns');
8110 if (!btns) return;
8111 btns.innerHTML = '';
8112 function makeBtn(lbl, pg, active, disabled) {
8113 var b = document.createElement('button');
8114 b.className = 'pg-btn' + (active ? ' active' : '');
8115 b.textContent = lbl; b.disabled = disabled;
8116 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
8117 return b;
8118 }
8119 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
8120 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
8121 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
8122 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
8123 }
8124
8125 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
8126 window.applyFilters = function() { currentPage = 1; renderPage(); };
8127
8128 // ── Sorting ───────────────────────────────────────────────────────────
8129 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
8130 function doSort(col, type, order) {
8131 var tbody = document.getElementById('history-tbody');
8132 if (!tbody) return;
8133 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
8134 rows.sort(function(a, b) {
8135 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
8136 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
8137 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
8138 return va < vb ? 1 : va > vb ? -1 : 0;
8139 });
8140 rows.forEach(function(r) { tbody.appendChild(r); });
8141 currentPage = 1; renderPage();
8142 }
8143 sortHeaders.forEach(function(th) {
8144 th.addEventListener('click', function(e) {
8145 if (e.target.classList.contains('col-resize-handle')) return;
8146 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
8147 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
8148 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8149 th.classList.add('sort-' + sortOrder);
8150 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
8151 doSort(col, type, sortOrder);
8152 });
8153 });
8154
8155 // ── Column resize ─────────────────────────────────────────────────────
8156 (function() {
8157 var table = document.getElementById('history-table');
8158 if (!table) return;
8159 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
8160 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
8161 ths.forEach(function(th, i) {
8162 var handle = th.querySelector('.col-resize-handle');
8163 if (!handle || !cols[i]) return;
8164 var startX, startW;
8165 handle.addEventListener('mousedown', function(e) {
8166 e.stopPropagation(); e.preventDefault();
8167 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
8168 handle.classList.add('dragging');
8169 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
8170 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
8171 document.addEventListener('mousemove', onMove);
8172 document.addEventListener('mouseup', onUp);
8173 });
8174 });
8175 })();
8176
8177 // ── Reset view ────────────────────────────────────────────────────────
8178 window.resetView = function() {
8179 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
8180 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
8181 sortCol = null; sortOrder = 'asc';
8182 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8183 var tbody = document.getElementById('history-tbody');
8184 if (tbody) {
8185 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
8186 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
8187 rows.forEach(function(r) { tbody.appendChild(r); });
8188 }
8189 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
8190 var table = document.getElementById('history-table');
8191 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
8192 currentPage = 1; renderPage();
8193 };
8194
8195 renderPage();
8196
8197 (function randomizeWatermarks() {
8198 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8199 if (!wms.length) return;
8200 var placed = [];
8201 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;}
8202 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];}
8203 var half=Math.floor(wms.length/2);
8204 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+';';});
8205 })();
8206
8207 (function spawnCodeParticles() {
8208 var container = document.getElementById('code-particles');
8209 if (!container) return;
8210 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'];
8211 for (var i = 0; i < 38; i++) {
8212 (function(idx) {
8213 var el = document.createElement('span');
8214 el.className = 'code-particle';
8215 el.textContent = snippets[idx % snippets.length];
8216 var left = Math.random() * 94 + 2;
8217 var top = Math.random() * 88 + 6;
8218 var dur = (Math.random() * 10 + 9).toFixed(1);
8219 var delay = (Math.random() * 18).toFixed(1);
8220 var rot = (Math.random() * 26 - 13).toFixed(1);
8221 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
8222 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
8223 container.appendChild(el);
8224 })(i);
8225 }
8226 })();
8227 })();
8228
8229 function rowClick(runId, hasHtml) {
8230 if (hasHtml) window.open('/runs/' + runId + '/html', '_blank');
8231 }
8232
8233 function browseReport() {
8234 fetch('/pick-file?kind=report')
8235 .then(function(r) { return r.json(); })
8236 .then(function(data) {
8237 if (!data.cancelled && data.selected_path) {
8238 var form = document.createElement('form');
8239 form.method = 'POST';
8240 form.action = '/locate-report';
8241 var input = document.createElement('input');
8242 input.type = 'hidden';
8243 input.name = 'file_path';
8244 input.value = data.selected_path;
8245 form.appendChild(input);
8246 document.body.appendChild(form);
8247 form.submit();
8248 }
8249 })
8250 .catch(function(e) { alert('Could not open file picker: ' + e); });
8251 }
8252
8253 // ── Export helpers ────────────────────────────────────────────────────────
8254 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
8255 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
8256 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);}
8257 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;');}
8258 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');}
8259
8260 var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
8261 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;}
8262 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
8263 window.exportHistoryXls = function(){slocXls('scan-history.xls','Scan History',_hh,getHistoryRows());};
8264 </script>
8265</body>
8266</html>
8267"##,
8268 ext = "html"
8269)]
8270struct HistoryTemplate {
8271 entries: Vec<HistoryEntryRow>,
8272 total_scans: usize,
8273 linked: bool,
8274 csp_nonce: String,
8275}
8276
8277#[derive(Template)]
8280#[template(
8281 source = r##"
8282<!doctype html>
8283<html lang="en">
8284<head>
8285 <meta charset="utf-8">
8286 <meta name="viewport" content="width=device-width, initial-scale=1">
8287 <title>OxideSLOC | Compare Scans</title>
8288 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8289 <style nonce="{{ csp_nonce }}">
8290 :root {
8291 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
8292 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
8293 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
8294 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
8295 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
8296 }
8297 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
8298 *{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);}
8299 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
8300 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
8301 .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);}
8302 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
8303 .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));}
8304 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
8305 .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;}
8306 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
8307 .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;}
8308 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
8309 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
8310 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
8311 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
8312 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
8313 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
8314 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
8315 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
8316 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
8317 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
8318 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
8319 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
8320 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
8321 .per-page-label{font-size:13px;color:var(--muted);}
8322 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;}
8323 .filter-input{min-width:180px;cursor:text;}
8324 .table-wrap{width:100%;overflow-x:auto;}
8325 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
8326 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;}
8327 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--accent-2);}
8328 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
8329 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--accent-2);}
8330 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
8331 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(111,155,255,0.3);}
8332 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
8333 tr:last-child td{border-bottom:none;}
8334 tr.selected td{background:var(--sel-bg);}
8335 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
8336 tr:hover:not(.selected) td{background:var(--surface-2);}
8337 tr{cursor:pointer;}
8338 .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);}
8339 .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);}
8340 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
8341 .metric-num{font-weight:700;}
8342 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
8343 .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;}
8344 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
8345 .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;}
8346 .btn:hover{background:var(--line);}
8347 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
8348 .btn.primary:hover{opacity:.9;}
8349 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
8350 .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;}
8351 .btn-back:hover{background:var(--line);}
8352 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
8353 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
8354 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
8355 .pagination-info{font-size:13px;color:var(--muted);}
8356 .pagination-btns{display:flex;gap:6px;}
8357 .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;}
8358 .pg-btn:hover:not(:disabled){background:var(--line);}
8359 .pg-btn.active{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
8360 .pg-btn:disabled{opacity:.35;cursor:default;}
8361 .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
8362 .site-footer a{color:var(--muted);}
8363 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
8364 .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;}
8365 .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;}
8366 .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;}
8367 @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));}}
8368 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
8369 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
8370 .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;}
8371 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
8372 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
8373 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
8374 .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);}
8375 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
8376 .stat-chip:hover .stat-chip-tip{opacity:1;}
8377 .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;}
8378 .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%;}
8379 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
8380 </style>
8381</head>
8382<body>
8383 <div class="background-watermarks" aria-hidden="true">
8384 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8385 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8386 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8387 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8388 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8389 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8390 </div>
8391 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8392 <div class="top-nav">
8393 <div class="top-nav-inner">
8394 <a class="brand" href="/">
8395 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
8396 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
8397 </a>
8398 <div class="nav-right">
8399 <a class="nav-pill" href="/">Home</a>
8400 <a class="nav-pill" href="/view-reports">View Reports</a>
8401 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
8402 <div class="server-status-wrap">
8403 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
8404 <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>
8405 </div>
8406 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
8407 <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>
8408 <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>
8409 </button>
8410 </div>
8411 </div>
8412 </div>
8413
8414 <div class="page">
8415 {% if total_scans > 0 %}
8416 <div class="summary-strip">
8417 <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>
8418 <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>
8419 <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>
8420 <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>
8421 </div>
8422 {% endif %}
8423 <section class="panel">
8424 <div class="panel-header">
8425 <div>
8426 <h1>Compare Scans</h1>
8427 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
8428 </div>
8429 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
8430 <button class="btn primary" id="compare-btn" onclick="doCompare()" disabled>
8431 <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>
8432 Compare <span class="sel-count" id="sel-count">0/2</span>
8433 </button>
8434 <a class="btn-back" href="/">
8435 <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>
8436 Home
8437 </a>
8438 </div>
8439 </div>
8440
8441 {% if entries.is_empty() %}
8442 <div class="empty-state">
8443 <strong>No scans yet</strong>
8444 Run your first analysis from the <a href="/scan">scan page</a>.
8445 </div>
8446 {% else %}
8447 <div style="display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;">
8448 <div class="instruction-bar" style="margin-bottom:0;flex-shrink:0;">
8449 <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>
8450 Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
8451 </div>
8452 <div style="margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
8453 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…" oninput="applyFilters()">
8454 <select class="filter-select" id="branch-filter" onchange="applyFilters()"><option value="">All branches</option></select>
8455 <button type="button" class="btn" onclick="resetView()">↻ Reset view</button>
8456 </div>
8457 </div>
8458 <div class="table-wrap">
8459 <table id="compare-table">
8460 <colgroup>
8461 <col style="width:44px">
8462 <col style="width:165px">
8463 <col style="width:180px">
8464 <col style="width:110px">
8465 <col style="width:100px">
8466 <col style="width:80px">
8467 <col style="width:100px">
8468 <col style="width:90px">
8469 <col style="width:100px">
8470 </colgroup>
8471 <thead>
8472 <tr id="compare-thead">
8473 <th style="text-align:center;padding-left:8px;padding-right:8px;"><div class="col-resize-handle"></div></th>
8474 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
8475 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
8476 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
8477 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
8478 <th class="sortable" data-sort-col="code" data-sort-type="num">Code<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
8479 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
8480 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
8481 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
8482 </tr>
8483 </thead>
8484 <tbody id="compare-tbody">
8485 {% for entry in entries %}
8486 <tr class="compare-row" data-run="{{ entry.run_id }}"
8487 data-timestamp="{{ entry.timestamp }}"
8488 data-project="{{ entry.project_label }}"
8489 data-files="{{ entry.files_analyzed }}"
8490 data-code="{{ entry.code_lines }}"
8491 data-comments="{{ entry.comment_lines }}"
8492 data-branch="{{ entry.git_branch }}"
8493 data-commit="{{ entry.git_commit }}"
8494 onclick="toggleRow(this, '{{ entry.run_id }}')">
8495 <td style="text-align:center;padding-left:8px;padding-right:8px;"><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
8496 <td>{{ entry.timestamp }}</td>
8497 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
8498 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
8499 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
8500 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
8501 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
8502 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
8503 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
8504 </tr>
8505 {% endfor %}
8506 </tbody>
8507 </table>
8508 </div>
8509 <div class="pagination">
8510 <span class="pagination-info" id="pagination-info"></span>
8511 <div class="pagination-btns" id="pagination-btns"></div>
8512 <div style="display:flex;align-items:center;gap:8px;">
8513 <span class="per-page-label">Show</span>
8514 <select class="per-page" id="per-page-sel" onchange="setPerPage(this.value)">
8515 <option value="10">10 per page</option>
8516 <option value="25" selected>25 per page</option>
8517 <option value="50">50 per page</option>
8518 <option value="100">100 per page</option>
8519 </select>
8520 <span class="per-page-label" id="page-range-label"></span>
8521 </div>
8522 </div>
8523 {% endif %}
8524 </section>
8525 </div>
8526
8527 <footer class="site-footer">
8528 oxide-sloc — local source line analysis workbench ·
8529 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8530 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8531 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
8532 </footer>
8533
8534 <script nonce="{{ csp_nonce }}">
8535 (function () {
8536 // ── Theme ──────────────────────────────────────────────────────────────
8537 var storageKey = 'oxide-sloc-theme';
8538 var body = document.body;
8539 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
8540 var toggle = document.getElementById('theme-toggle');
8541 if (toggle) toggle.addEventListener('click', function () {
8542 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
8543 body.classList.toggle('dark-theme', next === 'dark');
8544 try { localStorage.setItem(storageKey, next); } catch(e) {}
8545 });
8546
8547 // ── State ─────────────────────────────────────────────────────────────
8548 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
8549 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
8550 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
8551
8552 // ── Stat chips ────────────────────────────────────────────────────────
8553 (function() {
8554 var projects = {}, latestTs = '', latestRow = null;
8555 allRows.forEach(function(r) {
8556 var p = r.dataset.project || ''; if (p) projects[p] = true;
8557 var ts = r.dataset.timestamp || '';
8558 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
8559 });
8560 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
8561 if (latestRow) {
8562 var ce = document.getElementById('agg-code'); if (ce) ce.textContent = Number(latestRow.dataset.code).toLocaleString();
8563 var fe = document.getElementById('agg-files'); if (fe) fe.textContent = latestRow.dataset.files;
8564 }
8565 })();
8566
8567 // ── Branch filter population ──────────────────────────────────────────
8568 (function() {
8569 var branches = {};
8570 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
8571 var sel = document.getElementById('branch-filter');
8572 if (sel) Object.keys(branches).sort().forEach(function(b) {
8573 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
8574 });
8575 })();
8576
8577 // ── Filter ────────────────────────────────────────────────────────────
8578 function getFilteredRows() {
8579 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
8580 var branch = ((document.getElementById('branch-filter') || {}).value || '');
8581 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
8582 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
8583 if (branch && (r.dataset.branch || '') !== branch) return false;
8584 return true;
8585 });
8586 }
8587
8588 // ── Pagination ────────────────────────────────────────────────────────
8589 function renderPage() {
8590 var filtered = getFilteredRows();
8591 var total = filtered.length;
8592 var totalPages = Math.max(1, Math.ceil(total / perPage));
8593 currentPage = Math.min(currentPage, totalPages);
8594 var start = (currentPage - 1) * perPage;
8595 var end = Math.min(start + perPage, total);
8596 var shown = {};
8597 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
8598 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
8599 r.style.display = shown[r.dataset.run] ? '' : 'none';
8600 });
8601 var rl = document.getElementById('page-range-label');
8602 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
8603 var info = document.getElementById('pagination-info');
8604 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
8605 var btns = document.getElementById('pagination-btns');
8606 if (!btns) return;
8607 btns.innerHTML = '';
8608 function makeBtn(lbl, pg, active, disabled) {
8609 var b = document.createElement('button');
8610 b.className = 'pg-btn' + (active ? ' active' : '');
8611 b.textContent = lbl; b.disabled = disabled;
8612 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
8613 return b;
8614 }
8615 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
8616 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
8617 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
8618 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
8619 }
8620
8621 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
8622 window.applyFilters = function() { currentPage = 1; renderPage(); };
8623
8624 // ── Sorting ───────────────────────────────────────────────────────────
8625 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
8626 function doSort(col, type, order) {
8627 var tbody = document.getElementById('compare-tbody');
8628 if (!tbody) return;
8629 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
8630 rows.sort(function(a, b) {
8631 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
8632 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
8633 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
8634 return va < vb ? 1 : va > vb ? -1 : 0;
8635 });
8636 rows.forEach(function(r) { tbody.appendChild(r); });
8637 currentPage = 1; renderPage();
8638 }
8639 sortHeaders.forEach(function(th) {
8640 th.addEventListener('click', function(e) {
8641 if (e.target.classList.contains('col-resize-handle')) return;
8642 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
8643 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
8644 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8645 th.classList.add('sort-' + sortOrder);
8646 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
8647 doSort(col, type, sortOrder);
8648 });
8649 });
8650
8651 // ── Column resize ─────────────────────────────────────────────────────
8652 (function() {
8653 var table = document.getElementById('compare-table');
8654 if (!table) return;
8655 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
8656 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
8657 ths.forEach(function(th, i) {
8658 var handle = th.querySelector('.col-resize-handle');
8659 if (!handle || !cols[i]) return;
8660 var startX, startW;
8661 handle.addEventListener('mousedown', function(e) {
8662 e.stopPropagation(); e.preventDefault();
8663 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
8664 handle.classList.add('dragging');
8665 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
8666 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
8667 document.addEventListener('mousemove', onMove);
8668 document.addEventListener('mouseup', onUp);
8669 });
8670 });
8671 })();
8672
8673 // ── Reset view ────────────────────────────────────────────────────────
8674 window.resetView = function() {
8675 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
8676 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
8677 sortCol = null; sortOrder = 'asc';
8678 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8679 var tbody = document.getElementById('compare-tbody');
8680 if (tbody) {
8681 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
8682 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
8683 rows.forEach(function(r) { tbody.appendChild(r); });
8684 }
8685 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
8686 var table = document.getElementById('compare-table');
8687 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
8688 currentPage = 1; renderPage();
8689 };
8690
8691 renderPage();
8692
8693 (function randomizeWatermarks() {
8694 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8695 if (!wms.length) return;
8696 var placed = [];
8697 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;}
8698 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];}
8699 var half=Math.floor(wms.length/2);
8700 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+';';});
8701 })();
8702
8703 (function spawnCodeParticles() {
8704 var container = document.getElementById('code-particles');
8705 if (!container) return;
8706 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'];
8707 for (var i = 0; i < 38; i++) {
8708 (function(idx) {
8709 var el = document.createElement('span');
8710 el.className = 'code-particle';
8711 el.textContent = snippets[idx % snippets.length];
8712 var left = Math.random() * 94 + 2;
8713 var top = Math.random() * 88 + 6;
8714 var dur = (Math.random() * 10 + 9).toFixed(1);
8715 var delay = (Math.random() * 18).toFixed(1);
8716 var rot = (Math.random() * 26 - 13).toFixed(1);
8717 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
8718 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
8719 container.appendChild(el);
8720 })(i);
8721 }
8722 })();
8723 })();
8724
8725 var selected = [];
8726 function updateCompareBtn() {
8727 var btn = document.getElementById('compare-btn');
8728 var cnt = document.getElementById('sel-count');
8729 if (!btn) return;
8730 btn.disabled = selected.length !== 2;
8731 if (cnt) cnt.textContent = selected.length + '/2';
8732 }
8733
8734 function toggleRow(row, runId) {
8735 var idx = selected.indexOf(runId);
8736 if (idx >= 0) {
8737 selected.splice(idx, 1);
8738 row.classList.remove('selected');
8739 var b = document.getElementById('badge-' + runId);
8740 if (b) b.textContent = '';
8741 } else {
8742 if (selected.length >= 2) return;
8743 selected.push(runId);
8744 row.classList.add('selected');
8745 var b = document.getElementById('badge-' + runId);
8746 if (b) b.textContent = selected.length;
8747 }
8748 selected.forEach(function(id, i) {
8749 var b = document.getElementById('badge-' + id);
8750 if (b) b.textContent = i + 1;
8751 });
8752 updateCompareBtn();
8753 }
8754
8755 function doCompare() {
8756 if (selected.length !== 2) return;
8757 window.location.href = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
8758 }
8759 </script>
8760</body>
8761</html>
8762"##,
8763 ext = "html"
8764)]
8765struct CompareSelectTemplate {
8766 entries: Vec<HistoryEntryRow>,
8767 total_scans: usize,
8768 csp_nonce: String,
8769}
8770
8771#[derive(Template)]
8774#[template(
8775 source = r##"
8776<!doctype html>
8777<html lang="en">
8778<head>
8779 <meta charset="utf-8">
8780 <meta name="viewport" content="width=device-width, initial-scale=1">
8781 <title>OxideSLOC | Scan Delta</title>
8782 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8783 <style nonce="{{ csp_nonce }}">
8784 :root {
8785 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
8786 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
8787 --nav:#b85d33; --nav-2:#7a371b;
8788 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
8789 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fdeaea; --zero-bg:transparent;
8790 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
8791 }
8792 body.dark-theme {
8793 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
8794 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#f5a3a3; --neg-bg:#3d1c1c;
8795 }
8796 *{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);}
8797 .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);}
8798 .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;}
8799 .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));}
8800 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
8801 .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;}
8802 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
8803 .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;}
8804 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
8805 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
8806 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
8807 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
8808 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
8809 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
8810 .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;}
8811 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
8812 .hero-body{display:block;}
8813 .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;}
8814 .btn-back:hover{background:var(--line);}
8815 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;}
8816 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
8817 .muted{color:var(--muted);font-size:14px;}
8818 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
8819 .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;}
8820 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
8821 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
8822 .vpill-arrow{font-size:20px;color:var(--muted);}
8823 .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%;}
8824 .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;}
8825 .delta-card.delta-card-wide{padding:14px 18px;}
8826 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);}
8827 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
8828 .delta-card-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:4px;}
8829 .delta-card-from{font-size:12px;color:var(--muted);}
8830 .delta-card-to{font-size:20px;font-weight:800;margin:2px 0;}
8831 .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;}
8832 .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);}
8833 .delta-card:hover .dc-tip{display:block;}
8834 .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;}
8835 .export-btn:hover{background:var(--line);}
8836 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
8837 .delta-card-change{font-size:13px;font-weight:700;border-radius:6px;padding:1px 7px;display:inline-block;margin-top:2px;}
8838 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
8839 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
8840 .delta-card-change.zero{color:var(--muted);background:transparent;}
8841 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
8842 .fc-row{display:flex;align-items:center;gap:8px;}
8843 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
8844 .fc-label{color:var(--muted);}
8845 .fc-modified .fc-count{color:#926000;}
8846 .fc-added .fc-count{color:var(--pos);}
8847 .fc-removed .fc-count{color:var(--neg);}
8848 .fc-unchanged .fc-count{color:var(--muted);}
8849 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
8850 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
8851 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
8852 .chip.modified{background:#fff2d8;color:#926000;}
8853 .chip.added{background:#e8f5ed;color:#1a8f47;}
8854 .chip.removed{background:#fdeaea;color:#b33b3b;}
8855 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
8856 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
8857 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
8858 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
8859 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
8860 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
8861 .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;}
8862 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
8863 .tab-btn:hover:not(.active){background:var(--line);}
8864 .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;}
8865 .btn-reset:hover{background:var(--line);}
8866 .table-wrap{width:100%;overflow-x:auto;}
8867 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
8868 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;}
8869 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
8870 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
8871 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
8872 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
8873 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
8874 td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
8875 tr:last-child td{border-bottom:none;}
8876 tr.row-added td{background:rgba(26,143,71,0.06);}
8877 tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
8878 tr.row-modified td{background:rgba(146,96,0,0.05);}
8879 tr.row-unchanged td{opacity:.6;}
8880 .file-path{font-family:ui-monospace,monospace;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
8881 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
8882 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
8883 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
8884 .status-badge.modified{background:#fff2d8;color:#926000;}
8885 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
8886 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
8887 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
8888 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
8889 .delta-val{font-weight:700;}
8890 .delta-val.pos{color:var(--pos);}
8891 .delta-val.neg{color:var(--neg);}
8892 .delta-val.zero{color:var(--muted);}
8893 .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
8894 .from-to strong{color:var(--text);}
8895 .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
8896 .site-footer a{color:var(--muted);}
8897 @media(max-width:1400px){.delta-strip{grid-template-columns:repeat(3,1fr);}}
8898 @media(max-width:900px){.delta-strip{grid-template-columns:repeat(2,1fr);}}
8899 @media(max-width:600px){.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
8900 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
8901 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
8902 .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;}
8903 .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;}
8904 .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;}
8905 @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));}}
8906 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
8907 .path-link:hover{color:var(--oxide-2);}
8908 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
8909 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
8910 a.vpill-id:hover{color:var(--oxide);}
8911 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
8912 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
8913 .pagination-info{font-size:13px;color:var(--muted);}
8914 .pagination-btns{display:flex;gap:6px;}
8915 .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;}
8916 .pg-btn:hover:not(:disabled){background:var(--line);}
8917 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
8918 .pg-btn:disabled{opacity:.35;cursor:default;}
8919 .per-page-label{font-size:13px;color:var(--muted);}
8920 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;}
8921 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
8922 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
8923 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
8924 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
8925 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
8926 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
8927 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
8928 .tab-btn.tab-unchanged{color:var(--muted);}
8929 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
8930 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
8931 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
8932 </style>
8933</head>
8934<body>
8935 <div class="background-watermarks" aria-hidden="true">
8936 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8937 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8938 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8939 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8940 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8941 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8942 </div>
8943 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8944 <div class="top-nav">
8945 <div class="top-nav-inner">
8946 <a class="brand" href="/">
8947 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
8948 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
8949 </a>
8950 <div class="nav-right">
8951 <a class="nav-pill" href="/">Home</a>
8952 <a class="nav-pill" href="/view-reports">View Reports</a>
8953 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
8954 <div class="server-status-wrap">
8955 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
8956 <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>
8957 </div>
8958 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
8959 <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>
8960 <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>
8961 </button>
8962 </div>
8963 </div>
8964 </div>
8965
8966 <div class="page">
8967 <section class="hero">
8968 <div class="hero-header">
8969 <div>
8970 <h1 style="margin:0 0 6px;">Scan Delta</h1>
8971 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
8972 <span class="muted" style="font-size:13px;">Comparing two scans of</span>
8973 <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>
8974 </div>
8975 <div style="display:flex;align-items:center;gap:8px;margin-top:10px;flex-wrap:wrap;">
8976 <span style="font-size:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:4px 10px;color:var(--muted);">
8977 <span style="color:var(--text);font-weight:700;">Baseline</span> {{ baseline_timestamp }}
8978 </span>
8979 <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>
8980 <span style="font-size:12px;background:var(--surface-2);border:1px solid var(--oxide);border-radius:8px;padding:4px 10px;color:var(--muted);">
8981 <span style="color:var(--oxide);font-weight:700;">Current</span> {{ current_timestamp }}
8982 </span>
8983 </div>
8984 </div>
8985 <a class="btn-back" href="/compare-scans">
8986 <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>
8987 Compare Scans
8988 </a>
8989 </div>
8990 <div class="hero-body">
8991 <div class="delta-strip">
8992 <div class="delta-card delta-card-meta">
8993 <div class="delta-card-label">Baseline</div>
8994 <div class="delta-card-to" style="font-size:15px;line-height:1.2;">{{ baseline_timestamp }}</div>
8995 <a class="vpill-id" href="/runs/{{ baseline_run_id }}/html" target="_blank">{{ baseline_run_id_short }}</a>
8996 {% if !baseline_git_branch.is_empty() %}<span class="vpill-meta">Branch: {{ baseline_git_branch }}</span>{% endif %}
8997 {% if let Some(author) = baseline_git_author %}<span class="vpill-meta">Last commit by: {{ author }}</span>{% endif %}
8998 {% if let Some(tags) = baseline_git_tags %}<span class="vpill-meta">Tags: {{ tags }}</span>{% endif %}
8999 </div>
9000 <div class="delta-card delta-card-meta">
9001 <div class="delta-card-label">Current</div>
9002 <div class="delta-card-to" style="font-size:15px;line-height:1.2;">{{ current_timestamp }}</div>
9003 <a class="vpill-id" href="/runs/{{ current_run_id }}/html" target="_blank">{{ current_run_id_short }}</a>
9004 {% if !current_git_branch.is_empty() %}<span class="vpill-meta">Branch: {{ current_git_branch }}</span>{% endif %}
9005 {% if let Some(author) = current_git_author %}<span class="vpill-meta">Last commit by: {{ author }}</span>{% endif %}
9006 {% if let Some(tags) = current_git_tags %}<span class="vpill-meta">Tags: {{ tags }}</span>{% endif %}
9007 </div>
9008 <div class="delta-card">
9009 <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
9010 <div class="delta-card-label">Code lines</div>
9011 <div class="delta-card-from">Before: {{ baseline_code }}</div>
9012 <div class="delta-card-to">{{ current_code }}</div>
9013 {% if code_lines_delta_class == "pos" %}<span class="delta-card-change pos">{{ code_lines_delta_str }}</span>
9014 {% else if code_lines_delta_class == "neg" %}<span class="delta-card-change neg">{{ code_lines_delta_str }}</span>
9015 {% endif %}
9016 </div>
9017 <div class="delta-card">
9018 <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
9019 <div class="delta-card-label">Files analyzed</div>
9020 <div class="delta-card-from">Before: {{ baseline_files }}</div>
9021 <div class="delta-card-to">{{ current_files }}</div>
9022 {% if files_analyzed_delta_class == "pos" %}<span class="delta-card-change pos">{{ files_analyzed_delta_str }}</span>
9023 {% else if files_analyzed_delta_class == "neg" %}<span class="delta-card-change neg">{{ files_analyzed_delta_str }}</span>
9024 {% endif %}
9025 </div>
9026 <div class="delta-card">
9027 <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
9028 <div class="delta-card-label">Comment lines</div>
9029 <div class="delta-card-from">Before: {{ baseline_comments }}</div>
9030 <div class="delta-card-to">{{ current_comments }}</div>
9031 {% if comment_lines_delta_class == "pos" %}<span class="delta-card-change pos">{{ comment_lines_delta_str }}</span>
9032 {% else if comment_lines_delta_class == "neg" %}<span class="delta-card-change neg">{{ comment_lines_delta_str }}</span>
9033 {% endif %}
9034 </div>
9035 <div class="delta-card delta-card-wide">
9036 <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>
9037 <div class="delta-card-label">File changes</div>
9038 <div class="file-changes-grid">
9039 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
9040 <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
9041 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
9042 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
9043 </div>
9044 </div>
9045 </div>
9046 </div>
9047 </section>
9048
9049 <section class="panel">
9050 <h2>File-level delta</h2>
9051 <div class="filter-tabs-row">
9052 <div class="filter-tabs">
9053 <button class="tab-btn tab-all active" onclick="filterRows('all', this)">All</button>
9054 <button class="tab-btn tab-modified" onclick="filterRows('modified', this)">Modified ({{ files_modified }})</button>
9055 <button class="tab-btn tab-added" onclick="filterRows('added', this)">Added ({{ files_added }})</button>
9056 <button class="tab-btn tab-removed" onclick="filterRows('removed', this)">Removed ({{ files_removed }})</button>
9057 <button class="tab-btn tab-unchanged" onclick="filterRows('unchanged', this)">Unchanged ({{ files_unchanged }})</button>
9058 </div>
9059 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
9060 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
9061 <div class="export-group">
9062 <button type="button" class="btn-reset" onclick="resetDeltaTable()">↻ Reset</button>
9063 <button type="button" class="export-btn" onclick="exportDeltaCsv()">
9064 <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>
9065 CSV
9066 </button>
9067 <button type="button" class="export-btn" onclick="exportDeltaXls()">
9068 <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>
9069 Excel
9070 </button>
9071 <button type="button" class="export-btn" onclick="exportDeltaCharts()">
9072 <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>
9073 Charts
9074 </button>
9075 </div>
9076 </div>
9077 </div>
9078
9079 <div class="table-wrap">
9080 <table id="delta-table">
9081 <colgroup>
9082 <col style="width:34%">
9083 <col style="width:10%">
9084 <col style="width:9%">
9085 <col style="width:15%">
9086 <col style="width:8%">
9087 <col style="width:8%">
9088 <col style="width:8%">
9089 </colgroup>
9090 <thead>
9091 <tr id="delta-thead">
9092 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
9093 <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>
9094 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
9095 <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>
9096 <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>
9097 <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>
9098 <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>
9099 </tr>
9100 </thead>
9101 <tbody id="delta-tbody">
9102 {% for row in file_rows %}
9103 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
9104 data-path="{{ row.relative_path }}"
9105 data-language="{{ row.language }}"
9106 data-baseline-code="{{ row.baseline_code }}"
9107 data-current-code="{{ row.current_code }}"
9108 data-code-delta="{{ row.code_delta_str }}"
9109 data-comment-delta="{{ row.comment_delta_str }}"
9110 data-total-delta="{{ row.total_delta_str }}"
9111 data-orig-idx="">
9112 <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
9113 <td class="hide-sm">{{ row.language }}</td>
9114 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
9115 <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
9116 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
9117 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
9118 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
9119 </tr>
9120 {% endfor %}
9121 </tbody>
9122 </table>
9123 </div>
9124 <div class="pagination">
9125 <span class="pagination-info" id="pg-info"></span>
9126 <div class="pagination-btns" id="pg-btns"></div>
9127 <div style="display:flex;align-items:center;gap:8px;">
9128 <span class="per-page-label">Show</span>
9129 <select class="per-page" id="per-page-sel" onchange="setDeltaPerPage(this.value)">
9130 <option value="10">10 per page</option>
9131 <option value="25" selected>25 per page</option>
9132 <option value="50">50 per page</option>
9133 <option value="100">100 per page</option>
9134 </select>
9135 <span class="per-page-label" id="pg-range-label"></span>
9136 </div>
9137 </div>
9138 </section>
9139 </div>
9140
9141 <footer class="site-footer">
9142 oxide-sloc — local source line analysis workbench ·
9143 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
9144 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
9145 </footer>
9146
9147 <script nonce="{{ csp_nonce }}">
9148 (function () {
9149 var storageKey = 'oxide-sloc-theme';
9150 var body = document.body;
9151 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
9152 var toggle = document.getElementById('theme-toggle');
9153 if (toggle) toggle.addEventListener('click', function () {
9154 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
9155 body.classList.toggle('dark-theme', next === 'dark');
9156 try { localStorage.setItem(storageKey, next); } catch(e) {}
9157 });
9158
9159 (function randomizeWatermarks() {
9160 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
9161 if (!wms.length) return;
9162 var placed = [];
9163 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;}
9164 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];}
9165 var half=Math.floor(wms.length/2);
9166 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+';';});
9167 })();
9168
9169 (function spawnCodeParticles() {
9170 var container = document.getElementById('code-particles');
9171 if (!container) return;
9172 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'];
9173 for (var i = 0; i < 38; i++) {
9174 (function(idx) {
9175 var el = document.createElement('span');
9176 el.className = 'code-particle';
9177 el.textContent = snippets[idx % snippets.length];
9178 var left = Math.random() * 94 + 2;
9179 var top = Math.random() * 88 + 6;
9180 var dur = (Math.random() * 10 + 9).toFixed(1);
9181 var delay = (Math.random() * 18).toFixed(1);
9182 var rot = (Math.random() * 26 - 13).toFixed(1);
9183 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
9184 el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
9185 container.appendChild(el);
9186 })(i);
9187 }
9188 })();
9189 })();
9190
9191 var activeStatusFilter = 'all';
9192 var deltaPerPage = 25, deltaCurrPage = 1;
9193
9194 function openFolder(path) {
9195 fetch('/open-path?path=' + encodeURIComponent(path)).catch(function(){});
9196 }
9197
9198 function getDeltaFilteredRows() {
9199 return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
9200 return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
9201 });
9202 }
9203
9204 function renderDeltaPage() {
9205 var filtered = getDeltaFilteredRows();
9206 var total = filtered.length;
9207 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
9208 deltaCurrPage = Math.min(deltaCurrPage, totalPages);
9209 var start = (deltaCurrPage - 1) * deltaPerPage;
9210 var end = Math.min(start + deltaPerPage, total);
9211 var shownSet = {};
9212 filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
9213 Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
9214 r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
9215 });
9216 var rl = document.getElementById('pg-range-label');
9217 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
9218 var info = document.getElementById('pg-info');
9219 if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
9220 var btns = document.getElementById('pg-btns');
9221 if (!btns) return;
9222 btns.innerHTML = '';
9223 if (totalPages <= 1) return;
9224 function makeBtn(lbl, pg, active, disabled) {
9225 var b = document.createElement('button');
9226 b.className = 'pg-btn' + (active ? ' active' : '');
9227 b.textContent = lbl; b.disabled = disabled;
9228 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
9229 return b;
9230 }
9231 btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
9232 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
9233 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
9234 btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
9235 }
9236
9237 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
9238
9239 function filterRows(status, btn) {
9240 activeStatusFilter = status;
9241 deltaCurrPage = 1;
9242 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
9243 b.classList.remove('active');
9244 });
9245 if (btn) btn.classList.add('active');
9246 renderDeltaPage();
9247 }
9248
9249 // ── Sorting ──────────────────────────────────────────────────────────────
9250 var sortCol = null, sortOrder = 'asc';
9251 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
9252 (function() {
9253 var tbody = document.getElementById('delta-tbody');
9254 if (!tbody) return;
9255 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
9256 rows.forEach(function(r, i) { r.dataset.origIdx = i; });
9257 })();
9258
9259 function parseDeltaNum(str) {
9260 if (!str || str === '—') return 0;
9261 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
9262 }
9263
9264 sortHeaders.forEach(function(th) {
9265 th.addEventListener('click', function(e) {
9266 if (e.target.classList.contains('col-resize-handle')) return;
9267 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
9268 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
9269 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
9270 th.classList.add('sort-' + sortOrder);
9271 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
9272 var tbody = document.getElementById('delta-tbody');
9273 if (!tbody) return;
9274 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
9275 rows.sort(function(a, b) {
9276 var va, vb;
9277 if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
9278 else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
9279 else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
9280 else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
9281 else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
9282 else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
9283 else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
9284 else { va = ''; vb = ''; }
9285 if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
9286 return va < vb ? 1 : va > vb ? -1 : 0;
9287 });
9288 rows.forEach(function(r) { tbody.appendChild(r); });
9289 deltaCurrPage = 1;
9290 renderDeltaPage();
9291 var activeBtn = document.querySelector('.tab-btn.active');
9292 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
9293 if (activeBtn) activeBtn.classList.add('active');
9294 });
9295 });
9296
9297 // ── Column resize ─────────────────────────────────────────────────────────
9298 (function() {
9299 var table = document.getElementById('delta-table');
9300 if (!table) return;
9301 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
9302 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
9303 ths.forEach(function(th, i) {
9304 var handle = th.querySelector('.col-resize-handle');
9305 if (!handle || !cols[i]) return;
9306 var startX, startW;
9307 handle.addEventListener('mousedown', function(e) {
9308 e.stopPropagation(); e.preventDefault();
9309 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
9310 handle.classList.add('dragging');
9311 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
9312 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
9313 document.addEventListener('mousemove', onMove);
9314 document.addEventListener('mouseup', onUp);
9315 });
9316 });
9317 })();
9318
9319 // ── Reset ─────────────────────────────────────────────────────────────────
9320 window.resetDeltaTable = function() {
9321 sortCol = null; sortOrder = 'asc';
9322 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
9323 var tbody = document.getElementById('delta-tbody');
9324 if (tbody) {
9325 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
9326 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
9327 rows.forEach(function(r) { tbody.appendChild(r); });
9328 }
9329 var table = document.getElementById('delta-table');
9330 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
9331 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
9332 activeStatusFilter = 'all';
9333 deltaCurrPage = 1;
9334 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
9335 var allBtn = document.querySelector('.tab-btn');
9336 if (allBtn) allBtn.classList.add('active');
9337 renderDeltaPage();
9338 };
9339
9340 renderDeltaPage();
9341
9342 // ── Export helpers ────────────────────────────────────────────────────────
9343 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
9344 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
9345 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);}
9346 function slocMakeXlsx(fname,sd,dr){
9347 var enc=new TextEncoder();
9348 // CRC-32 table
9349 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;}
9350 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;}
9351 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
9352 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
9353 // Shared string table
9354 var ss=[],si={};
9355 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
9356 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
9357 // Worksheet builder — each WS() call gets its own row counter R
9358 function WS(){
9359 var R=0,buf=[];
9360 function cl(c){return String.fromCharCode(65+c);}
9361 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
9362 '<v>'+S(v)+'</v></c>';}
9363 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
9364 (st?' s="'+st+'"':'')+'>'+
9365 '<v>'+(+v)+'</v></c>';}
9366 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
9367 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
9368 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
9369 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
9370 '<sheetFormatPr defaultRowHeight="15"/>'+
9371 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
9372 return{sc:sc,nc:nc,row:row,xml:xml};
9373 }
9374 // Language breakdown
9375 var lm={};
9376 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;});
9377 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
9378 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
9379 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
9380 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
9381 // Summary sheet
9382 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
9383 r1(s1(0,'OxideSLOC — Scan Delta Report',1));
9384 r1(s1(0,proj,2));
9385 r1(s1(0,sd.bts+' → '+sd.cts,2));
9386 r1('');
9387 r1(s1(0,'Metric',3)+s1(1,'Baseline',3)+s1(2,'Current',3)+s1(3,'Delta',3));
9388 r1(s1(0,'Code Lines')+n1(1,sd.bc,4)+n1(2,sd.cc,4)+s1(3,sd.cd,dstyle(sd.cd)));
9389 r1(s1(0,'Files Analyzed')+n1(1,sd.bf,4)+n1(2,sd.cf,4)+s1(3,sd.fd,dstyle(sd.fd)));
9390 r1(s1(0,'Comment Lines')+n1(1,sd.bcm,4)+n1(2,sd.ccm,4)+s1(3,sd.cmd,dstyle(sd.cmd)));
9391 r1('');
9392 r1(s1(0,'FILE CHANGES',8));
9393 r1(s1(0,'Category',3)+s1(3,'Count',3));
9394 r1(s1(0,'Modified')+n1(3,sd.fm,4));
9395 r1(s1(0,'Added')+n1(3,sd.fa,4));
9396 r1(s1(0,'Removed')+n1(3,sd.fr,4));
9397 r1(s1(0,'Unchanged')+n1(3,sd.fu,4));
9398 if(langs.length){
9399 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
9400 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
9401 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)));});
9402 }
9403 r1('');r1(s1(0,'SCAN METADATA',8));
9404 r1(s1(1,'Baseline')+s1(2,'Current'));
9405 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
9406 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
9407 var sh1=W1.xml('<col min="1" max="1" width="24" customWidth="1"/><col min="2" max="4" width="14" customWidth="1"/>');
9408 // File Delta sheet
9409 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
9410 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));
9411 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])));});
9412 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="8" width="13" customWidth="1"/>');
9413 // Shared strings XML
9414 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
9415 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
9416 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
9417 // XLSX file map
9418 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
9419 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>',
9420 '_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>',
9421 '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>',
9422 '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>',
9423 '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>',
9424 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
9425 // ZIP packer — STORED (no compression), compatible with all XLSX readers
9426 var zparts=[],zcds=[],zoff=0,znf=0;
9427 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
9428 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
9429 ].forEach(function(name){
9430 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
9431 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]);
9432 var entry=new Uint8Array(lha.length+nb.length+sz);
9433 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
9434 zparts.push(entry);
9435 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));
9436 var cde=new Uint8Array(cda.length+nb.length);
9437 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
9438 zcds.push(cde);zoff+=entry.length;znf++;
9439 });
9440 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
9441 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]);
9442 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
9443 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
9444 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
9445 zout.set(new Uint8Array(ea),zpos);
9446 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
9447 var xurl=URL.createObjectURL(xblob);
9448 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
9449 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
9450 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
9451 }
9452 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;');}
9453 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;}
9454
9455 var _summaryHdrs = ['Metric','Baseline','Current','Delta'];
9456 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 }}'};
9457 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)]];}
9458 var _dh = ['File','Language','Status','Code Before','Code After','Code Delta','Comment Delta','Total Delta'];
9459 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;}
9460 window.exportDeltaCsv = function(){slocCsvMulti(getExportFilename('csv'),[{hdrs:_summaryHdrs,rows:getSummaryExportRows()},{hdrs:_dh,rows:getDeltaExportRows()}]);};
9461 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
9462
9463 // ── Chart HTML report ─────────────────────────────────────────────────────
9464 function slocChartReport(fname, sd, dr) {
9465 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
9466 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
9467 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
9468 function fmt(n){return Number(n).toLocaleString();}
9469 function px(n){return Math.round(n);}
9470 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
9471 // Language map
9472 var lm={};
9473 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;});
9474 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
9475
9476 // Builds onmouse* attrs for interactive tooltip on each SVG element
9477 function barTT(label,val){
9478 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
9479 }
9480
9481 // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
9482 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}];
9483 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
9484 var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
9485 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
9486 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9487 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"/>';}
9488 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
9489 c1mets.forEach(function(m,i){
9490 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
9491 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
9492 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>';
9493 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))+'/>';
9494 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>';
9495 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))+'/>';
9496 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>';
9497 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>';
9498 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>';
9499 });
9500 c1+='</svg>';
9501
9502 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
9503 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}];
9504 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
9505 var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
9506 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
9507 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9508 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9509 mets.forEach(function(m,i){
9510 var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
9511 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
9512 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
9513 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>';
9514 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
9515 if(bw>=52){
9516 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>';
9517 }else{
9518 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
9519 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>';
9520 }
9521 });
9522 c2+='</svg>';
9523
9524 // ── Chart 3: Language Code Delta ─────────────────────────────────────
9525 var c3='';
9526 if(langs.length){
9527 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
9528 var C3W=550,c3LW=124,c3FW=52;
9529 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
9530 var L3rH=30,C3H=langs.length*L3rH+20;
9531 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9532 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9533 langs.forEach(function(l,i){
9534 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
9535 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
9536 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
9537 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
9538 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':''))+'/>';
9539 if(bw>=48){
9540 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>';
9541 }else{
9542 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
9543 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>';
9544 }
9545 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>';
9546 });
9547 c3+='</svg>';
9548 }
9549
9550 // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
9551 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;});
9552 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
9553 var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
9554 var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9555 var ang=-Math.PI/2;
9556 segs.forEach(function(s){
9557 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
9558 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
9559 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
9560 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
9561 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
9562 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)+'%')+'/>';
9563 ang+=sw;
9564 });
9565 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>';
9566 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
9567 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>';});
9568 c4+='</svg>';
9569
9570 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
9571 var ttJs='var tt=document.getElementById("ox-tt");'+
9572 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
9573 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
9574 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
9575 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
9576 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
9577 'function oxHT(){tt.style.display="none";}';
9578
9579 // body max-width keeps charts from inflating beyond design dimensions on
9580 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
9581 // each chart's height blows up proportionally, breaking the one-page layout.
9582 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;}'+
9583 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
9584 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
9585 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
9586 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
9587 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
9588 'svg{display:block;}'+
9589 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
9590 '#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;}'+
9591 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
9592 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
9593 '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
9594 '<div id="ox-tt"><\/div>'+
9595 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
9596 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
9597 '<div class="two-col">'+
9598 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
9599 '<div class="leg"><span><span class="dot" style="background:#AAAAAA"><\/span>Baseline<\/span>'+
9600 '<span><span class="dot" style="background:#C45C10"><\/span>Current<\/span><\/div>'+c1+'<\/div>'+
9601 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
9602 '<\/div>'+
9603 '<div class="two-col">'+
9604 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
9605 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
9606 '<\/div>'+
9607 '<script>'+ttJs+'<\/script>'+
9608 '<\/body><\/html>';
9609 slocDownload(html, fname, 'text/html;charset=utf-8;');
9610 }
9611 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
9612 </script>
9613</body>
9614</html>
9615"##,
9616 ext = "html"
9617)]
9618struct CompareTemplate {
9619 baseline_run_id: String,
9620 current_run_id: String,
9621 baseline_run_id_short: String,
9622 current_run_id_short: String,
9623 baseline_timestamp: String,
9624 current_timestamp: String,
9625 project_path: String,
9626 baseline_code: u64,
9627 current_code: u64,
9628 code_lines_delta_str: String,
9629 code_lines_delta_class: String,
9630 baseline_files: u64,
9631 current_files: u64,
9632 files_analyzed_delta_str: String,
9633 files_analyzed_delta_class: String,
9634 baseline_comments: u64,
9635 current_comments: u64,
9636 comment_lines_delta_str: String,
9637 comment_lines_delta_class: String,
9638 files_added: usize,
9639 files_removed: usize,
9640 files_modified: usize,
9641 files_unchanged: usize,
9642 file_rows: Vec<CompareFileDeltaRow>,
9643 baseline_git_author: Option<String>,
9644 current_git_author: Option<String>,
9645 baseline_git_branch: String,
9646 current_git_branch: String,
9647 baseline_git_tags: Option<String>,
9648 current_git_tags: Option<String>,
9649 csp_nonce: String,
9650}