1#![allow(clippy::multiple_crate_versions)]
4
5static IMG_LOGO_TEXT: &[u8] = include_bytes!("../assets/logo/logo-text.png");
6static IMG_LOGO_SMALL: &[u8] = include_bytes!("../assets/logo/small-logo.png");
7static IMG_ICON_C: &[u8] = include_bytes!("../assets/icons/c.png");
8static IMG_ICON_CPP: &[u8] = include_bytes!("../assets/icons/cpp.png");
9static IMG_ICON_CSHARP: &[u8] = include_bytes!("../assets/icons/c-sharp.png");
10static IMG_ICON_PYTHON: &[u8] = include_bytes!("../assets/icons/python.png");
11static IMG_ICON_SHELL: &[u8] = include_bytes!("../assets/icons/shell.png");
12static IMG_ICON_POWERSHELL: &[u8] = include_bytes!("../assets/icons/powershell.png");
13static IMG_ICON_JAVASCRIPT: &[u8] = include_bytes!("../assets/icons/java-script.png");
14static IMG_ICON_HTML: &[u8] = include_bytes!("../assets/icons/html-5.png");
15static IMG_ICON_JAVA: &[u8] = include_bytes!("../assets/icons/java.png");
16static IMG_ICON_VB: &[u8] = include_bytes!("../assets/icons/visual-basic.png");
17static IMG_ICON_ASSEMBLY: &[u8] = include_bytes!("../assets/icons/asm.png");
18static IMG_ICON_GO: &[u8] = include_bytes!("../assets/icons/go.png");
19static IMG_ICON_R: &[u8] = include_bytes!("../assets/icons/r.png");
20static IMG_ICON_XML: &[u8] = include_bytes!("../assets/icons/xml.png");
21static IMG_ICON_GROOVY: &[u8] = include_bytes!("../assets/icons/groovy.png");
22static IMG_ICON_DOCKERFILE: &[u8] = include_bytes!("../assets/icons/docker.png");
23static IMG_ICON_MAKEFILE: &[u8] = include_bytes!("../assets/icons/makefile.svg");
24static IMG_ICON_PERL: &[u8] = include_bytes!("../assets/icons/perl.svg");
25
26pub(crate) mod git_browser;
27pub(crate) mod git_webhook;
28
29use std::{
30 collections::{HashMap, VecDeque},
31 fmt::Write,
32 fs,
33 net::{IpAddr, SocketAddr},
34 path::{Path, PathBuf},
35 process::Stdio,
36 sync::Arc,
37 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
38};
39
40use anyhow::{Context, Result};
41use askama::Template;
42use axum::{
43 body::Body,
44 extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
45 http::{header, HeaderValue, Request, StatusCode},
46 middleware::{self, Next},
47 response::{Html, IntoResponse, Response},
48 routing::{get, post},
49 Json, Router,
50};
51use serde::{Deserialize, Serialize};
52use tokio::sync::Mutex;
53use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
54
55use sloc_config::{AppConfig, BinaryFileBehavior, MixedLinePolicy};
56use sloc_git::ScheduleStore;
57
58#[derive(Clone)]
59struct CspNonce(String);
60
61static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
62
63use sloc_core::{
64 analyze, compute_delta, read_json, AnalysisRun, FileChangeStatus, RegistryEntry, ScanRegistry,
65 ScanSummarySnapshot, SummaryTotals,
66};
67use sloc_report::{render_html, render_sub_report_html, write_pdf_from_html};
68const MAX_CONCURRENT_ANALYSES: usize = 4;
69
70#[cfg(all(target_os = "windows", feature = "native-dialog"))]
78#[allow(clippy::upper_case_acronyms)]
79mod win_dialog_focus {
80 use std::mem::size_of;
81
82 type HWND = *mut core::ffi::c_void;
83 type DWORD = u32;
84 type UINT = u32;
85 type BOOL = i32;
86
87 #[repr(C)]
91 #[allow(non_snake_case)]
92 struct FLASHWINFO {
93 cbSize: UINT,
94 hwnd: HWND,
95 dwFlags: DWORD,
96 uCount: UINT,
97 dwTimeout: DWORD,
98 }
99
100 const FLASHW_ALL: DWORD = 0x3;
101 const FLASHW_TIMERNOFG: DWORD = 0xC;
102
103 #[link(name = "user32")]
104 extern "system" {
105 fn GetForegroundWindow() -> HWND;
106 fn SetForegroundWindow(hWnd: HWND) -> BOOL;
107 fn BringWindowToTop(hWnd: HWND) -> BOOL;
108 fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
109 fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
110 fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
111 fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
112 }
113
114 #[link(name = "kernel32")]
115 extern "system" {
116 fn GetCurrentThreadId() -> DWORD;
117 }
118
119 pub fn attach_to_foreground() -> DWORD {
124 unsafe {
125 let fg_hwnd = GetForegroundWindow();
126 if fg_hwnd.is_null() {
127 return 0;
128 }
129 let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
130 let my_tid = GetCurrentThreadId();
131 if fg_tid == my_tid {
132 return 0;
133 }
134 AttachThreadInput(my_tid, fg_tid, 1);
135 fg_tid
136 }
137 }
138
139 pub fn detach_from_foreground(fg_tid: DWORD) {
141 if fg_tid == 0 {
142 return;
143 }
144 unsafe {
145 AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
146 }
147 }
148
149 pub fn flash_dialog_when_ready(title: String) {
153 std::thread::spawn(move || {
154 let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
155 for _ in 0..40 {
156 std::thread::sleep(std::time::Duration::from_millis(80));
157 unsafe {
158 let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
159 if !hwnd.is_null() {
160 SetForegroundWindow(hwnd);
161 BringWindowToTop(hwnd);
162 #[allow(non_snake_case)]
163 FlashWindowEx(&FLASHWINFO {
164 cbSize: size_of::<FLASHWINFO>() as UINT,
165 hwnd,
166 dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
167 uCount: 3,
168 dwTimeout: 0,
169 });
170 break;
171 }
172 }
173 }
174 });
175 }
176}
177
178struct IpRateLimiter {
181 window: Duration,
182 max_requests: usize,
183 auth_lockout_threshold: u32,
184 auth_lockout_window: Duration,
185 state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
186 auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
187}
188
189impl IpRateLimiter {
190 fn new(
191 window: Duration,
192 max_requests: usize,
193 auth_lockout_threshold: u32,
194 auth_lockout_window: Duration,
195 ) -> Self {
196 Self {
197 window,
198 max_requests,
199 auth_lockout_threshold,
200 auth_lockout_window,
201 state: std::sync::Mutex::new(HashMap::new()),
202 auth_failures: std::sync::Mutex::new(HashMap::new()),
203 }
204 }
205
206 #[allow(clippy::significant_drop_tightening)]
209 fn is_allowed(&self, ip: IpAddr) -> bool {
210 let now = Instant::now();
211 let cutoff = now.checked_sub(self.window).unwrap_or(now);
212 let mut state = self
213 .state
214 .lock()
215 .unwrap_or_else(std::sync::PoisonError::into_inner);
216 if state.len() > 10_000 {
217 state.retain(|_, bucket| {
218 while bucket.front().is_some_and(|t| *t <= cutoff) {
219 bucket.pop_front();
220 }
221 !bucket.is_empty()
222 });
223 }
224 let bucket = state.entry(ip).or_default();
225 while bucket.front().is_some_and(|t| *t <= cutoff) {
226 bucket.pop_front();
227 }
228 if bucket.len() >= self.max_requests {
229 false
230 } else {
231 bucket.push_back(now);
232 true
233 }
234 }
235
236 fn record_auth_failure(&self, ip: IpAddr) {
237 let now = Instant::now();
238 let mut map = self
239 .auth_failures
240 .lock()
241 .unwrap_or_else(std::sync::PoisonError::into_inner);
242 map.entry(ip)
243 .and_modify(|e| {
244 e.0 += 1;
245 e.1 = now;
246 })
247 .or_insert_with(|| (1, now));
248 }
249
250 fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
251 let mut map = self
252 .auth_failures
253 .lock()
254 .unwrap_or_else(std::sync::PoisonError::into_inner);
255 let expired = map
256 .get(&ip)
257 .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
258 if expired {
259 map.remove(&ip);
260 return false;
261 }
262 map.get(&ip)
263 .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
264 }
265
266 fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
267 let map = self
268 .auth_failures
269 .lock()
270 .unwrap_or_else(std::sync::PoisonError::into_inner);
271 map.get(&ip).map_or(0, |e| {
272 self.auth_lockout_window
273 .checked_sub(e.1.elapsed())
274 .map_or(0, |r| r.as_secs())
275 })
276 }
277}
278
279#[derive(Clone, Debug, Default)]
281struct RunResultContext {
282 prev_entry: Option<RegistryEntry>,
283 prev_scan_count: usize,
284 project_path: String,
285}
286
287#[derive(Clone)]
289enum AsyncRunState {
290 Running {
291 started_at: std::time::Instant,
292 },
293 Complete {
295 run_id: String,
296 },
297 Failed {
298 message: String,
299 },
300}
301
302#[derive(Clone)]
303struct AppState {
304 base_config: AppConfig,
305 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
306 async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
307 registry: Arc<Mutex<ScanRegistry>>,
308 registry_path: PathBuf,
309 analyze_semaphore: Arc<tokio::sync::Semaphore>,
310 server_mode: bool,
311 tls_enabled: bool,
312 api_keys: Vec<secrecy::Secret<String>>,
313 rate_limiter: Arc<IpRateLimiter>,
314 trust_proxy: bool,
315 git_clones_dir: PathBuf,
317 schedules: Arc<Mutex<ScheduleStore>>,
319 schedules_path: PathBuf,
320}
321
322type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
323
324#[derive(Clone, Debug)]
327pub(crate) struct RunArtifacts {
328 output_dir: PathBuf,
329 html_path: Option<PathBuf>,
330 pdf_path: Option<PathBuf>,
331 json_path: Option<PathBuf>,
332 scan_config_path: Option<PathBuf>,
333 report_title: String,
334 result_context: RunResultContext,
335}
336
337fn build_router(state: AppState) -> Router {
338 let protected = Router::new()
339 .route("/", get(splash))
340 .route("/scan-setup", get(scan_setup_handler))
341 .route("/scan", get(index))
342 .route("/analyze", post(analyze_handler))
343 .route("/preview", get(preview_handler))
344 .route("/pick-directory", get(pick_directory_handler))
345 .route("/open-path", get(open_path_handler))
346 .route("/pick-file", get(pick_file_handler))
347 .route("/locate-report", post(locate_report_handler))
348 .route("/locate-reports-dir", post(locate_reports_dir_handler))
349 .route("/view-reports", get(history_handler))
350 .route("/compare-scans", get(compare_select_handler))
351 .route("/compare", get(compare_handler))
352 .route("/images/{folder}/{file}", get(image_handler))
353 .route("/runs/{run_id}/{artifact}", get(artifact_handler))
354 .route("/api/metrics/latest", get(api_metrics_latest_handler))
355 .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
356 .route("/api/project-history", get(project_history_handler))
357 .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
358 .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
359 .route("/runs/{run_id}/result", get(async_run_result_handler))
360 .route("/embed/summary", get(embed_handler))
361 .route("/git-browser", get(git_browser::git_browser_handler))
363 .route("/api/git/refs", get(git_browser::api_list_refs))
364 .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
365 .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
366 .route("/webhook-setup", get(git_webhook::webhook_setup_handler))
368 .route("/api/schedules", get(git_webhook::api_list_schedules))
369 .route("/api/schedules", post(git_webhook::api_create_schedule))
370 .route(
371 "/api/schedules",
372 axum::routing::delete(git_webhook::api_delete_schedule),
373 )
374 .route_layer(middleware::from_fn_with_state(
375 state.clone(),
376 require_api_key,
377 ));
378
379 protected
380 .route("/healthz", get(healthz))
381 .route("/badge/{metric}", get(badge_handler))
382 .route("/static/chart.js", get(chart_js_handler))
383 .route("/auth/login", get(auth_login_get))
384 .route("/auth/login", post(auth_login_post))
385 .route("/webhooks/github", post(git_webhook::handle_github_webhook))
387 .route("/webhooks/gitlab", post(git_webhook::handle_gitlab_webhook))
388 .route(
389 "/webhooks/bitbucket",
390 post(git_webhook::handle_bitbucket_webhook),
391 )
392 .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
393 .layer(middleware::from_fn_with_state(
394 state.clone(),
395 add_security_headers,
396 ))
397 .layer(build_cors_layer(state.server_mode))
398 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
399 .with_state(state)
400}
401
402pub fn make_test_router() -> Router {
404 let tmp = std::env::temp_dir().join("sloc_test");
405 let state = AppState {
406 base_config: AppConfig::default(),
407 artifacts: Arc::new(Mutex::new(HashMap::new())),
408 async_runs: Arc::new(Mutex::new(HashMap::new())),
409 registry: Arc::new(Mutex::new(ScanRegistry::default())),
410 registry_path: tmp.join("registry.json"),
411 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
412 server_mode: false,
413 tls_enabled: false,
414 api_keys: vec![],
415 rate_limiter: Arc::new(IpRateLimiter::new(
416 Duration::from_secs(60),
417 600,
418 10,
419 Duration::from_secs(3600),
420 )),
421 trust_proxy: false,
422 git_clones_dir: tmp.join("git-clones"),
423 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
424 schedules_path: tmp.join("schedules.json"),
425 };
426 build_router(state)
427}
428
429#[allow(clippy::too_many_lines)]
440pub async fn serve(config: AppConfig) -> Result<()> {
441 let bind_address = config.web.bind_address.clone();
442 let server_mode = config.web.server_mode;
443 let output_root = resolve_output_root(None);
444 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
446 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
447 let mut registry = ScanRegistry::load(®istry_path);
448 registry.prune_stale();
449 let _ = registry.save(®istry_path);
450
451 let api_keys: Vec<secrecy::Secret<String>> = std::env::var("SLOC_API_KEYS")
452 .or_else(|_| std::env::var("SLOC_API_KEY"))
453 .unwrap_or_default()
454 .split(',')
455 .map(str::trim)
456 .filter(|s| !s.is_empty())
457 .map(|s| secrecy::Secret::new(s.to_owned()))
458 .collect();
459 if server_mode && api_keys.is_empty() {
460 println!(
461 "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
462 unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
463 );
464 }
465
466 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
467 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
468 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
469 if server_mode && !tls_enabled {
470 println!(
471 "WARNING: TLS is not configured. Traffic is cleartext. \
472 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
473 or terminate TLS at a reverse proxy (nginx, caddy)."
474 );
475 }
476 if server_mode {
477 println!(
478 "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
479 to restrict cross-origin access (comma-separated)."
480 );
481 }
482 let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
483 if trust_proxy {
484 println!(
485 "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For header is trusted for rate limiting. \
486 Only set this when oxide-sloc is behind a trusted reverse proxy."
487 );
488 }
489
490 let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
491 .ok()
492 .and_then(|v| v.parse::<u32>().ok())
493 .unwrap_or(10);
494 let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
495 .ok()
496 .and_then(|v| v.parse::<u64>().ok())
497 .unwrap_or(3600);
498 let rate_limiter = Arc::new(IpRateLimiter::new(
500 Duration::from_mins(1),
501 600,
502 auth_lockout_threshold,
503 Duration::from_secs(auth_lockout_secs),
504 ));
505
506 let git_clones_dir = resolve_git_clones_dir(&output_root);
507 let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
508 .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
509 let schedules = ScheduleStore::load(&schedules_path);
510
511 let state = AppState {
512 base_config: config,
513 artifacts: Arc::new(Mutex::new(HashMap::new())),
514 async_runs: Arc::new(Mutex::new(HashMap::new())),
515 registry: Arc::new(Mutex::new(registry)),
516 registry_path,
517 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
518 server_mode,
519 tls_enabled,
520 api_keys,
521 rate_limiter,
522 trust_proxy,
523 git_clones_dir,
524 schedules: Arc::new(Mutex::new(schedules)),
525 schedules_path,
526 };
527
528 restart_poll_schedules(&state).await;
529
530 let app = build_router(state.clone());
531
532 let preferred: SocketAddr = bind_address
537 .parse()
538 .with_context(|| format!("invalid bind address: {bind_address}"))?;
539 let (listener, addr) = {
540 let candidates = (0u16..=9).map(|offset| {
541 let mut a = preferred;
542 a.set_port(preferred.port().saturating_add(offset));
543 a
544 });
545 let mut found = None;
546 for candidate in candidates {
547 if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
548 found = Some((l, candidate));
549 break;
550 }
551 }
552 found.ok_or_else(|| {
553 anyhow::anyhow!(
554 "failed to bind local web UI on {} (tried ports {}-{}): all in use",
555 bind_address,
556 preferred.port(),
557 preferred.port().saturating_add(9)
558 )
559 })?
560 };
561 if addr != preferred {
562 eprintln!(
563 "NOTE: port {} is blocked by a system socket (Windows zombie); \
564 using {} instead.",
565 preferred.port(),
566 addr.port()
567 );
568 }
569
570 if tls_enabled {
571 let cert_path = tls_cert.expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
572 let key_path = tls_key.expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
573 let tls_config = build_tls_config(&cert_path, &key_path)
574 .context("failed to load TLS certificate/key")?;
575 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
576
577 let url = format!("https://{addr}/");
578 println!("OxideSLOC server running at {url} (TLS)");
579 println!("Use Ctrl+C to stop.");
580
581 return serve_tls(listener, app, acceptor, server_mode).await;
582 }
583
584 let url = format!("http://{addr}/");
585 log_startup_url(&url, server_mode);
586
587 axum::serve(
588 listener,
589 app.into_make_service_with_connect_info::<SocketAddr>(),
590 )
591 .with_graceful_shutdown(shutdown_signal(server_mode))
592 .await
593 .context("web server terminated unexpectedly")
594}
595
596fn primary_lan_ip() -> Option<String> {
600 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
601 socket.connect("8.8.8.8:80").ok()?;
602 let addr = socket.local_addr().ok()?;
603 let ip = addr.ip();
604 if ip.is_loopback() {
605 return None;
606 }
607 Some(ip.to_string())
608}
609
610fn log_startup_url(url: &str, server_mode: bool) {
612 if server_mode {
613 println!("OxideSLOC server running at {url}");
614 println!("Use Ctrl+C to stop.");
615 } else {
616 println!("OxideSLOC local web UI running at {url}");
617 println!("Press Ctrl+C to stop the server.");
618 let open_url = url.to_owned();
619 tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
620 }
621}
622
623fn open_browser_tab(url: &str) {
625 #[cfg(target_os = "windows")]
626 let _ = std::process::Command::new("cmd")
627 .args(["/c", "start", "", url])
628 .stdout(Stdio::null())
629 .stderr(Stdio::null())
630 .spawn();
631 #[cfg(target_os = "macos")]
632 let _ = std::process::Command::new("open")
633 .arg(url)
634 .stdout(Stdio::null())
635 .stderr(Stdio::null())
636 .spawn();
637 #[cfg(target_os = "linux")]
638 let _ = std::process::Command::new("xdg-open")
639 .arg(url)
640 .stdout(Stdio::null())
641 .stderr(Stdio::null())
642 .spawn();
643}
644
645async fn shutdown_signal(server_mode: bool) {
647 if tokio::signal::ctrl_c().await.is_ok() {
648 println!();
649 if server_mode {
650 println!("Shutting down OxideSLOC server...");
651 } else {
652 println!("Shutting down OxideSLOC local web UI...");
653 }
654 println!("Server stopped cleanly.");
655 }
656}
657
658fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
660 use rustls_pemfile::{certs, private_key};
661 use std::io::BufReader;
662
663 let cert_bytes =
664 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
665 let key_bytes =
666 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
667
668 let cert_chain: Vec<_> = certs(&mut BufReader::new(cert_bytes.as_slice()))
669 .collect::<std::result::Result<_, _>>()
670 .context("failed to parse TLS certificates")?;
671
672 let key = private_key(&mut BufReader::new(key_bytes.as_slice()))
673 .context("failed to parse TLS private key")?
674 .ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
675
676 rustls::ServerConfig::builder()
677 .with_no_client_auth()
678 .with_single_cert(cert_chain, key)
679 .context("failed to build TLS server config")
680}
681
682async fn serve_tls(
684 listener: tokio::net::TcpListener,
685 app: Router,
686 acceptor: tokio_rustls::TlsAcceptor,
687 server_mode: bool,
688) -> Result<()> {
689 use hyper_util::rt::{TokioExecutor, TokioIo};
690 use hyper_util::server::conn::auto::Builder as ConnBuilder;
691 use hyper_util::service::TowerToHyperService;
692 use tower::{Service, ServiceExt};
693
694 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
695
696 loop {
697 tokio::select! {
698 biased;
699 _ = tokio::signal::ctrl_c() => {
700 println!();
701 if server_mode {
702 println!("Shutting down OxideSLOC server...");
703 } else {
704 println!("Shutting down OxideSLOC local web UI...");
705 }
706 println!("Server stopped cleanly.");
707 return Ok(());
708 }
709 result = listener.accept() => {
710 let (tcp, peer_addr) = result.context("TLS accept failed")?;
711 let acceptor = acceptor.clone();
712 let mut factory = make_svc.clone();
713
714 tokio::spawn(async move {
715 let tls = match acceptor.accept(tcp).await {
716 Ok(s) => s,
717 Err(e) => {
718 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
719 return;
720 }
721 };
722 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
723 Ok(f) => match Service::call(f, peer_addr).await {
724 Ok(s) => s,
725 Err(_) => return,
726 },
727 Err(_) => return,
728 };
729 let io = TokioIo::new(tls);
730 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
731 .serve_connection(io, TowerToHyperService::new(svc))
732 .await
733 {
734 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
735 }
736 });
737 }
738 }
739 }
740}
741
742async fn require_api_key(
743 State(state): State<AppState>,
744 req: Request<Body>,
745 next: Next,
746) -> Response {
747 if state.api_keys.is_empty() {
748 return next.run(req).await;
749 }
750
751 let keys = &state.api_keys;
752 let peer_ip = req
753 .extensions()
754 .get::<axum::extract::ConnectInfo<SocketAddr>>()
755 .map_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), |c| c.0.ip());
756
757 let auth_header = req
759 .headers()
760 .get(header::AUTHORIZATION)
761 .and_then(|v| v.to_str().ok())
762 .and_then(|v| v.strip_prefix("Bearer "))
763 .map(str::to_owned);
764 let x_api_key = req
765 .headers()
766 .get("X-API-Key")
767 .and_then(|v| v.to_str().ok())
768 .map(str::to_owned);
769 let session_cookie = req
770 .headers()
771 .get(header::COOKIE)
772 .and_then(|v| v.to_str().ok())
773 .and_then(extract_session_cookie)
774 .map(str::to_owned);
775
776 let any_credential_provided =
777 auth_header.is_some() || x_api_key.is_some() || session_cookie.is_some();
778
779 let valid = [&auth_header, &x_api_key, &session_cookie]
780 .iter()
781 .filter_map(|o| o.as_deref())
782 .any(|k| {
783 keys.iter().any(|expected| {
784 use secrecy::ExposeSecret;
785 ct_eq(k, expected.expose_secret())
786 })
787 });
788
789 if valid {
790 return next.run(req).await;
791 }
792
793 if state.rate_limiter.is_auth_locked_out(peer_ip) {
794 tracing::warn!(event = "auth_lockout", peer_addr = %peer_ip,
795 "Authentication locked out after repeated failures");
796 let remaining = state.rate_limiter.auth_lockout_remaining_secs(peer_ip);
797 let retry_after = HeaderValue::from_str(&remaining.to_string())
798 .unwrap_or(HeaderValue::from_static("3600"));
799 if is_browser_request(&req) {
800 let minutes = remaining.div_ceil(60).max(1);
801 let s = if minutes == 1 { "" } else { "s" };
802 let body = format!(
803 r#"<!doctype html><html><head><meta charset="utf-8">
804<title>Locked Out — OxideSLOC</title>
805<style>body{{font-family:system-ui,sans-serif;max-width:520px;margin:80px auto;padding:0 24px;color:#2f241c}}
806h1{{color:#b85d33}}p{{line-height:1.6}}code{{background:#f3e9e0;padding:2px 6px;border-radius:4px}}</style>
807</head><body>
808<h1>Too many failed sign-in attempts</h1>
809<p>Access from your IP is temporarily locked. Lockout expires in approximately
810<strong>{minutes} minute{s}</strong>.</p>
811<p>To clear immediately, restart the server.</p>
812<p>For trusted LAN testing, leave <code>SLOC_API_KEY</code> unset, or raise the
813threshold via <code>SLOC_AUTH_LOCKOUT_FAILS</code> / <code>SLOC_AUTH_LOCKOUT_SECS</code>.</p>
814</body></html>"#
815 );
816 let mut resp = (StatusCode::TOO_MANY_REQUESTS, Html(body)).into_response();
817 resp.headers_mut().insert(header::RETRY_AFTER, retry_after);
818 return resp;
819 }
820 let mut resp = (
821 StatusCode::TOO_MANY_REQUESTS,
822 format!("429 Too Many Requests — locked out, retry in {remaining}s\n"),
823 )
824 .into_response();
825 resp.headers_mut().insert(header::RETRY_AFTER, retry_after);
826 return resp;
827 }
828
829 if any_credential_provided {
830 state.rate_limiter.record_auth_failure(peer_ip);
832 let path = req.uri().path().to_owned();
833 tracing::warn!(event = "auth_failure", peer_addr = %peer_ip, path = %path,
834 "API key authentication failed");
835 return (
836 StatusCode::UNAUTHORIZED,
837 [(header::WWW_AUTHENTICATE, "Bearer realm=\"oxide-sloc\"")],
838 "401 Unauthorized\n",
839 )
840 .into_response();
841 }
842
843 if is_browser_request(&req) {
847 let next_path = req.uri().path_and_query().map_or("/", |pq| pq.as_str());
848 let login_url = format!("/auth/login?next={}", urlencode_path(next_path));
849 let location = HeaderValue::from_str(&login_url)
850 .unwrap_or_else(|_| HeaderValue::from_static("/auth/login"));
851 let mut resp = StatusCode::FOUND.into_response();
852 resp.headers_mut().insert(header::LOCATION, location);
853 return resp;
854 }
855
856 (
857 StatusCode::UNAUTHORIZED,
858 [(header::WWW_AUTHENTICATE, "Bearer realm=\"oxide-sloc\"")],
859 "401 Unauthorized\n",
860 )
861 .into_response()
862}
863
864fn ct_eq(a: &str, b: &str) -> bool {
865 use subtle::ConstantTimeEq;
866 a.as_bytes().ct_eq(b.as_bytes()).into()
867}
868
869fn extract_session_cookie(cookie_header: &str) -> Option<&str> {
870 cookie_header.split(';').find_map(|pair| {
871 let pair = pair.trim();
872 let (k, v) = pair.split_once('=')?;
873 if k.trim() == "sloc_session" {
874 Some(v.trim())
875 } else {
876 None
877 }
878 })
879}
880
881fn is_browser_request(req: &Request<Body>) -> bool {
882 req.headers()
883 .get(header::ACCEPT)
884 .and_then(|v| v.to_str().ok())
885 .is_some_and(|a| a.contains("text/html"))
886}
887
888fn urlencode_path(s: &str) -> String {
889 let mut out = String::with_capacity(s.len());
890 for b in s.bytes() {
891 match b {
892 b'A'..=b'Z'
893 | b'a'..=b'z'
894 | b'0'..=b'9'
895 | b'-'
896 | b'_'
897 | b'.'
898 | b'~'
899 | b'/'
900 | b'?'
901 | b'='
902 | b'&'
903 | b'#' => {
904 out.push(b as char);
905 }
906 _ => {
907 use std::fmt::Write as _;
908 write!(&mut out, "%{b:02X}").ok();
909 }
910 }
911 }
912 out
913}
914
915#[derive(serde::Deserialize)]
918struct LoginQuery {
919 next: Option<String>,
920 error: Option<String>,
921}
922
923#[derive(serde::Deserialize)]
924struct LoginFormData {
925 key: String,
926 next: Option<String>,
927}
928
929async fn auth_login_get(
930 State(state): State<AppState>,
931 Query(query): Query<LoginQuery>,
932 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
933) -> Response {
934 if state.api_keys.is_empty() {
935 let mut resp = StatusCode::FOUND.into_response();
936 resp.headers_mut()
937 .insert(header::LOCATION, HeaderValue::from_static("/"));
938 return resp;
939 }
940 let has_error = query.error.as_deref() == Some("1");
941 let next_url = query.next.unwrap_or_default();
942 let lockout_threshold = state.rate_limiter.auth_lockout_threshold;
943 Html(
944 LoginTemplate {
945 csp_nonce,
946 has_error,
947 next_url,
948 lockout_threshold,
949 }
950 .render()
951 .unwrap_or_else(|e| format!("<pre>Template error: {e}</pre>")),
952 )
953 .into_response()
954}
955
956async fn auth_login_post(
957 State(state): State<AppState>,
958 axum::extract::ConnectInfo(peer_addr): axum::extract::ConnectInfo<SocketAddr>,
959 Form(form): Form<LoginFormData>,
960) -> Response {
961 let peer_ip = peer_addr.ip();
962 let next_url = form
963 .next
964 .as_deref()
965 .filter(|s| !s.is_empty())
966 .unwrap_or("/");
967 let safe_next = if next_url.starts_with('/') {
968 next_url
969 } else {
970 "/"
971 };
972
973 let valid = state.api_keys.iter().any(|expected| {
974 use secrecy::ExposeSecret;
975 ct_eq(&form.key, expected.expose_secret())
976 });
977
978 if valid {
979 let secure_flag = if state.tls_enabled { "; Secure" } else { "" };
980 let cookie_value = format!(
981 "sloc_session={}; Path=/; HttpOnly; SameSite=Strict{}",
982 form.key, secure_flag,
983 );
984 let location =
985 HeaderValue::from_str(safe_next).unwrap_or_else(|_| HeaderValue::from_static("/"));
986 let cookie_hv = HeaderValue::from_str(&cookie_value)
987 .unwrap_or_else(|_| HeaderValue::from_static("sloc_session=; Path=/; HttpOnly"));
988 let mut resp = StatusCode::FOUND.into_response();
989 resp.headers_mut().insert(header::LOCATION, location);
990 resp.headers_mut().insert(header::SET_COOKIE, cookie_hv);
991 resp
992 } else {
993 state.rate_limiter.record_auth_failure(peer_ip);
994 tracing::warn!(event = "auth_failure", peer_addr = %peer_ip, path = "/auth/login",
995 "Login form authentication failed");
996 let error_url = format!("/auth/login?next={}&error=1", urlencode_path(safe_next));
997 let location = HeaderValue::from_str(&error_url)
998 .unwrap_or_else(|_| HeaderValue::from_static("/auth/login?error=1"));
999 let mut resp = StatusCode::FOUND.into_response();
1000 resp.headers_mut().insert(header::LOCATION, location);
1001 resp
1002 }
1003}
1004
1005fn build_cors_layer(server_mode: bool) -> CorsLayer {
1006 if server_mode {
1007 let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1008 .unwrap_or_default()
1009 .split(',')
1010 .filter(|s| !s.is_empty())
1011 .filter_map(|s| s.trim().parse().ok())
1012 .collect();
1013 if allowed.is_empty() {
1014 return CorsLayer::new();
1015 }
1016 CorsLayer::new()
1017 .allow_origin(AllowOrigin::list(allowed))
1018 .allow_methods(AllowMethods::list([
1019 axum::http::Method::GET,
1020 axum::http::Method::POST,
1021 ]))
1022 .allow_headers(AllowHeaders::list([
1023 axum::http::header::AUTHORIZATION,
1024 axum::http::header::CONTENT_TYPE,
1025 ]))
1026 } else {
1027 CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1028 let s = origin.to_str().unwrap_or("");
1029 s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1030 }))
1031 }
1032}
1033
1034async fn add_security_headers(
1035 State(state): State<AppState>,
1036 mut req: Request<Body>,
1037 next: Next,
1038) -> Response {
1039 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1040 req.extensions_mut().insert(CspNonce(nonce.clone()));
1041 let mut resp = next.run(req).await;
1042 let h = resp.headers_mut();
1043 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1044 h.insert(
1045 "X-Content-Type-Options",
1046 HeaderValue::from_static("nosniff"),
1047 );
1048 h.insert(
1049 "Referrer-Policy",
1050 HeaderValue::from_static("strict-origin-when-cross-origin"),
1051 );
1052 let csp = format!(
1053 "default-src 'self'; \
1054 style-src 'self' 'nonce-{nonce}'; \
1055 img-src 'self' data: blob:; \
1056 script-src 'self' 'nonce-{nonce}'; \
1057 font-src 'self' data:; \
1058 object-src 'none'; \
1059 frame-ancestors 'none'"
1060 );
1061 h.insert(
1062 "Content-Security-Policy",
1063 HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1064 HeaderValue::from_static(
1065 "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1066 )
1067 }),
1068 );
1069 h.insert(
1070 "X-Permitted-Cross-Domain-Policies",
1071 HeaderValue::from_static("none"),
1072 );
1073 h.insert(
1074 "Permissions-Policy",
1075 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1076 );
1077 h.insert(
1078 "Cross-Origin-Opener-Policy",
1079 HeaderValue::from_static("same-origin"),
1080 );
1081 h.insert(
1082 "Cross-Origin-Resource-Policy",
1083 HeaderValue::from_static("same-origin"),
1084 );
1085 if state.tls_enabled {
1086 h.insert(
1087 "Strict-Transport-Security",
1088 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1089 );
1090 }
1091 resp
1092}
1093
1094async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1095 let ip = req
1096 .extensions()
1097 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1098 .map(|c| c.0.ip())
1099 .or_else(|| {
1100 if state.trust_proxy {
1101 req.headers()
1102 .get("X-Forwarded-For")
1103 .and_then(|v| v.to_str().ok())
1104 .and_then(|s| s.split(',').next())
1105 .and_then(|s| s.trim().parse::<IpAddr>().ok())
1106 } else {
1107 None
1108 }
1109 })
1110 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1111
1112 if !state.rate_limiter.is_allowed(ip) {
1113 tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1114 path = %req.uri().path(), "Rate limit exceeded");
1115 return (
1116 StatusCode::TOO_MANY_REQUESTS,
1117 [(header::RETRY_AFTER, "60")],
1118 "429 Too Many Requests\n",
1119 )
1120 .into_response();
1121 }
1122 next.run(req).await
1123}
1124
1125async fn splash(
1126 State(state): State<AppState>,
1127 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1128) -> impl IntoResponse {
1129 let lan_ip = if state.server_mode {
1130 primary_lan_ip()
1131 } else {
1132 None
1133 };
1134 let port = state
1135 .base_config
1136 .web
1137 .bind_address
1138 .rsplit(':')
1139 .next()
1140 .and_then(|p| p.parse::<u16>().ok())
1141 .unwrap_or(4317);
1142 let template = SplashTemplate {
1143 csp_nonce,
1144 server_mode: state.server_mode,
1145 lan_ip,
1146 port,
1147 version: env!("CARGO_PKG_VERSION"),
1148 };
1149 Html(
1150 template
1151 .render()
1152 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1153 )
1154}
1155
1156async fn index(
1157 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1158 Query(query): Query<IndexQuery>,
1159) -> impl IntoResponse {
1160 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1161 let policy = query
1162 .mixed_line_policy
1163 .unwrap_or_else(|| "code_only".to_string());
1164 let behavior = query
1165 .binary_file_behavior
1166 .unwrap_or_else(|| "skip".to_string());
1167 let cfg = ScanConfig {
1168 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1169 path: query.path.unwrap_or_default(),
1170 include_globs: query.include_globs.unwrap_or_default(),
1171 exclude_globs: query.exclude_globs.unwrap_or_default(),
1172 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1173 mixed_line_policy: policy,
1174 python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1175 != Some("off"),
1176 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1177 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1178 vendor_directory_detection: query.vendor_directory_detection.as_deref()
1179 != Some("disabled"),
1180 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1181 binary_file_behavior: behavior,
1182 output_dir: query.output_dir.unwrap_or_default(),
1183 report_title: query.report_title.unwrap_or_default(),
1184 generate_html: query.generate_html.as_deref() != Some("off"),
1185 generate_pdf: query.generate_pdf.as_deref() == Some("on"),
1186 };
1187 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1188 } else {
1189 "{}".to_string()
1190 };
1191
1192 let git_repo = query.git_repo.unwrap_or_default();
1193 let git_ref = query.git_ref.unwrap_or_default();
1194
1195 let git_label = make_git_label(&git_repo, &git_ref);
1196 let git_output_dir = if git_label.is_empty() {
1197 String::new()
1198 } else {
1199 desktop_dir().join(&git_label).display().to_string()
1200 };
1201 let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1202 let git_output_dir_json =
1203 serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1204
1205 let template = IndexTemplate {
1206 version: env!("CARGO_PKG_VERSION"),
1207 prefill_json,
1208 csp_nonce,
1209 git_repo,
1210 git_ref,
1211 git_label_json,
1212 git_output_dir_json,
1213 };
1214
1215 Html(
1216 template
1217 .render()
1218 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1219 )
1220}
1221
1222async fn scan_setup_handler(
1223 State(state): State<AppState>,
1224 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1225) -> impl IntoResponse {
1226 let recent_scans_json = {
1227 let arr: Vec<serde_json::Value> = {
1228 let reg = state.registry.lock().await;
1229 reg.entries
1230 .iter()
1231 .rev()
1232 .take(6)
1233 .map(|e| {
1234 let run_dir = e
1235 .html_path
1236 .as_ref()
1237 .or(e.json_path.as_ref())
1238 .and_then(|p| p.parent().map(PathBuf::from));
1239 let config_val: Option<serde_json::Value> = run_dir
1240 .and_then(|d| find_scan_config_in_dir(&d))
1241 .and_then(|p| fs::read_to_string(&p).ok())
1242 .and_then(|s| serde_json::from_str(&s).ok());
1243 serde_json::json!({
1244 "project_label": e.project_label,
1245 "timestamp": fmt_pst(e.timestamp_utc),
1246 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
1247 "config": config_val,
1248 })
1249 })
1250 .collect()
1251 };
1252 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
1253 };
1254
1255 let template = ScanSetupTemplate {
1256 version: env!("CARGO_PKG_VERSION"),
1257 recent_scans_json,
1258 csp_nonce,
1259 };
1260 Html(
1261 template
1262 .render()
1263 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1264 )
1265}
1266
1267async fn healthz() -> &'static str {
1268 "ok"
1269}
1270
1271async fn chart_js_handler() -> impl IntoResponse {
1272 (
1273 [(
1274 header::CONTENT_TYPE,
1275 "application/javascript; charset=utf-8",
1276 )],
1277 CHART_JS,
1278 )
1279}
1280
1281#[derive(Debug, Deserialize)]
1282struct AnalyzeForm {
1283 path: String,
1284 git_repo: Option<String>,
1285 git_ref: Option<String>,
1286 mixed_line_policy: Option<MixedLinePolicy>,
1287 python_docstrings_as_comments: Option<String>,
1288 generated_file_detection: Option<String>,
1289 minified_file_detection: Option<String>,
1290 vendor_directory_detection: Option<String>,
1291 include_lockfiles: Option<String>,
1292 binary_file_behavior: Option<BinaryFileBehavior>,
1293 output_dir: Option<String>,
1294 report_title: Option<String>,
1295 generate_html: Option<String>,
1296 generate_pdf: Option<String>,
1297 include_globs: Option<String>,
1298 exclude_globs: Option<String>,
1299 submodule_breakdown: Option<String>,
1300}
1301
1302#[allow(clippy::struct_excessive_bools)]
1303#[derive(Debug, Serialize, Deserialize, Clone)]
1304struct ScanConfig {
1305 oxide_sloc_version: String,
1306 path: String,
1307 include_globs: String,
1308 exclude_globs: String,
1309 submodule_breakdown: bool,
1310 mixed_line_policy: String,
1311 python_docstrings_as_comments: bool,
1312 generated_file_detection: bool,
1313 minified_file_detection: bool,
1314 vendor_directory_detection: bool,
1315 include_lockfiles: bool,
1316 binary_file_behavior: String,
1317 output_dir: String,
1318 report_title: String,
1319 generate_html: bool,
1320 generate_pdf: bool,
1321}
1322
1323#[derive(Debug, Deserialize, Default)]
1324struct IndexQuery {
1325 path: Option<String>,
1326 include_globs: Option<String>,
1327 exclude_globs: Option<String>,
1328 submodule_breakdown: Option<String>,
1329 mixed_line_policy: Option<String>,
1330 python_docstrings_as_comments: Option<String>,
1331 generated_file_detection: Option<String>,
1332 minified_file_detection: Option<String>,
1333 vendor_directory_detection: Option<String>,
1334 include_lockfiles: Option<String>,
1335 binary_file_behavior: Option<String>,
1336 output_dir: Option<String>,
1337 report_title: Option<String>,
1338 generate_html: Option<String>,
1339 generate_pdf: Option<String>,
1340 prefilled: Option<String>,
1341 git_repo: Option<String>,
1342 git_ref: Option<String>,
1343}
1344
1345#[derive(Debug, Deserialize)]
1346struct PreviewQuery {
1347 path: Option<String>,
1348 include_globs: Option<String>,
1349 exclude_globs: Option<String>,
1350}
1351
1352#[cfg(feature = "native-dialog")]
1353#[derive(Debug, Deserialize)]
1354struct PickDirectoryQuery {
1355 kind: Option<String>,
1356 current: Option<String>,
1357}
1358
1359#[cfg(not(feature = "native-dialog"))]
1360#[derive(Debug, Deserialize)]
1361struct PickDirectoryQuery {}
1362
1363#[derive(Debug, Deserialize, Default)]
1364struct ArtifactQuery {
1365 download: Option<String>,
1366}
1367
1368#[cfg(feature = "native-dialog")]
1369#[derive(Debug, Serialize)]
1370struct PickDirectoryResponse {
1371 selected_path: Option<String>,
1372 cancelled: bool,
1373}
1374
1375#[cfg(feature = "native-dialog")]
1376async fn pick_directory_handler(
1377 State(state): State<AppState>,
1378 Query(query): Query<PickDirectoryQuery>,
1379) -> Response {
1380 if state.server_mode {
1381 return StatusCode::NOT_FOUND.into_response();
1382 }
1383
1384 let title = match query.kind.as_deref() {
1385 Some("output") => "Select output directory",
1386 Some("reports") => "Select folder containing saved reports",
1387 _ => "Select project directory",
1388 }
1389 .to_owned();
1390 let current = query.current.clone();
1391
1392 let picked = tokio::task::spawn_blocking(move || {
1393 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1396 let _fg_tid = win_dialog_focus::attach_to_foreground();
1397 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1398 win_dialog_focus::flash_dialog_when_ready(title.clone());
1399
1400 let mut dialog = rfd::FileDialog::new().set_title(&title);
1401 if let Some(current) = current.as_deref() {
1402 let resolved = resolve_input_path(current);
1403 let seed = if resolved.is_dir() {
1404 Some(resolved)
1405 } else {
1406 resolved.parent().map(Path::to_path_buf)
1407 };
1408 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
1409 dialog = dialog.set_directory(seed_dir);
1410 }
1411 }
1412 let result = dialog.pick_folder();
1413
1414 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1415 win_dialog_focus::detach_from_foreground(_fg_tid);
1416
1417 result
1418 })
1419 .await
1420 .unwrap_or(None);
1421
1422 Json(PickDirectoryResponse {
1423 selected_path: picked.as_ref().map(|p| display_path(p)),
1424 cancelled: picked.is_none(),
1425 })
1426 .into_response()
1427}
1428
1429#[cfg(not(feature = "native-dialog"))]
1430async fn pick_directory_handler(
1431 State(_state): State<AppState>,
1432 Query(_query): Query<PickDirectoryQuery>,
1433) -> Response {
1434 StatusCode::NOT_FOUND.into_response()
1435}
1436
1437#[cfg(feature = "native-dialog")]
1438async fn pick_file_handler(State(state): State<AppState>) -> Response {
1439 if state.server_mode {
1440 return StatusCode::NOT_FOUND.into_response();
1441 }
1442 let picked = tokio::task::spawn_blocking(|| {
1443 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1444 let _fg_tid = win_dialog_focus::attach_to_foreground();
1445 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1446 win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
1447
1448 let result = rfd::FileDialog::new()
1449 .set_title("Select HTML report")
1450 .add_filter("HTML report", &["html"])
1451 .pick_file();
1452
1453 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1454 win_dialog_focus::detach_from_foreground(_fg_tid);
1455
1456 result
1457 })
1458 .await
1459 .unwrap_or(None);
1460 Json(PickDirectoryResponse {
1461 selected_path: picked.as_ref().map(|p| display_path(p)),
1462 cancelled: picked.is_none(),
1463 })
1464 .into_response()
1465}
1466
1467#[cfg(not(feature = "native-dialog"))]
1468async fn pick_file_handler(State(_state): State<AppState>) -> Response {
1469 StatusCode::NOT_FOUND.into_response()
1470}
1471
1472#[derive(Deserialize)]
1473struct LocateReportForm {
1474 file_path: String,
1475}
1476
1477fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
1479 let html = ErrorTemplate {
1480 message: message.into(),
1481 last_report_url: Some("/view-reports".to_string()),
1482 last_report_label: Some("View Reports".to_string()),
1483 csp_nonce: csp_nonce.to_owned(),
1484 }
1485 .render()
1486 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
1487 Html(html).into_response()
1488}
1489
1490fn registry_entry_from_run(
1492 run: &AnalysisRun,
1493 json_path: PathBuf,
1494 html_path: PathBuf,
1495) -> RegistryEntry {
1496 let project_label = run.input_roots.first().map_or_else(
1497 || "Unknown Project".to_string(),
1498 |r| sanitize_project_label(r),
1499 );
1500 RegistryEntry {
1501 run_id: run.tool.run_id.clone(),
1502 timestamp_utc: run.tool.timestamp_utc,
1503 project_label,
1504 input_roots: run.input_roots.clone(),
1505 json_path: Some(json_path),
1506 html_path: Some(html_path),
1507 pdf_path: None,
1508 summary: ScanSummarySnapshot {
1509 files_analyzed: run.summary_totals.files_analyzed,
1510 files_skipped: run.summary_totals.files_skipped,
1511 total_physical_lines: run.summary_totals.total_physical_lines,
1512 code_lines: run.summary_totals.code_lines,
1513 comment_lines: run.summary_totals.comment_lines,
1514 blank_lines: run.summary_totals.blank_lines,
1515 functions: run.summary_totals.functions,
1516 classes: run.summary_totals.classes,
1517 variables: run.summary_totals.variables,
1518 imports: run.summary_totals.imports,
1519 },
1520 git_branch: None,
1521 git_commit: None,
1522 git_author: None,
1523 git_tags: None,
1524 git_commit_date: None,
1525 }
1526}
1527
1528#[allow(clippy::result_large_err)]
1533fn validate_locate_request(
1534 state: &AppState,
1535 file_path: &str,
1536 csp_nonce: &str,
1537) -> Result<(PathBuf, PathBuf), Response> {
1538 let file_ext = Path::new(file_path)
1539 .extension()
1540 .and_then(|e| e.to_str())
1541 .unwrap_or("")
1542 .to_ascii_lowercase();
1543 if file_ext != "html" {
1544 return Err(locate_report_error(
1545 "Only .html report files can be located via this form.",
1546 csp_nonce,
1547 ));
1548 }
1549 let html_path = match fs::canonicalize(PathBuf::from(file_path)) {
1550 Ok(p) => strip_unc_prefix(p),
1551 Err(_) => {
1552 return Err(locate_report_error(
1553 "Report file not found or path is invalid.",
1554 csp_nonce,
1555 ));
1556 }
1557 };
1558 if state.server_mode {
1559 let output_root = resolve_output_root(None);
1560 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
1561 if !html_path.starts_with(&canonical_root) {
1562 return Err(locate_report_error(
1563 "Report file must be within the configured output directory.",
1564 csp_nonce,
1565 ));
1566 }
1567 }
1568 let parent = match html_path.parent() {
1569 Some(p) => p.to_path_buf(),
1570 None => {
1571 return Err(locate_report_error(
1572 "Report file has no parent directory.",
1573 csp_nonce,
1574 ));
1575 }
1576 };
1577 Ok((html_path, parent))
1578}
1579
1580fn locate_path_hint(server_mode: bool, path: &Path) -> String {
1582 if server_mode {
1583 String::new()
1584 } else {
1585 format!("\n\nFile: {}", path.display())
1586 }
1587}
1588
1589async fn locate_report_handler(
1590 State(state): State<AppState>,
1591 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1592 Form(form): Form<LocateReportForm>,
1593) -> impl IntoResponse {
1594 let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
1595 Ok(v) => v,
1596 Err(resp) => return resp,
1597 };
1598
1599 let json_candidate = parent.join("result.json");
1600 let mut reg = state.registry.lock().await;
1601 let entry_idx = reg.entries.iter().position(|e| {
1603 let json_match = e
1604 .json_path
1605 .as_ref()
1606 .and_then(|p| p.parent())
1607 .is_some_and(|p| p == parent);
1608 let html_match = e
1609 .html_path
1610 .as_ref()
1611 .and_then(|p| p.parent())
1612 .is_some_and(|p| p == parent);
1613 json_match || html_match
1614 });
1615 if let Some(idx) = entry_idx {
1616 reg.entries[idx].html_path = Some(html_path);
1617 let _ = reg.save(&state.registry_path);
1618 return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1619 }
1620 if json_candidate.exists() {
1622 match read_json(&json_candidate) {
1623 Ok(run) => {
1624 let entry = registry_entry_from_run(&run, json_candidate, html_path);
1625 reg.add_entry(entry);
1626 let _ = reg.save(&state.registry_path);
1627 return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1628 }
1629 Err(e) => {
1630 let file_hint = locate_path_hint(state.server_mode, &json_candidate);
1631 let err_detail = if state.server_mode {
1632 String::new()
1633 } else {
1634 format!("\n\nError: {e}")
1635 };
1636 return locate_report_error(
1637 format!(
1638 "Could not link this report.\n\nA 'result.json' was found but could not \
1639 be parsed — it may have been saved by an older version of OxideSLOC. \
1640 Re-running the analysis will create a fresh, compatible \
1641 record.{file_hint}{err_detail}"
1642 ),
1643 &csp_nonce,
1644 );
1645 }
1646 }
1647 }
1648 drop(reg);
1649 let file_hint = locate_path_hint(state.server_mode, &html_path);
1650 locate_report_error(
1651 format!(
1652 "Could not link this report.\n\nNo matching scan record was found, and no \
1653 'result.json' was found in the same folder.{file_hint}"
1654 ),
1655 &csp_nonce,
1656 )
1657}
1658
1659#[derive(Deserialize)]
1660struct LocateReportsDirForm {
1661 folder_path: String,
1662}
1663
1664async fn locate_reports_dir_handler(
1665 State(state): State<AppState>,
1666 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1667 Form(form): Form<LocateReportsDirForm>,
1668) -> impl IntoResponse {
1669 if state.server_mode {
1670 return StatusCode::NOT_FOUND.into_response();
1671 }
1672 let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
1673 Ok(p) => strip_unc_prefix(p),
1674 Err(_) => return locate_report_error("Folder not found or path is invalid.", &csp_nonce),
1675 };
1676 if !folder.is_dir() {
1677 return locate_report_error("Selected path is not a directory.", &csp_nonce);
1678 }
1679
1680 let mut candidates: Vec<PathBuf> = Vec::new();
1682 let top = folder.join("result.json");
1683 if top.exists() {
1684 candidates.push(top);
1685 }
1686 if let Ok(dir_entries) = fs::read_dir(&folder) {
1687 for entry in dir_entries.flatten() {
1688 let sub = entry.path();
1689 if sub.is_dir() {
1690 let j = sub.join("result.json");
1691 if j.exists() {
1692 candidates.push(j);
1693 }
1694 }
1695 }
1696 }
1697
1698 if candidates.is_empty() {
1699 return locate_report_error(
1700 "No result.json files found in the selected folder or its subdirectories.",
1701 &csp_nonce,
1702 );
1703 }
1704
1705 let mut linked_count: usize = 0;
1706 let mut reg = state.registry.lock().await;
1707 for json_path in candidates {
1708 let parent = match json_path.parent() {
1709 Some(p) => p.to_path_buf(),
1710 None => continue,
1711 };
1712 let already = reg.entries.iter().any(|e| {
1714 e.json_path
1715 .as_ref()
1716 .and_then(|p| p.parent())
1717 .is_some_and(|p| p == parent)
1718 || e.html_path
1719 .as_ref()
1720 .and_then(|p| p.parent())
1721 .is_some_and(|p| p == parent)
1722 });
1723 if already {
1724 continue;
1725 }
1726 let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
1728 rd.flatten()
1729 .map(|e| e.path())
1730 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
1731 });
1732 let run = match read_json(&json_path) {
1733 Ok(r) => r,
1734 Err(_) => continue,
1735 };
1736 let project_label = run.input_roots.first().map_or_else(
1737 || "Unknown Project".to_string(),
1738 |r| sanitize_project_label(r),
1739 );
1740 let entry = RegistryEntry {
1741 run_id: run.tool.run_id.clone(),
1742 timestamp_utc: run.tool.timestamp_utc,
1743 project_label,
1744 input_roots: run.input_roots.clone(),
1745 json_path: Some(json_path),
1746 html_path,
1747 pdf_path: None,
1748 summary: ScanSummarySnapshot {
1749 files_analyzed: run.summary_totals.files_analyzed,
1750 files_skipped: run.summary_totals.files_skipped,
1751 total_physical_lines: run.summary_totals.total_physical_lines,
1752 code_lines: run.summary_totals.code_lines,
1753 comment_lines: run.summary_totals.comment_lines,
1754 blank_lines: run.summary_totals.blank_lines,
1755 functions: run.summary_totals.functions,
1756 classes: run.summary_totals.classes,
1757 variables: run.summary_totals.variables,
1758 imports: run.summary_totals.imports,
1759 },
1760 git_branch: run.git_branch.clone(),
1761 git_commit: run.git_commit_short.clone(),
1762 git_author: run.git_commit_author.clone(),
1763 git_tags: run.git_tags.clone(),
1764 git_commit_date: run.git_commit_date.clone(),
1765 };
1766 reg.add_entry(entry);
1767 linked_count += 1;
1768 }
1769 let _ = reg.save(&state.registry_path);
1770 drop(reg);
1771
1772 if linked_count == 0 {
1773 return locate_report_error(
1774 "No new reports were loaded. The selected folder may already be fully indexed, \
1775 or the result.json files could not be parsed.",
1776 &csp_nonce,
1777 );
1778 }
1779 axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
1780}
1781
1782#[derive(Debug, Deserialize)]
1783struct OpenPathQuery {
1784 path: Option<String>,
1785}
1786
1787async fn open_path_handler(
1788 State(state): State<AppState>,
1789 Query(query): Query<OpenPathQuery>,
1790) -> impl IntoResponse {
1791 if state.server_mode {
1792 return StatusCode::NOT_FOUND.into_response();
1793 }
1794 let raw = match query.path.as_deref() {
1795 Some(p) if !p.is_empty() => p,
1796 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
1797 };
1798
1799 let Ok(canonical) = fs::canonicalize(raw) else {
1800 return (StatusCode::BAD_REQUEST, "path not found").into_response();
1801 };
1802
1803 let target = if canonical.is_file() {
1805 match canonical.parent() {
1806 Some(p) => p.to_path_buf(),
1807 None => return (StatusCode::BAD_REQUEST, "path has no parent").into_response(),
1808 }
1809 } else if canonical.is_dir() {
1810 canonical
1811 } else {
1812 return (StatusCode::BAD_REQUEST, "path is not a file or directory").into_response();
1814 };
1815
1816 #[cfg(target_os = "windows")]
1817 let _ = std::process::Command::new("explorer.exe")
1818 .arg(&target)
1819 .stdout(Stdio::null())
1820 .stderr(Stdio::null())
1821 .spawn();
1822 #[cfg(target_os = "macos")]
1823 let _ = std::process::Command::new("open")
1824 .arg(&target)
1825 .stdout(Stdio::null())
1826 .stderr(Stdio::null())
1827 .spawn();
1828 #[cfg(target_os = "linux")]
1829 let _ = std::process::Command::new("xdg-open")
1830 .arg(&target)
1831 .stdout(Stdio::null())
1832 .stderr(Stdio::null())
1833 .spawn();
1834
1835 (StatusCode::OK, "ok").into_response()
1836}
1837
1838async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
1839 let (content_type, bytes): (&'static str, &'static [u8]) =
1840 match (folder.as_str(), file.as_str()) {
1841 ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
1842 ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
1843 ("icons", "c.png") => ("image/png", IMG_ICON_C),
1844 ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
1845 ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
1846 ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
1847 ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
1848 ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
1849 ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
1850 ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
1851 ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
1852 ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
1853 ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
1854 ("icons", "go.png") => ("image/png", IMG_ICON_GO),
1855 ("icons", "r.png") => ("image/png", IMG_ICON_R),
1856 ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
1857 ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
1858 ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
1859 ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
1860 ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
1861 _ => return StatusCode::NOT_FOUND.into_response(),
1862 };
1863 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
1864}
1865
1866async fn preview_handler(
1867 State(state): State<AppState>,
1868 Query(query): Query<PreviewQuery>,
1869) -> impl IntoResponse {
1870 let raw_path = query
1871 .path
1872 .unwrap_or_else(|| "tests/fixtures/basic".to_string());
1873 let resolved = resolve_input_path(&raw_path);
1874
1875 if state.server_mode {
1876 let config = &state.base_config;
1877 if config.discovery.allowed_scan_roots.is_empty() {
1878 return Html(
1879 r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
1880 );
1881 }
1882 let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
1883 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
1884 fs::canonicalize(root)
1885 .ok()
1886 .is_some_and(|r| canonical.starts_with(&r))
1887 });
1888 if !allowed {
1889 return Html(
1890 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
1891 );
1892 }
1893 }
1894
1895 let include_patterns = split_patterns(query.include_globs.as_deref());
1896 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
1897
1898 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
1899 Ok(html) => Html(html),
1900 Err(err) => Html(format!(
1901 r#"<div class="preview-error">Preview failed: {}</div>"#,
1902 escape_html(&err.to_string())
1903 )),
1904 }
1905}
1906
1907#[allow(clippy::result_large_err)]
1909fn validate_server_scan_path(
1910 config: &sloc_config::AppConfig,
1911 resolved_path: &Path,
1912 csp_nonce: &str,
1913) -> Result<(), Response> {
1914 if config.discovery.allowed_scan_roots.is_empty() {
1915 let template = ErrorTemplate {
1916 message: "Scan path rejected: no allowed_scan_roots configured on this server. \
1917 Set allowed_scan_roots in the server config to permit scanning."
1918 .to_string(),
1919 last_report_url: None,
1920 last_report_label: None,
1921 csp_nonce: csp_nonce.to_owned(),
1922 };
1923 return Err((
1924 StatusCode::FORBIDDEN,
1925 Html(
1926 template
1927 .render()
1928 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
1929 ),
1930 )
1931 .into_response());
1932 }
1933 let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
1934 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
1935 fs::canonicalize(root)
1936 .ok()
1937 .is_some_and(|r| canonical.starts_with(&r))
1938 });
1939 if !allowed {
1940 tracing::warn!(event = "path_rejected", path = %canonical.display(),
1941 "Scan path not in allowed_scan_roots");
1942 let template = ErrorTemplate {
1943 message: "The requested path is not within an allowed scan directory.".to_string(),
1944 last_report_url: None,
1945 last_report_label: None,
1946 csp_nonce: csp_nonce.to_owned(),
1947 };
1948 return Err((
1949 StatusCode::FORBIDDEN,
1950 Html(
1951 template
1952 .render()
1953 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
1954 ),
1955 )
1956 .into_response());
1957 }
1958 Ok(())
1959}
1960
1961fn apply_output_dir_exclusions(
1963 config: &mut sloc_config::AppConfig,
1964 project_path: &str,
1965 raw_output_dir: &str,
1966) {
1967 let project_root = resolve_input_path(project_path);
1968 let raw_out = raw_output_dir.trim();
1969 let resolved_out = if raw_out.is_empty() {
1970 project_root.join("sloc")
1971 } else if Path::new(raw_out).is_absolute() {
1972 PathBuf::from(raw_out)
1973 } else {
1974 workspace_root().join(raw_out)
1975 };
1976 if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
1977 if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
1978 let dir = first.to_string();
1979 if !config.discovery.excluded_directories.contains(&dir) {
1980 config.discovery.excluded_directories.push(dir);
1981 }
1982 }
1983 }
1984 if !config
1985 .discovery
1986 .excluded_directories
1987 .iter()
1988 .any(|d| d == "sloc")
1989 {
1990 config
1991 .discovery
1992 .excluded_directories
1993 .push("sloc".to_string());
1994 }
1995}
1996
1997const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
1999 ScanSummarySnapshot {
2000 files_analyzed: run.summary_totals.files_analyzed,
2001 files_skipped: run.summary_totals.files_skipped,
2002 total_physical_lines: run.summary_totals.total_physical_lines,
2003 code_lines: run.summary_totals.code_lines,
2004 comment_lines: run.summary_totals.comment_lines,
2005 blank_lines: run.summary_totals.blank_lines,
2006 functions: run.summary_totals.functions,
2007 classes: run.summary_totals.classes,
2008 variables: run.summary_totals.variables,
2009 imports: run.summary_totals.imports,
2010 }
2011}
2012
2013pub(crate) fn build_run_registry_entry(
2015 run: &AnalysisRun,
2016 run_id: &str,
2017 project_label: &str,
2018 artifacts: &RunArtifacts,
2019) -> RegistryEntry {
2020 RegistryEntry {
2021 run_id: run_id.to_owned(),
2022 timestamp_utc: run.tool.timestamp_utc,
2023 project_label: project_label.to_owned(),
2024 input_roots: run.input_roots.clone(),
2025 json_path: artifacts.json_path.clone(),
2026 html_path: artifacts.html_path.clone(),
2027 pdf_path: artifacts.pdf_path.clone(),
2028 summary: summary_snapshot_from_run(run),
2029 git_branch: run.git_branch.clone(),
2030 git_commit: run.git_commit_short.clone(),
2031 git_author: run.git_commit_author.clone(),
2032 git_tags: run.git_tags.clone(),
2033 git_commit_date: run.git_commit_date.clone(),
2034 }
2035}
2036
2037fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
2039 if let Some(policy) = form.mixed_line_policy {
2040 config.analysis.mixed_line_policy = policy;
2041 }
2042 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
2043 config.analysis.generated_file_detection =
2044 form.generated_file_detection.as_deref() != Some("disabled");
2045 config.analysis.minified_file_detection =
2046 form.minified_file_detection.as_deref() != Some("disabled");
2047 config.analysis.vendor_directory_detection =
2048 form.vendor_directory_detection.as_deref() != Some("disabled");
2049 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
2050 if let Some(binary_behavior) = form.binary_file_behavior {
2051 config.analysis.binary_file_behavior = binary_behavior;
2052 }
2053 if let Some(report_title) = form.report_title.as_deref() {
2054 let trimmed = report_title.trim();
2055 if !trimmed.is_empty() {
2056 config.reporting.report_title = trimmed.to_string();
2057 }
2058 }
2059 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
2060 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
2061 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
2062}
2063
2064fn spawn_pdf_background(pending_pdf: PendingPdf) {
2066 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
2067 tokio::spawn(async move {
2068 let result = tokio::task::spawn_blocking(move || {
2069 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
2070 if cleanup_src {
2071 let _ = fs::remove_file(&pdf_src);
2072 }
2073 r
2074 })
2075 .await;
2076 match result {
2077 Ok(Err(err)) => eprintln!("[oxide-sloc][pdf] background PDF failed: {err}"),
2078 Err(err) => eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}"),
2079 Ok(Ok(())) => {}
2080 }
2081 });
2082 }
2083}
2084
2085fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
2087 cmp.file_deltas
2088 .iter()
2089 .map(|f| match f.status {
2090 FileChangeStatus::Added => f.current_code,
2091 FileChangeStatus::Modified => f.code_delta.max(0),
2092 _ => 0,
2093 })
2094 .sum()
2095}
2096
2097fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
2099 cmp.file_deltas
2100 .iter()
2101 .map(|f| match f.status {
2102 FileChangeStatus::Removed => f.baseline_code,
2103 FileChangeStatus::Modified => (-f.code_delta).max(0),
2104 _ => 0,
2105 })
2106 .sum()
2107}
2108
2109fn build_submodule_row(
2111 s: &sloc_core::SubmoduleSummary,
2112 run: &AnalysisRun,
2113 run_id: &str,
2114 run_dir: &Path,
2115 generate_html: bool,
2116) -> SubmoduleRow {
2117 let safe = sanitize_project_label(&s.name);
2118 let artifact_key = format!("sub_{safe}");
2119 let html_url = if run.effective_configuration.discovery.submodule_breakdown && generate_html {
2120 let parent_path = run
2121 .input_roots
2122 .first()
2123 .map_or("", std::string::String::as_str);
2124 let sub_run = build_sub_run(run, s, parent_path);
2125 render_sub_report_html(&sub_run).ok().and_then(|sub_html| {
2126 let path = run_dir.join(format!("{artifact_key}.html"));
2127 if fs::write(&path, sub_html.as_bytes()).is_ok() {
2128 Some(format!("/runs/{run_id}/{artifact_key}"))
2129 } else {
2130 None
2131 }
2132 })
2133 } else {
2134 None
2135 };
2136 SubmoduleRow {
2137 name: s.name.clone(),
2138 relative_path: s.relative_path.clone(),
2139 files_analyzed: s.files_analyzed,
2140 code_lines: s.code_lines,
2141 comment_lines: s.comment_lines,
2142 blank_lines: s.blank_lines,
2143 total_physical_lines: s.total_physical_lines,
2144 html_url,
2145 }
2146}
2147
2148#[allow(clippy::too_many_lines)]
2151#[allow(clippy::similar_names)]
2152async fn analyze_handler(
2153 State(state): State<AppState>,
2154 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2155 Form(form): Form<AnalyzeForm>,
2156) -> impl IntoResponse {
2157 let Ok(_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
2158 let template = ErrorTemplate {
2159 message: "Server is busy — too many concurrent analyses. Please try again in a moment."
2160 .to_string(),
2161 last_report_url: None,
2162 last_report_label: None,
2163 csp_nonce: csp_nonce.clone(),
2164 };
2165 return (
2166 StatusCode::SERVICE_UNAVAILABLE,
2167 Html(
2168 template
2169 .render()
2170 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
2171 ),
2172 )
2173 .into_response();
2174 };
2175
2176 let mut config = state.base_config.clone();
2177
2178 let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
2179 let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
2180 let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
2181
2182 if !is_git_mode {
2183 let resolved_path = resolve_input_path(&form.path);
2184 if state.server_mode {
2185 if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
2186 return resp;
2187 }
2188 }
2189 config.discovery.root_paths = vec![resolved_path];
2190 }
2191
2192 apply_form_to_config(&mut config, &form);
2193 apply_output_dir_exclusions(
2194 &mut config,
2195 &form.path,
2196 form.output_dir.as_deref().unwrap_or(""),
2197 );
2198
2199 let wait_id = uuid::Uuid::new_v4().to_string();
2201 let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
2202
2203 let project_path_bg = form.path.clone();
2205 let output_dir_bg = form.output_dir.clone();
2206 let git_repo_bg = form.git_repo.clone().filter(|s| !s.is_empty());
2207 let git_ref_bg = form.git_ref.clone().filter(|s| !s.is_empty());
2208 let generate_html_bg = form.generate_html.is_some();
2209 let generate_pdf_bg = form.generate_pdf.is_some();
2210 let clones_dir = state.git_clones_dir.clone();
2211 let wait_id_bg = wait_id.clone();
2212 let state_bg = state.clone();
2213
2214 {
2215 let mut runs = state.async_runs.lock().await;
2216 runs.insert(
2217 wait_id.clone(),
2218 AsyncRunState::Running {
2219 started_at: std::time::Instant::now(),
2220 },
2221 );
2222 }
2223
2224 tokio::spawn(async move {
2225 let _permit = _permit;
2227
2228 let git_repo_sb = git_repo_bg.clone();
2230 let git_ref_sb = git_ref_bg.clone();
2231 let analysis_result =
2232 tokio::task::spawn_blocking(move || -> Result<(sloc_core::AnalysisRun, String)> {
2233 if let (Some(repo), Some(refname)) = (&git_repo_sb, &git_ref_sb) {
2234 let dest = git_clone_dest(repo, &clones_dir);
2235 sloc_git::clone_or_fetch(repo, &dest)?;
2236 let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
2237 sloc_git::create_worktree(&dest, refname, &wt)?;
2238 config.discovery.root_paths = vec![wt.clone()];
2239 let run = analyze(&config, "serve");
2240 let _ = sloc_git::destroy_worktree(&dest, &wt);
2241 let mut run = run?;
2242 if run.git_branch.is_none() {
2243 run.git_branch = Some(refname.clone());
2244 }
2245 let html = render_html(&run)?;
2246 return Ok((run, html));
2247 }
2248 let run = analyze(&config, "serve")?;
2249 let html = render_html(&run)?;
2250 Ok((run, html))
2251 })
2252 .await
2253 .map_err(|err| anyhow::anyhow!(err.to_string()))
2254 .and_then(|result| result);
2255
2256 let (run, report_html) = match analysis_result {
2257 Ok(v) => v,
2258 Err(err) => {
2259 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
2260 let mut runs = state_bg.async_runs.lock().await;
2261 runs.insert(
2262 wait_id_bg.clone(),
2263 AsyncRunState::Failed {
2264 message: "Analysis failed. Check that the path exists and is readable."
2265 .to_string(),
2266 },
2267 );
2268 return;
2269 }
2270 };
2271
2272 let run_id = run.tool.run_id.clone();
2273 tracing::info!(event = "scan_complete", run_id = %run_id,
2274 path = %project_path_bg, files = run.summary_totals.files_analyzed,
2275 "Analysis finished");
2276
2277 let prev_entry: Option<RegistryEntry> = {
2278 let reg = state_bg.registry.lock().await;
2279 reg.entries_for_roots(&run.input_roots)
2280 .into_iter()
2281 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
2282 .cloned()
2283 };
2284
2285 let scan_delta = prev_entry.as_ref().and_then(|prev| {
2286 prev.json_path
2287 .as_ref()
2288 .and_then(|p| read_json(p).ok())
2289 .map(|prev_run| compute_delta(&prev_run, &run))
2290 });
2291 let prev_scan_count: usize = {
2292 let reg = state_bg.registry.lock().await;
2293 reg.entries_for_roots(&run.input_roots)
2294 .iter()
2295 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
2296 .count()
2297 };
2298
2299 let output_root = resolve_output_root(output_dir_bg.as_deref());
2300
2301 let project_label = if let (Some(repo), Some(refname)) = (
2302 git_repo_bg.as_deref().filter(|s| !s.is_empty()),
2303 git_ref_bg.as_deref().filter(|s| !s.is_empty()),
2304 ) {
2305 let repo_name = repo
2306 .trim_end_matches('/')
2307 .trim_end_matches(".git")
2308 .rsplit('/')
2309 .next()
2310 .unwrap_or("repo");
2311 sanitize_project_label(&format!("{repo_name}_{refname}"))
2312 } else {
2313 sanitize_project_label(&project_path_bg)
2314 };
2315 let run_dir = output_root.join(format!("{project_label}_{run_id}"));
2316 let file_stem = {
2317 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
2318 if commit.is_empty() {
2319 project_label.clone()
2320 } else {
2321 format!("{project_label}_{commit}")
2322 }
2323 };
2324
2325 let result_context = RunResultContext {
2326 prev_entry: prev_entry.clone(),
2327 prev_scan_count,
2328 project_path: project_path_bg.clone(),
2329 };
2330
2331 let artifact_result = persist_run_artifacts(
2332 &run,
2333 &report_html,
2334 &run_dir,
2335 true,
2336 generate_html_bg,
2337 generate_pdf_bg,
2338 &run.effective_configuration.reporting.report_title,
2339 &file_stem,
2340 result_context,
2341 );
2342
2343 let (artifacts, pending_pdf) = match artifact_result {
2344 Ok(v) => v,
2345 Err(err) => {
2346 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
2347 let mut runs = state_bg.async_runs.lock().await;
2348 runs.insert(
2349 wait_id_bg.clone(),
2350 AsyncRunState::Failed {
2351 message: "Failed to save report artifacts. Check available disk space."
2352 .to_string(),
2353 },
2354 );
2355 return;
2356 }
2357 };
2358
2359 {
2360 let mut map = state_bg.artifacts.lock().await;
2361 map.insert(run_id.clone(), artifacts.clone());
2362 }
2363
2364 {
2365 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
2366 let mut reg = state_bg.registry.lock().await;
2367 reg.add_entry(entry);
2368 let _ = reg.save(&state_bg.registry_path);
2369 }
2370
2371 if let Some(ref cfg_path) = artifacts.scan_config_path {
2372 let policy_str =
2373 serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
2374 .ok()
2375 .and_then(|v| v.as_str().map(String::from))
2376 .unwrap_or_else(|| "code_only".to_string());
2377 let behavior_str =
2378 serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
2379 .ok()
2380 .and_then(|v| v.as_str().map(String::from))
2381 .unwrap_or_else(|| "skip".to_string());
2382 let scan_cfg = ScanConfig {
2383 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
2384 path: project_path_bg.clone(),
2385 include_globs: run
2386 .effective_configuration
2387 .discovery
2388 .include_globs
2389 .join("\n"),
2390 exclude_globs: run
2391 .effective_configuration
2392 .discovery
2393 .exclude_globs
2394 .join("\n"),
2395 submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
2396 mixed_line_policy: policy_str,
2397 python_docstrings_as_comments: run
2398 .effective_configuration
2399 .analysis
2400 .python_docstrings_as_comments,
2401 generated_file_detection: run
2402 .effective_configuration
2403 .analysis
2404 .generated_file_detection,
2405 minified_file_detection: run
2406 .effective_configuration
2407 .analysis
2408 .minified_file_detection,
2409 vendor_directory_detection: run
2410 .effective_configuration
2411 .analysis
2412 .vendor_directory_detection,
2413 include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
2414 binary_file_behavior: behavior_str,
2415 output_dir: output_dir_bg.clone().unwrap_or_default(),
2416 report_title: run.effective_configuration.reporting.report_title.clone(),
2417 generate_html: generate_html_bg,
2418 generate_pdf: generate_pdf_bg,
2419 };
2420 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
2421 let _ = std::fs::write(cfg_path, json);
2422 }
2423 }
2424
2425 spawn_pdf_background(pending_pdf);
2426
2427 let mut runs = state_bg.async_runs.lock().await;
2429 runs.insert(
2430 wait_id_bg.clone(),
2431 AsyncRunState::Complete {
2432 run_id: run_id.clone(),
2433 },
2434 );
2435 drop(runs);
2436
2437 let _ = scan_delta;
2439 });
2440
2441 let template = ScanWaitTemplate {
2442 version: env!("CARGO_PKG_VERSION"),
2443 wait_id_json,
2444 project_path: form.path.clone(),
2445 csp_nonce,
2446 };
2447 let html = template
2448 .render()
2449 .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
2450 let mut response = Html(html).into_response();
2451 if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
2452 if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
2453 response.headers_mut().insert(name, val);
2454 }
2455 }
2456 response
2457}
2458
2459#[derive(Serialize)]
2462#[serde(tag = "state", rename_all = "snake_case")]
2463enum AsyncRunStatusResponse {
2464 Running { elapsed_secs: u64 },
2465 Complete { run_id: String },
2466 Failed { message: String },
2467}
2468
2469async fn async_run_status_handler(
2470 State(state): State<AppState>,
2471 AxumPath(wait_id): AxumPath<String>,
2472) -> Response {
2473 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
2475 return StatusCode::BAD_REQUEST.into_response();
2476 }
2477 let run_state = {
2478 let runs = state.async_runs.lock().await;
2479 runs.get(&wait_id).cloned()
2480 };
2481 match run_state {
2482 None => StatusCode::NOT_FOUND.into_response(),
2483 Some(AsyncRunState::Running { started_at }) => {
2484 if started_at.elapsed() > std::time::Duration::from_secs(7200) {
2486 let mut runs = state.async_runs.lock().await;
2487 runs.insert(
2488 wait_id,
2489 AsyncRunState::Failed {
2490 message: "Analysis timed out after 2 hours.".to_string(),
2491 },
2492 );
2493 return Json(AsyncRunStatusResponse::Failed {
2494 message: "Analysis timed out after 2 hours.".to_string(),
2495 })
2496 .into_response();
2497 }
2498 Json(AsyncRunStatusResponse::Running {
2499 elapsed_secs: started_at.elapsed().as_secs(),
2500 })
2501 .into_response()
2502 }
2503 Some(AsyncRunState::Complete { run_id }) => {
2504 Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
2505 }
2506 Some(AsyncRunState::Failed { message }) => {
2507 Json(AsyncRunStatusResponse::Failed { message }).into_response()
2508 }
2509 }
2510}
2511
2512async fn async_run_result_handler(
2513 State(state): State<AppState>,
2514 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2515 AxumPath(run_id): AxumPath<String>,
2516) -> Response {
2517 if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
2518 return StatusCode::BAD_REQUEST.into_response();
2519 }
2520
2521 let artifacts = {
2522 let map = state.artifacts.lock().await;
2523 map.get(&run_id).cloned()
2524 };
2525 let artifacts = if let Some(a) = artifacts {
2526 a
2527 } else {
2528 let reg = state.registry.lock().await;
2529 if let Some(entry) = reg.find_by_run_id(&run_id) {
2530 recover_artifacts_from_registry(entry)
2531 } else {
2532 let html = ErrorTemplate {
2533 message: format!(
2534 "Report not found. Run ID {} is not in the scan history.",
2535 &run_id[..run_id.len().min(8)]
2536 ),
2537 last_report_url: Some("/view-reports".to_string()),
2538 last_report_label: Some("View Reports".to_string()),
2539 csp_nonce: csp_nonce.clone(),
2540 }
2541 .render()
2542 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
2543 return (StatusCode::NOT_FOUND, Html(html)).into_response();
2544 }
2545 };
2546
2547 let json_path = match &artifacts.json_path {
2548 Some(p) => p.clone(),
2549 None => {
2550 let html = ErrorTemplate {
2551 message: "JSON result was not saved for this run.".to_string(),
2552 last_report_url: Some("/view-reports".to_string()),
2553 last_report_label: Some("View Reports".to_string()),
2554 csp_nonce: csp_nonce.clone(),
2555 }
2556 .render()
2557 .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
2558 return (StatusCode::NOT_FOUND, Html(html)).into_response();
2559 }
2560 };
2561
2562 let run = match read_json(&json_path) {
2563 Ok(r) => r,
2564 Err(e) => {
2565 let html = ErrorTemplate {
2566 message: format!("Could not load scan result: {e}"),
2567 last_report_url: Some("/view-reports".to_string()),
2568 last_report_label: Some("View Reports".to_string()),
2569 csp_nonce: csp_nonce.clone(),
2570 }
2571 .render()
2572 .unwrap_or_else(|_| "<pre>Load error.</pre>".to_string());
2573 return (StatusCode::INTERNAL_SERVER_ERROR, Html(html)).into_response();
2574 }
2575 };
2576
2577 render_result_page(&run, &artifacts, &run_id, &csp_nonce)
2578}
2579
2580#[allow(clippy::too_many_lines)]
2581fn render_result_page(
2582 run: &AnalysisRun,
2583 artifacts: &RunArtifacts,
2584 run_id: &str,
2585 csp_nonce: &str,
2586) -> Response {
2587 let ctx = &artifacts.result_context;
2588 let prev_entry = &ctx.prev_entry;
2589 let prev_scan_count = ctx.prev_scan_count;
2590 let project_path = &ctx.project_path;
2591
2592 let scan_delta = prev_entry.as_ref().and_then(|prev| {
2593 prev.json_path
2594 .as_ref()
2595 .and_then(|p| read_json(p).ok())
2596 .map(|prev_run| compute_delta(&prev_run, run))
2597 });
2598
2599 let language_rows = run
2600 .totals_by_language
2601 .iter()
2602 .map(|row| LanguageSummaryRow {
2603 language: row.language.display_name().to_string(),
2604 files: row.files,
2605 physical: row.total_physical_lines,
2606 code: row.code_lines,
2607 comments: row.comment_lines,
2608 blank: row.blank_lines,
2609 mixed: row.mixed_lines_separate,
2610 functions: row.functions,
2611 classes: row.classes,
2612 variables: row.variables,
2613 imports: row.imports,
2614 })
2615 .collect::<Vec<_>>();
2616
2617 let files_analyzed = run.per_file_records.len() as u64;
2618 let files_skipped = run.skipped_file_records.len() as u64;
2619 let physical_lines = language_rows.iter().map(|r| r.physical).sum::<u64>();
2620 let code_lines = language_rows.iter().map(|r| r.code).sum::<u64>();
2621 let comment_lines = language_rows.iter().map(|r| r.comments).sum::<u64>();
2622 let blank_lines = language_rows.iter().map(|r| r.blank).sum::<u64>();
2623 let mixed_lines = language_rows.iter().map(|r| r.mixed).sum::<u64>();
2624 let functions = language_rows.iter().map(|r| r.functions).sum::<u64>();
2625 let classes = language_rows.iter().map(|r| r.classes).sum::<u64>();
2626 let variables = language_rows.iter().map(|r| r.variables).sum::<u64>();
2627 let imports = language_rows.iter().map(|r| r.imports).sum::<u64>();
2628
2629 let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
2630 let prev_fa = prev_sum.map(|s| s.files_analyzed);
2631 let prev_fs = prev_sum.map(|s| s.files_skipped);
2632 let prev_pl = prev_sum.map(|s| s.total_physical_lines);
2633 let prev_cl = prev_sum.map(|s| s.code_lines);
2634 let prev_cml = prev_sum.map(|s| s.comment_lines);
2635 let prev_bl = prev_sum.map(|s| s.blank_lines);
2636 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
2637 let prev_fa_str = fmt_prev(prev_fa);
2638 let prev_fs_str = fmt_prev(prev_fs);
2639 let prev_pl_str = fmt_prev(prev_pl);
2640 let prev_cl_str = fmt_prev(prev_cl);
2641 let prev_cml_str = fmt_prev(prev_cml);
2642 let prev_bl_str = fmt_prev(prev_bl);
2643 let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
2644 let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
2645 let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
2646 let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
2647 let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
2648 let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
2649 let delta_fa_class = delta_fa_class.to_string();
2650 let delta_fs_class = delta_fs_class.to_string();
2651 let delta_pl_class = delta_pl_class.to_string();
2652 let delta_cl_class = delta_cl_class.to_string();
2653 let delta_cml_class = delta_cml_class.to_string();
2654 let delta_bl_class = delta_bl_class.to_string();
2655
2656 let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
2657 let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
2658 let (delta_lines_net_str, delta_lines_net_class) =
2659 match (delta_lines_added, delta_lines_removed) {
2660 (Some(a), Some(r)) => {
2661 let net = a - r;
2662 (fmt_delta(net), delta_class(net).to_string())
2663 }
2664 _ => ("—".to_string(), "na".to_string()),
2665 };
2666
2667 let run_dir = artifacts.output_dir.clone();
2668 let git_branch = run.git_branch.clone();
2669 let git_commit = run.git_commit_short.clone();
2670 let git_author = run.git_commit_author.clone();
2671
2672 let template = ResultTemplate {
2673 version: env!("CARGO_PKG_VERSION"),
2674 report_title: run.effective_configuration.reporting.report_title.clone(),
2675 project_path: project_path.clone(),
2676 output_dir: display_path(&artifacts.output_dir),
2677 run_id: run_id.to_owned(),
2678 files_analyzed,
2679 files_skipped,
2680 physical_lines,
2681 code_lines,
2682 comment_lines,
2683 blank_lines,
2684 mixed_lines,
2685 functions,
2686 classes,
2687 variables,
2688 imports,
2689 html_url: artifacts
2690 .html_path
2691 .as_ref()
2692 .map(|_| format!("/runs/{run_id}/html")),
2693 pdf_url: artifacts
2694 .pdf_path
2695 .as_ref()
2696 .map(|_| format!("/runs/{run_id}/pdf")),
2697 json_url: artifacts
2698 .json_path
2699 .as_ref()
2700 .map(|_| format!("/runs/{run_id}/json")),
2701 html_download_url: artifacts
2702 .html_path
2703 .as_ref()
2704 .map(|_| format!("/runs/{run_id}/html?download=1")),
2705 pdf_download_url: artifacts
2706 .pdf_path
2707 .as_ref()
2708 .map(|_| format!("/runs/{run_id}/pdf?download=1")),
2709 json_download_url: artifacts
2710 .json_path
2711 .as_ref()
2712 .map(|_| format!("/runs/{run_id}/json?download=1")),
2713 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
2714 pdf_path: artifacts.pdf_path.as_ref().map(|p| display_path(p)),
2715 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
2716 language_rows,
2717 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
2718 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_pst(e.timestamp_utc)),
2719 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
2720 prev_fa_str,
2721 prev_fs_str,
2722 prev_pl_str,
2723 prev_cl_str,
2724 prev_cml_str,
2725 prev_bl_str,
2726 delta_fa_str,
2727 delta_fa_class,
2728 delta_fs_str,
2729 delta_fs_class,
2730 delta_pl_str,
2731 delta_pl_class,
2732 delta_cl_str,
2733 delta_cl_class,
2734 delta_cml_str,
2735 delta_cml_class,
2736 delta_bl_str,
2737 delta_bl_class,
2738 delta_lines_added,
2739 delta_lines_removed,
2740 delta_lines_net_str,
2741 delta_lines_net_class,
2742 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
2743 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
2744 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
2745 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
2746 delta_unmodified_lines: scan_delta.as_ref().map(|d| {
2747 d.file_deltas
2748 .iter()
2749 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
2750 .map(|f| {
2751 #[allow(clippy::cast_sign_loss)]
2752 let n = f.current_code as u64;
2753 n
2754 })
2755 .sum()
2756 }),
2757 git_branch: git_branch.clone(),
2758 git_commit: git_commit.clone(),
2759 git_author: git_author.clone(),
2760 current_scan_number: prev_scan_count + 1,
2761 prev_scan_count,
2762 submodule_rows: run
2763 .submodule_summaries
2764 .iter()
2765 .map(|s| build_submodule_row(s, run, run_id, &run_dir, artifacts.html_path.is_some()))
2766 .collect(),
2767 pdf_generating: artifacts
2768 .pdf_path
2769 .as_ref()
2770 .map(|p| !p.exists())
2771 .unwrap_or(false),
2772 scan_config_url: format!("/runs/{run_id}/scan-config"),
2773 lang_chart_json: {
2774 let entries: Vec<String> = run
2775 .totals_by_language
2776 .iter()
2777 .take(12)
2778 .map(|l| {
2779 let name = l
2780 .language
2781 .display_name()
2782 .replace('\\', "\\\\")
2783 .replace('"', "\\\"");
2784 format!(
2785 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{}}}"#,
2786 name, l.code_lines, l.comment_lines, l.blank_lines,
2787 )
2788 })
2789 .collect();
2790 format!("[{}]", entries.join(","))
2791 },
2792 csp_nonce: csp_nonce.to_owned(),
2793 };
2794
2795 Html(
2796 template
2797 .render()
2798 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2799 )
2800 .into_response()
2801}
2802
2803fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
2804 let slug: String = report_title
2805 .chars()
2806 .map(|c| {
2807 if c.is_alphanumeric() || c == '-' {
2808 c.to_ascii_lowercase()
2809 } else {
2810 '_'
2811 }
2812 })
2813 .collect::<String>()
2814 .split('_')
2815 .filter(|s| !s.is_empty())
2816 .collect::<Vec<_>>()
2817 .join("_");
2818
2819 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
2820
2821 if slug.is_empty() {
2822 format!("report_{short_id}.pdf")
2823 } else {
2824 format!("{slug}_{short_id}.pdf")
2825 }
2826}
2827
2828async fn pdf_status_handler(
2831 State(state): State<AppState>,
2832 AxumPath(run_id): AxumPath<String>,
2833) -> Response {
2834 let pdf_path = {
2835 let registry = state.artifacts.lock().await;
2836 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
2837 };
2838 let pdf_path = if pdf_path.is_some() {
2839 pdf_path
2840 } else {
2841 let reg = state.registry.lock().await;
2842 reg.find_by_run_id(&run_id)
2843 .map(recover_artifacts_from_registry)
2844 .and_then(|a| a.pdf_path)
2845 };
2846 let ready = pdf_path.map(|p| p.exists()).unwrap_or(false);
2847 Json(serde_json::json!({"ready": ready})).into_response()
2848}
2849
2850fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
2855 let Some(start) = html.find("nonce=\"") else {
2857 return html
2861 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
2862 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
2863 };
2864 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
2866 return html.to_owned();
2867 };
2868 let old_nonce = &html[value_start..value_start + end_offset];
2869 html.replace(
2870 &format!("nonce=\"{old_nonce}\""),
2871 &format!("nonce=\"{new_nonce}\""),
2872 )
2873}
2874
2875fn serve_html_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
2876 match fs::read_to_string(path) {
2877 Ok(raw) => {
2878 let content = patch_html_nonce(&raw, csp_nonce);
2880 if wants_download {
2881 (
2882 [
2883 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
2884 (
2885 header::CONTENT_DISPOSITION,
2886 "attachment; filename=report.html",
2887 ),
2888 ],
2889 content,
2890 )
2891 .into_response()
2892 } else {
2893 Html(content).into_response()
2894 }
2895 }
2896 Err(err) => {
2897 let filename = path.file_name().map_or_else(
2898 || "report.html".to_string(),
2899 |n| n.to_string_lossy().into_owned(),
2900 );
2901 let msg = format!(
2902 "HTML report '{filename}' could not be read.\n\n\
2903 Error: {err}\n\n\
2904 If you moved or renamed the output folder, the stored path is now stale. \
2905 Use 'Open HTML folder' from the results page to browse the output directory."
2906 );
2907 let html = ErrorTemplate {
2908 message: msg,
2909 last_report_url: Some("/view-reports".to_string()),
2910 last_report_label: Some("View Reports".to_string()),
2911 csp_nonce: csp_nonce.to_owned(),
2912 }
2913 .render()
2914 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
2915 (StatusCode::NOT_FOUND, Html(html)).into_response()
2916 }
2917 }
2918}
2919
2920fn serve_pdf_artifact(
2922 path: &Path,
2923 report_title: &str,
2924 run_id: &str,
2925 wants_download: bool,
2926 csp_nonce: &str,
2927) -> Response {
2928 match fs::read(path) {
2929 Ok(bytes) => {
2930 let filename = build_pdf_filename(report_title, run_id);
2931 let disposition = if wants_download {
2932 format!("attachment; filename=\"{filename}\"")
2933 } else {
2934 format!("inline; filename=\"{filename}\"")
2935 };
2936 (
2937 [
2938 (header::CONTENT_TYPE, "application/pdf".to_string()),
2939 (header::CONTENT_DISPOSITION, disposition),
2940 ],
2941 bytes,
2942 )
2943 .into_response()
2944 }
2945 Err(err) => {
2946 let filename = path.file_name().map_or_else(
2947 || "report.pdf".to_string(),
2948 |n| n.to_string_lossy().into_owned(),
2949 );
2950 let msg = format!(
2951 "PDF report '{filename}' could not be read.\n\n\
2952 Error: {err}\n\n\
2953 If you moved or renamed the output folder, the stored path is now stale. \
2954 Use 'Open PDF folder' from the results page to browse the output directory."
2955 );
2956 let html = ErrorTemplate {
2957 message: msg,
2958 last_report_url: Some("/view-reports".to_string()),
2959 last_report_label: Some("View Reports".to_string()),
2960 csp_nonce: csp_nonce.to_owned(),
2961 }
2962 .render()
2963 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
2964 (StatusCode::NOT_FOUND, Html(html)).into_response()
2965 }
2966 }
2967}
2968
2969fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
2971 match fs::read(path) {
2972 Ok(bytes) => {
2973 if wants_download {
2974 (
2975 [
2976 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
2977 (
2978 header::CONTENT_DISPOSITION,
2979 "attachment; filename=result.json",
2980 ),
2981 ],
2982 bytes,
2983 )
2984 .into_response()
2985 } else {
2986 (
2987 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
2988 bytes,
2989 )
2990 .into_response()
2991 }
2992 }
2993 Err(err) => {
2994 let filename = path.file_name().map_or_else(
2995 || "result.json".to_string(),
2996 |n| n.to_string_lossy().into_owned(),
2997 );
2998 let msg = format!(
2999 "JSON result '{filename}' could not be read.\n\n\
3000 Error: {err}\n\n\
3001 If you moved or renamed the output folder, the stored path is now stale. \
3002 Use 'Open JSON folder' from the results page to browse the output directory."
3003 );
3004 let html = ErrorTemplate {
3005 message: msg,
3006 last_report_url: Some("/view-reports".to_string()),
3007 last_report_label: Some("View Reports".to_string()),
3008 csp_nonce: csp_nonce.to_owned(),
3009 }
3010 .render()
3011 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
3012 (StatusCode::NOT_FOUND, Html(html)).into_response()
3013 }
3014 }
3015}
3016
3017fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
3019 let output_dir = entry
3020 .html_path
3021 .as_ref()
3022 .or(entry.json_path.as_ref())
3023 .or(entry.pdf_path.as_ref())
3024 .and_then(|p| p.parent().map(PathBuf::from))
3025 .unwrap_or_default();
3026 let pdf_path = entry.pdf_path.clone().or_else(|| {
3029 let candidate = output_dir.join("report.pdf");
3030 candidate.exists().then_some(candidate)
3031 });
3032 RunArtifacts {
3033 output_dir: output_dir.clone(),
3034 html_path: entry.html_path.clone(),
3035 pdf_path,
3036 json_path: entry.json_path.clone(),
3037 scan_config_path: find_scan_config_in_dir(&output_dir),
3038 report_title: entry.project_label.clone(),
3039 result_context: RunResultContext::default(),
3040 }
3041}
3042
3043#[allow(clippy::too_many_lines)]
3044async fn artifact_handler(
3045 State(state): State<AppState>,
3046 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3047 AxumPath((run_id, artifact)): AxumPath<(String, String)>,
3048 Query(query): Query<ArtifactQuery>,
3049) -> Response {
3050 let artifact_set = {
3051 let registry = state.artifacts.lock().await;
3052 registry.get(&run_id).cloned()
3053 };
3054
3055 let artifact_set = if let Some(a) = artifact_set {
3058 a
3059 } else {
3060 let reg = state.registry.lock().await;
3061 if let Some(entry) = reg.find_by_run_id(&run_id) {
3062 recover_artifacts_from_registry(entry)
3063 } else {
3064 let error_html = ErrorTemplate {
3065 message: format!(
3066 "Report not found. Run ID {} is not in the scan history. \
3067 The report may have been deleted, or this is an old run from \
3068 before the scan registry was introduced.",
3069 &run_id[..run_id.len().min(8)]
3070 ),
3071 last_report_url: Some("/view-reports".to_string()),
3072 last_report_label: Some("View Reports".to_string()),
3073 csp_nonce: csp_nonce.clone(),
3074 }
3075 .render()
3076 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
3077 return (StatusCode::NOT_FOUND, Html(error_html)).into_response();
3078 }
3079 };
3080
3081 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
3082
3083 match artifact.as_str() {
3084 "html" => {
3085 let Some(path) = artifact_set.html_path else {
3086 return StatusCode::NOT_FOUND.into_response();
3087 };
3088 serve_html_artifact(&path, wants_download, &csp_nonce)
3089 }
3090 "pdf" => {
3091 let Some(path) = artifact_set.pdf_path else {
3092 let msg = "PDF report was not generated for this run, or was not recorded in \
3093 the scan registry. Re-run the analysis with PDF output enabled."
3094 .to_string();
3095 let html = ErrorTemplate {
3096 message: msg,
3097 last_report_url: Some(format!("/runs/{run_id}/html")),
3098 last_report_label: Some("View HTML Report".to_string()),
3099 csp_nonce: csp_nonce.clone(),
3100 }
3101 .render()
3102 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
3103 return (StatusCode::NOT_FOUND, Html(html)).into_response();
3104 };
3105 if !path.exists() {
3108 let html = format!(
3109 "<!doctype html><html lang=\"en\"><head>\
3110 <meta charset=utf-8>\
3111 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
3112 <meta http-equiv=\"refresh\" content=\"5\">\
3113 <title>OxideSLOC | Generating PDF\u{2026}</title>\
3114 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
3115 <style nonce=\"{csp_nonce}\">\
3116 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
3117 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
3118 --nav:#b85d33;--nav-2:#7a371b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
3119 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
3120 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
3121 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
3122 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
3123 background:var(--bg);color:var(--text);}}\
3124 .top-nav{{position:sticky;top:0;z-index:30;\
3125 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
3126 border-bottom:1px solid rgba(255,255,255,0.12);\
3127 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
3128 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
3129 min-height:56px;display:flex;align-items:center;gap:14px;}}\
3130 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;}}\
3131 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
3132 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
3133 .brand-copy{{display:flex;flex-direction:column;justify-content:center;min-width:0;}}\
3134 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
3135 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;}}\
3136 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
3137 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
3138 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
3139 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
3140 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
3141 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
3142 justify-content:center;min-height:38px;border-radius:999px;\
3143 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
3144 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
3145 .theme-toggle .icon-sun{{display:none;}}\
3146 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
3147 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
3148 .page{{max-width:1720px;margin:0 auto;padding:60px 24px;\
3149 display:flex;align-items:center;justify-content:center;\
3150 min-height:calc(100vh - 56px);}}\
3151 .panel{{background:var(--surface);border:1px solid var(--line);\
3152 border-radius:var(--radius);box-shadow:var(--shadow);\
3153 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
3154 .spin-ring{{width:56px;height:56px;border-radius:50%;\
3155 border:5px solid var(--line);border-top-color:var(--oxide-2);\
3156 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
3157 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
3158 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
3159 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
3160 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
3161 min-height:42px;padding:0 20px;border-radius:14px;\
3162 border:1px solid var(--line-strong);text-decoration:none;\
3163 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
3164 .back-link:hover{{background:var(--line);}}\
3165 </style></head>\
3166 <body>\
3167 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
3168 <a class=\"brand\" href=\"/\">\
3169 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
3170 <div class=\"brand-copy\">\
3171 <div class=\"brand-title\">OxideSLOC</div>\
3172 <div class=\"brand-subtitle\">Local analysis workbench</div>\
3173 </div>\
3174 </a>\
3175 <div class=\"nav-right\">\
3176 <a class=\"nav-pill\" href=\"/\">Home</a>\
3177 <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
3178 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
3179 <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>\
3180 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
3181 <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>\
3182 </button>\
3183 </div>\
3184 </div></div>\
3185 <div class=\"page\"><div class=\"panel\">\
3186 <div class=\"spin-ring\"></div>\
3187 <h1>Generating PDF\u{2026}</h1>\
3188 <p>The PDF is being rendered from the HTML report.<br>\
3189 This page refreshes automatically \u{2014} usually 15\u{2013}45 seconds.</p>\
3190 <a class=\"back-link\" href=\"/runs/{run_id}/pdf\">Refresh now</a>\
3191 </div></div>\
3192 <script nonce=\"{csp_nonce}\">\
3193 (function(){{\
3194 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
3195 if(s===\"dark\")b.classList.add(\"dark-theme\");\
3196 var t=document.getElementById(\"theme-toggle\");\
3197 if(t)t.addEventListener(\"click\",function(){{\
3198 var d=b.classList.toggle(\"dark-theme\");\
3199 localStorage.setItem(k,d?\"dark\":\"light\");\
3200 }});\
3201 }})();\
3202 </script>\
3203 </body></html>"
3204 );
3205 return Html(html).into_response();
3206 }
3207 serve_pdf_artifact(
3208 &path,
3209 &artifact_set.report_title,
3210 &run_id,
3211 wants_download,
3212 &csp_nonce,
3213 )
3214 }
3215 "json" => {
3216 let Some(path) = artifact_set.json_path else {
3217 let msg = "JSON result was not generated for this run, or was not recorded in \
3218 the scan registry. Re-run the analysis with JSON output enabled."
3219 .to_string();
3220 let html = ErrorTemplate {
3221 message: msg,
3222 last_report_url: Some("/view-reports".to_string()),
3223 last_report_label: Some("View Reports".to_string()),
3224 csp_nonce: csp_nonce.clone(),
3225 }
3226 .render()
3227 .unwrap_or_else(|_| "<pre>JSON not available.</pre>".to_string());
3228 return (StatusCode::NOT_FOUND, Html(html)).into_response();
3229 };
3230 serve_json_artifact(&path, wants_download, &csp_nonce)
3231 }
3232 "scan-config" => {
3233 let path = artifact_set
3234 .scan_config_path
3235 .as_deref()
3236 .map(|p| p.to_path_buf())
3237 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
3238 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
3239 fs::read(&path).map_or_else(
3240 |_| StatusCode::NOT_FOUND.into_response(),
3241 |bytes| {
3242 (
3243 [
3244 (
3245 header::CONTENT_TYPE,
3246 "application/json; charset=utf-8".to_string(),
3247 ),
3248 (
3249 header::CONTENT_DISPOSITION,
3250 "attachment; filename=\"scan-config.json\"".to_string(),
3251 ),
3252 ],
3253 bytes,
3254 )
3255 .into_response()
3256 },
3257 )
3258 }
3259 _ if artifact.starts_with("sub_") => {
3260 if artifact.len() > 128
3261 || !artifact
3262 .chars()
3263 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
3264 {
3265 return StatusCode::BAD_REQUEST.into_response();
3266 }
3267 let filename = format!("{artifact}.html");
3268 let path = artifact_set.output_dir.join(&filename);
3269 if !path.exists() {
3270 let html = ErrorTemplate {
3271 message: format!(
3272 "Sub-report '{artifact}' was not found in the run directory.\n\
3273 Re-run the analysis with 'Detect and separate git submodules' \
3274 and HTML output enabled."
3275 ),
3276 last_report_url: Some("/view-reports".to_string()),
3277 last_report_label: Some("View Reports".to_string()),
3278 csp_nonce: csp_nonce.clone(),
3279 }
3280 .render()
3281 .unwrap_or_else(|_| "<pre>Sub-report not found.</pre>".to_string());
3282 return (StatusCode::NOT_FOUND, Html(html)).into_response();
3283 }
3284 serve_html_artifact(&path, wants_download, &csp_nonce)
3285 }
3286 _ => StatusCode::NOT_FOUND.into_response(),
3287 }
3288}
3289
3290struct SubmoduleLinkRow {
3293 name: String,
3294 url: String,
3295}
3296
3297struct HistoryEntryRow {
3298 run_id: String,
3299 run_id_short: String,
3300 timestamp: String,
3301 project_label: String,
3302 project_path: String,
3303 files_analyzed: u64,
3304 files_skipped: u64,
3305 code_lines: u64,
3306 comment_lines: u64,
3307 blank_lines: u64,
3308 git_branch: String,
3309 git_commit: String,
3310 has_html: bool,
3311 has_json: bool,
3312 has_pdf: bool,
3313 submodule_links: Vec<SubmoduleLinkRow>,
3314 submodule_names_csv: String,
3316}
3317
3318fn fmt_pst(dt: chrono::DateTime<chrono::Utc>) -> String {
3319 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset is always valid"))
3320 .format("%Y-%m-%d %H:%M PST")
3321 .to_string()
3322}
3323
3324fn fmt_git_date(iso: &str) -> Option<String> {
3325 chrono::DateTime::parse_from_rfc3339(iso)
3326 .ok()
3327 .map(|d| fmt_pst(d.with_timezone(&chrono::Utc)))
3328}
3329
3330fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
3331 reg.entries
3332 .iter()
3333 .map(|e| {
3334 let submodule_links = {
3335 let mut links: Vec<SubmoduleLinkRow> = vec![];
3336 let sub_dir = e
3337 .html_path
3338 .as_ref()
3339 .and_then(|p| p.parent())
3340 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
3341 if let Some(dir) = sub_dir {
3342 if let Ok(rd) = std::fs::read_dir(dir) {
3343 for entry_res in rd.flatten() {
3344 let fname = entry_res.file_name();
3345 let fname_str = fname.to_string_lossy();
3346 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
3347 let stem = &fname_str[..fname_str.len() - 5];
3348 let display = stem[4..].replace('-', " ");
3349 links.push(SubmoduleLinkRow {
3350 name: display,
3351 url: format!("/runs/{}/{stem}", e.run_id),
3352 });
3353 }
3354 }
3355 }
3356 }
3357 links.sort_by(|a, b| a.name.cmp(&b.name));
3358 links
3359 };
3360 let submodule_names_csv = submodule_links
3361 .iter()
3362 .map(|l| l.name.as_str())
3363 .collect::<Vec<_>>()
3364 .join(",");
3365 HistoryEntryRow {
3366 run_id: e.run_id.clone(),
3367 run_id_short: e
3368 .run_id
3369 .split('-')
3370 .next_back()
3371 .unwrap_or(&e.run_id)
3372 .chars()
3373 .take(7)
3374 .collect(),
3375 timestamp: fmt_pst(e.timestamp_utc),
3376 project_label: e.project_label.clone(),
3377 project_path: e
3378 .input_roots
3379 .first()
3380 .map(|s| sanitize_path_str(s))
3381 .unwrap_or_default(),
3382 files_analyzed: e.summary.files_analyzed,
3383 files_skipped: e.summary.files_skipped,
3384 code_lines: e.summary.code_lines,
3385 comment_lines: e.summary.comment_lines,
3386 blank_lines: e.summary.blank_lines,
3387 git_branch: e.git_branch.clone().unwrap_or_default(),
3388 git_commit: e.git_commit.clone().unwrap_or_default(),
3389 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
3390 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
3391 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
3392 submodule_links,
3393 submodule_names_csv,
3394 }
3395 })
3396 .collect()
3397}
3398
3399#[derive(Deserialize, Default)]
3400struct HistoryQuery {
3401 linked: Option<String>,
3402}
3403
3404async fn history_handler(
3405 State(state): State<AppState>,
3406 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3407 Query(query): Query<HistoryQuery>,
3408) -> impl IntoResponse {
3409 let mut entries = {
3410 let reg = state.registry.lock().await;
3411 make_history_rows(®)
3412 };
3413 entries.retain(|e| e.has_html);
3414 let total_scans = entries.len();
3415 let linked_count = query
3416 .linked
3417 .as_deref()
3418 .and_then(|s| s.parse::<usize>().ok())
3419 .unwrap_or(0);
3420 let template = HistoryTemplate {
3421 version: env!("CARGO_PKG_VERSION"),
3422 entries,
3423 total_scans,
3424 linked_count,
3425 csp_nonce,
3426 };
3427 Html(
3428 template
3429 .render()
3430 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
3431 )
3432 .into_response()
3433}
3434
3435async fn compare_select_handler(
3436 State(state): State<AppState>,
3437 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3438) -> impl IntoResponse {
3439 let mut entries = {
3440 let reg = state.registry.lock().await;
3441 make_history_rows(®)
3442 };
3443 entries.retain(|e| e.has_json);
3444 let total_scans = entries.len();
3445 let template = CompareSelectTemplate {
3446 version: env!("CARGO_PKG_VERSION"),
3447 entries,
3448 total_scans,
3449 csp_nonce,
3450 };
3451 Html(
3452 template
3453 .render()
3454 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
3455 )
3456 .into_response()
3457}
3458
3459#[derive(Deserialize, Default)]
3462struct CompareQuery {
3463 a: Option<String>,
3464 b: Option<String>,
3465 sub: Option<String>,
3467 scope: Option<String>,
3469}
3470
3471struct CompareFileDeltaRow {
3472 relative_path: String,
3473 language: String,
3474 status: String,
3475 baseline_code: i64,
3476 current_code: i64,
3477 code_delta_str: String,
3478 code_delta_class: String,
3479 comment_delta_str: String,
3480 comment_delta_class: String,
3481 total_delta_str: String,
3482 total_delta_class: String,
3483}
3484
3485fn recompute_summary_from_records(run: &mut AnalysisRun) {
3488 let files_analyzed = run
3489 .per_file_records
3490 .iter()
3491 .filter(|r| r.language.is_some())
3492 .count() as u64;
3493 let code_lines: u64 = run
3494 .per_file_records
3495 .iter()
3496 .map(|r| r.effective_counts.code_lines)
3497 .sum();
3498 let comment_lines: u64 = run
3499 .per_file_records
3500 .iter()
3501 .map(|r| r.effective_counts.comment_lines)
3502 .sum();
3503 let blank_lines: u64 = run
3504 .per_file_records
3505 .iter()
3506 .map(|r| r.effective_counts.blank_lines)
3507 .sum();
3508 run.summary_totals.files_analyzed = files_analyzed;
3509 run.summary_totals.files_considered = files_analyzed;
3510 run.summary_totals.code_lines = code_lines;
3511 run.summary_totals.comment_lines = comment_lines;
3512 run.summary_totals.blank_lines = blank_lines;
3513 run.summary_totals.total_physical_lines = code_lines + comment_lines + blank_lines;
3514}
3515
3516fn fmt_delta(n: i64) -> String {
3517 if n > 0 {
3518 format!("+{n}")
3519 } else {
3520 format!("{n}")
3521 }
3522}
3523
3524fn delta_class(n: i64) -> &'static str {
3525 use std::cmp::Ordering;
3526 match n.cmp(&0) {
3527 Ordering::Greater => "pos",
3528 Ordering::Less => "neg",
3529 Ordering::Equal => "zero",
3530 }
3531}
3532
3533fn fmt_pct(delta: i64, baseline: u64) -> String {
3534 if baseline == 0 {
3535 return "—".to_string();
3536 }
3537 let pct = (delta as f64 / baseline as f64) * 100.0;
3538 if pct > 0.049 {
3539 format!("+{pct:.1}%")
3540 } else if pct < -0.049 {
3541 format!("{pct:.1}%")
3542 } else {
3543 "±0%".to_string()
3544 }
3545}
3546
3547fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
3549 prev.map_or_else(
3550 || ("—".to_string(), "na"),
3551 |p| {
3552 #[allow(clippy::cast_possible_wrap)]
3553 let d = curr as i64 - p as i64;
3554 (fmt_delta(d), delta_class(d))
3555 },
3556 )
3557}
3558
3559#[allow(clippy::too_many_lines)]
3560async fn compare_handler(
3561 State(state): State<AppState>,
3562 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3563 Query(query): Query<CompareQuery>,
3564) -> impl IntoResponse {
3565 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
3568 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
3569 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
3570 };
3571
3572 let (maybe_a, maybe_b) = {
3573 let reg = state.registry.lock().await;
3574 (
3575 reg.find_by_run_id(&run_id_a).cloned(),
3576 reg.find_by_run_id(&run_id_b).cloned(),
3577 )
3578 };
3579
3580 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
3581 let html = ErrorTemplate {
3582 message: "One or both run IDs were not found in scan history. \
3583 The runs may have been deleted or the registry may have been reset."
3584 .to_string(),
3585 last_report_url: Some("/compare-scans".to_string()),
3586 last_report_label: Some("Compare Scans".to_string()),
3587 csp_nonce: csp_nonce.clone(),
3588 }
3589 .render()
3590 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
3591 return Html(html).into_response();
3592 };
3593
3594 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
3596 (entry_a, entry_b)
3597 } else {
3598 (entry_b, entry_a)
3599 };
3600
3601 if baseline_entry.run_id != run_id_a {
3605 let canonical = format!(
3606 "/compare?a={}&b={}",
3607 baseline_entry.run_id, current_entry.run_id
3608 );
3609 return axum::response::Redirect::to(&canonical).into_response();
3610 }
3611
3612 let (Some(base_json), Some(curr_json)) = (
3613 baseline_entry.json_path.as_ref(),
3614 current_entry.json_path.as_ref(),
3615 ) else {
3616 let html = ErrorTemplate {
3617 message: "Full comparison requires JSON scan data, which was not saved for one or \
3618 both of these runs. JSON is now always saved for new scans — re-run the \
3619 affected projects to enable comparisons."
3620 .to_string(),
3621 last_report_url: Some("/compare-scans".to_string()),
3622 last_report_label: Some("Compare Scans".to_string()),
3623 csp_nonce: csp_nonce.clone(),
3624 }
3625 .render()
3626 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
3627 return Html(html).into_response();
3628 };
3629
3630 let baseline_run = match read_json(base_json) {
3631 Ok(r) => r,
3632 Err(e) => {
3633 let message = if state.server_mode {
3634 "Could not load baseline scan data. The scan output folder may have been moved, \
3635 renamed, or deleted. Re-running the analysis will create fresh comparison data."
3636 .to_string()
3637 } else {
3638 format!(
3639 "Could not load baseline scan data.\n\nPath: {}\n\nError: {e}\n\n\
3640 The scan output folder may have been moved, renamed, or deleted. \
3641 Re-running the analysis for this project will create fresh comparison data.",
3642 base_json.display()
3643 )
3644 };
3645 let html = ErrorTemplate {
3646 message,
3647 last_report_url: Some("/compare-scans".to_string()),
3648 last_report_label: Some("Compare Scans".to_string()),
3649 csp_nonce: csp_nonce.clone(),
3650 }
3651 .render()
3652 .unwrap_or_else(|_| "<pre>Baseline load failed.</pre>".to_string());
3653 return (StatusCode::NOT_FOUND, Html(html)).into_response();
3654 }
3655 };
3656 let current_run = match read_json(curr_json) {
3657 Ok(r) => r,
3658 Err(e) => {
3659 let message = if state.server_mode {
3660 "Could not load current scan data. The scan output folder may have been moved, \
3661 renamed, or deleted. Re-running the analysis will create fresh comparison data."
3662 .to_string()
3663 } else {
3664 format!(
3665 "Could not load current scan data.\n\nPath: {}\n\nError: {e}\n\n\
3666 The scan output folder may have been moved, renamed, or deleted. \
3667 Re-running the analysis for this project will create fresh comparison data.",
3668 curr_json.display()
3669 )
3670 };
3671 let html = ErrorTemplate {
3672 message,
3673 last_report_url: Some("/compare-scans".to_string()),
3674 last_report_label: Some("Compare Scans".to_string()),
3675 csp_nonce: csp_nonce.clone(),
3676 }
3677 .render()
3678 .unwrap_or_else(|_| "<pre>Current load failed.</pre>".to_string());
3679 return (StatusCode::NOT_FOUND, Html(html)).into_response();
3680 }
3681 };
3682
3683 let active_submodule = query.sub.clone();
3684 let super_scope_active = query.scope.as_deref() == Some("super");
3685
3686 let submodule_options = {
3689 let mut names = std::collections::BTreeSet::new();
3690 for s in &baseline_run.submodule_summaries {
3691 names.insert(s.name.clone());
3692 }
3693 for s in ¤t_run.submodule_summaries {
3694 names.insert(s.name.clone());
3695 }
3696 names.into_iter().collect::<Vec<_>>()
3697 };
3698 let has_any_submodule_data = !submodule_options.is_empty();
3699
3700 let (effective_baseline, effective_current) = if let Some(ref sub_name) = active_submodule {
3702 let mut b = baseline_run.clone();
3703 let mut c = current_run.clone();
3704 b.per_file_records
3705 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
3706 c.per_file_records
3707 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
3708 recompute_summary_from_records(&mut b);
3709 recompute_summary_from_records(&mut c);
3710 (b, c)
3711 } else if super_scope_active {
3712 let mut b = baseline_run.clone();
3713 let mut c = current_run.clone();
3714 b.per_file_records.retain(|f| f.submodule.is_none());
3715 c.per_file_records.retain(|f| f.submodule.is_none());
3716 recompute_summary_from_records(&mut b);
3717 recompute_summary_from_records(&mut c);
3718 (b, c)
3719 } else {
3720 (baseline_run, current_run)
3721 };
3722
3723 let comparison = compute_delta(&effective_baseline, &effective_current);
3724
3725 let file_rows: Vec<CompareFileDeltaRow> = comparison
3726 .file_deltas
3727 .iter()
3728 .map(|d| CompareFileDeltaRow {
3729 relative_path: d.relative_path.clone(),
3730 language: d.language.clone().unwrap_or_else(|| "—".into()),
3731 status: match d.status {
3732 FileChangeStatus::Added => "added".into(),
3733 FileChangeStatus::Removed => "removed".into(),
3734 FileChangeStatus::Modified => "modified".into(),
3735 FileChangeStatus::Unchanged => "unchanged".into(),
3736 },
3737 baseline_code: d.baseline_code,
3738 current_code: d.current_code,
3739 code_delta_str: fmt_delta(d.code_delta),
3740 code_delta_class: delta_class(d.code_delta).into(),
3741 comment_delta_str: fmt_delta(d.comment_delta),
3742 comment_delta_class: delta_class(d.comment_delta).into(),
3743 total_delta_str: fmt_delta(d.total_delta),
3744 total_delta_class: delta_class(d.total_delta).into(),
3745 })
3746 .collect();
3747
3748 let project_path = baseline_entry
3749 .input_roots
3750 .first()
3751 .map(|s| sanitize_path_str(s))
3752 .unwrap_or_default();
3753 let lines_added = sum_added_code_lines(&comparison);
3754 let lines_removed = sum_removed_code_lines(&comparison);
3755 let new_scope = comparison.summary.baseline_code == 0 && comparison.summary.current_code > 0;
3758 let churn_pct = if comparison.summary.baseline_code > 0 {
3759 (lines_added + lines_removed) as f64 / comparison.summary.baseline_code as f64 * 100.0
3760 } else {
3761 0.0
3762 };
3763 let scope_flag = new_scope
3764 || (comparison.summary.baseline_code > 0
3765 && lines_added as f64 / comparison.summary.baseline_code as f64 > 0.20);
3766 let s = &comparison.summary;
3767 let template = CompareTemplate {
3768 version: env!("CARGO_PKG_VERSION"),
3769 project_label: baseline_entry.project_label.clone(),
3770 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
3771 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
3772 baseline_run_id: baseline_entry.run_id.clone(),
3773 current_run_id: current_entry.run_id.clone(),
3774 baseline_run_id_short: baseline_entry
3775 .run_id
3776 .split('-')
3777 .next_back()
3778 .unwrap_or(&baseline_entry.run_id)
3779 .chars()
3780 .take(7)
3781 .collect(),
3782 current_run_id_short: current_entry
3783 .run_id
3784 .split('-')
3785 .next_back()
3786 .unwrap_or(¤t_entry.run_id)
3787 .chars()
3788 .take(7)
3789 .collect(),
3790 baseline_timestamp: fmt_pst(baseline_entry.timestamp_utc),
3791 current_timestamp: fmt_pst(current_entry.timestamp_utc),
3792 project_path: project_path.clone(),
3793 baseline_code: s.baseline_code,
3794 current_code: s.current_code,
3795 code_lines_delta_str: fmt_delta(s.code_lines_delta),
3796 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
3797 baseline_files: s.baseline_files,
3798 current_files: s.current_files,
3799 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
3800 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
3801 baseline_comments: s.baseline_comments,
3802 current_comments: s.current_comments,
3803 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
3804 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
3805 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
3806 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
3807 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
3808 code_lines_added: lines_added,
3809 code_lines_removed: lines_removed,
3810 new_scope,
3811 churn_rate_str: if new_scope {
3812 "New".to_string()
3813 } else if s.baseline_code > 0 {
3814 format!("{churn_pct:.1}%")
3815 } else {
3816 "—".to_string()
3817 },
3818 churn_rate_class: if new_scope || churn_pct > 20.0 {
3819 "high".into()
3820 } else if churn_pct > 5.0 {
3821 "med".into()
3822 } else {
3823 "low".into()
3824 },
3825 scope_flag,
3826 files_added: comparison.files_added,
3827 files_removed: comparison.files_removed,
3828 files_modified: comparison.files_modified,
3829 files_unchanged: comparison.files_unchanged,
3830 file_rows,
3831 baseline_git_author: baseline_entry.git_author.clone(),
3832 current_git_author: current_entry.git_author.clone(),
3833 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
3834 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
3835 baseline_git_tags: baseline_entry.git_tags.clone(),
3836 current_git_tags: current_entry.git_tags.clone(),
3837 baseline_git_commit_date: baseline_entry
3838 .git_commit_date
3839 .as_deref()
3840 .and_then(fmt_git_date),
3841 current_git_commit_date: current_entry
3842 .git_commit_date
3843 .as_deref()
3844 .and_then(fmt_git_date),
3845 project_name: project_path
3846 .rsplit(['/', '\\'])
3847 .find(|s| !s.is_empty())
3848 .unwrap_or(&project_path)
3849 .to_string(),
3850 submodule_options,
3851 has_any_submodule_data,
3852 active_submodule,
3853 super_scope_active,
3854 csp_nonce,
3855 };
3856
3857 Html(
3858 template
3859 .render()
3860 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
3861 )
3862 .into_response()
3863}
3864
3865fn format_number(n: u64) -> String {
3873 let s = n.to_string();
3874 let mut out = String::with_capacity(s.len() + s.len() / 3);
3875 let len = s.len();
3876 for (i, c) in s.chars().enumerate() {
3877 if i > 0 && (len - i).is_multiple_of(3) {
3878 out.push(',');
3879 }
3880 out.push(c);
3881 }
3882 out
3883}
3884
3885const fn badge_char_width(c: char) -> f64 {
3886 match c {
3887 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
3888 'm' | 'w' => 9.0,
3889 ' ' => 4.0,
3890 _ => 6.5,
3891 }
3892}
3893
3894#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3895fn badge_text_px(text: &str) -> u32 {
3896 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
3897}
3898
3899fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
3900 let lw = badge_text_px(label) + 20;
3901 let rw = badge_text_px(value) + 20;
3902 let total = lw + rw;
3903 let lx = lw / 2;
3904 let rx = lw + rw / 2;
3905 let le = escape_html(label);
3906 let ve = escape_html(value);
3907 let ce = escape_html(color);
3908 format!(
3909 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
3910 <rect width="{total}" height="20" fill="#555"/>
3911 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
3912 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
3913 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
3914 <text x="{lx}" y="13">{le}</text>
3915 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
3916 <text x="{rx}" y="13">{ve}</text>
3917 </g>
3918</svg>"##
3919 )
3920}
3921
3922#[derive(Deserialize)]
3923struct BadgeQuery {
3924 label: Option<String>,
3925 color: Option<String>,
3926}
3927
3928async fn badge_handler(
3929 State(state): State<AppState>,
3930 AxumPath(metric): AxumPath<String>,
3931 Query(query): Query<BadgeQuery>,
3932) -> Response {
3933 let entry = {
3934 let reg = state.registry.lock().await;
3935 reg.entries.first().cloned()
3936 };
3937
3938 let Some(entry) = entry else {
3939 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
3940 return (
3941 [
3942 (header::CONTENT_TYPE, "image/svg+xml"),
3943 (header::CACHE_CONTROL, "no-cache, max-age=0"),
3944 ],
3945 svg,
3946 )
3947 .into_response();
3948 };
3949
3950 let (default_label, value, default_color) = match metric.as_str() {
3951 "code-lines" => (
3952 "code lines",
3953 format_number(entry.summary.code_lines),
3954 "#4a78ee",
3955 ),
3956 "files" => (
3957 "files analyzed",
3958 format_number(entry.summary.files_analyzed),
3959 "#4a9862",
3960 ),
3961 "comment-lines" => (
3962 "comment lines",
3963 format_number(entry.summary.comment_lines),
3964 "#b35428",
3965 ),
3966 "blank-lines" => (
3967 "blank lines",
3968 format_number(entry.summary.blank_lines),
3969 "#7a5db0",
3970 ),
3971 _ => return StatusCode::NOT_FOUND.into_response(),
3972 };
3973
3974 let label = query.label.as_deref().unwrap_or(default_label);
3975 let color = query.color.as_deref().unwrap_or(default_color);
3976 let svg = render_badge_svg(label, &value, color);
3977
3978 (
3979 [
3980 (header::CONTENT_TYPE, "image/svg+xml"),
3981 (header::CACHE_CONTROL, "no-cache, max-age=0"),
3982 ],
3983 svg,
3984 )
3985 .into_response()
3986}
3987
3988#[derive(Serialize)]
3996struct ApiMetricsResponse {
3997 run_id: String,
3998 timestamp: String,
3999 project: String,
4000 summary: ApiSummaryPayload,
4001 languages: Vec<ApiLanguageRow>,
4002}
4003
4004#[derive(Serialize)]
4005struct ApiSummaryPayload {
4006 files_analyzed: u64,
4007 files_skipped: u64,
4008 code_lines: u64,
4009 comment_lines: u64,
4010 blank_lines: u64,
4011 total_physical_lines: u64,
4012 functions: u64,
4013 classes: u64,
4014 variables: u64,
4015 imports: u64,
4016}
4017
4018#[derive(Serialize)]
4019struct ApiLanguageRow {
4020 name: String,
4021 files: u64,
4022 code_lines: u64,
4023 comment_lines: u64,
4024 blank_lines: u64,
4025 functions: u64,
4026 classes: u64,
4027 variables: u64,
4028 imports: u64,
4029}
4030
4031async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
4032 let entry = {
4033 let reg = state.registry.lock().await;
4034 reg.entries.first().cloned()
4035 };
4036 entry.map_or_else(
4037 || {
4038 (
4039 StatusCode::NOT_FOUND,
4040 Json(serde_json::json!({"error": "no scans recorded yet"})),
4041 )
4042 .into_response()
4043 },
4044 |e| build_metrics_response(&e),
4045 )
4046}
4047
4048async fn api_metrics_run_handler(
4049 State(state): State<AppState>,
4050 AxumPath(run_id): AxumPath<String>,
4051) -> Response {
4052 let entry = {
4053 let reg = state.registry.lock().await;
4054 reg.find_by_run_id(&run_id).cloned()
4055 };
4056 entry.map_or_else(
4057 || {
4058 (
4059 StatusCode::NOT_FOUND,
4060 Json(serde_json::json!({"error": "run not found"})),
4061 )
4062 .into_response()
4063 },
4064 |e| build_metrics_response(&e),
4065 )
4066}
4067
4068fn build_metrics_response(entry: &RegistryEntry) -> Response {
4069 let languages: Vec<ApiLanguageRow> = entry
4070 .json_path
4071 .as_ref()
4072 .and_then(|p| read_json(p).ok())
4073 .map(|run| {
4074 run.totals_by_language
4075 .iter()
4076 .map(|l| ApiLanguageRow {
4077 name: l.language.display_name().to_string(),
4078 files: l.files,
4079 code_lines: l.code_lines,
4080 comment_lines: l.comment_lines,
4081 blank_lines: l.blank_lines,
4082 functions: l.functions,
4083 classes: l.classes,
4084 variables: l.variables,
4085 imports: l.imports,
4086 })
4087 .collect()
4088 })
4089 .unwrap_or_default();
4090
4091 let s = &entry.summary;
4092 Json(ApiMetricsResponse {
4093 run_id: entry.run_id.clone(),
4094 timestamp: entry.timestamp_utc.to_rfc3339(),
4095 project: entry.project_label.clone(),
4096 summary: ApiSummaryPayload {
4097 files_analyzed: s.files_analyzed,
4098 files_skipped: s.files_skipped,
4099 code_lines: s.code_lines,
4100 comment_lines: s.comment_lines,
4101 blank_lines: s.blank_lines,
4102 total_physical_lines: s.total_physical_lines,
4103 functions: s.functions,
4104 classes: s.classes,
4105 variables: s.variables,
4106 imports: s.imports,
4107 },
4108 languages,
4109 })
4110 .into_response()
4111}
4112
4113#[derive(Deserialize)]
4120struct ProjectHistoryQuery {
4121 path: Option<String>,
4122}
4123
4124#[derive(Serialize)]
4125struct ProjectHistoryResponse {
4126 scan_count: usize,
4127 last_scan_id: Option<String>,
4128 last_scan_timestamp: Option<String>,
4129 last_scan_code_lines: Option<u64>,
4130 last_git_branch: Option<String>,
4131 last_git_commit: Option<String>,
4132}
4133
4134async fn project_history_handler(
4135 State(state): State<AppState>,
4136 Query(query): Query<ProjectHistoryQuery>,
4137) -> Response {
4138 let path = query.path.unwrap_or_default();
4139 let resolved = resolve_input_path(&path);
4140 let root_str = resolved.to_string_lossy().replace('\\', "/");
4141
4142 let entries: Vec<_> = {
4143 let reg = state.registry.lock().await;
4144 reg.entries
4145 .iter()
4146 .filter(|e| e.input_roots.iter().any(|r| r == &root_str))
4147 .cloned()
4148 .collect()
4149 };
4150 let scan_count = entries.len();
4151 let last = entries.first();
4152 let last_scan_id = last.map(|e| e.run_id.clone());
4153 let last_scan_timestamp = last.map(|e| fmt_pst(e.timestamp_utc));
4154 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
4155 let last_git_branch = last.and_then(|e| e.git_branch.clone());
4156 let last_git_commit = last.and_then(|e| e.git_commit.clone());
4157
4158 Json(ProjectHistoryResponse {
4159 scan_count,
4160 last_scan_id,
4161 last_scan_timestamp,
4162 last_scan_code_lines,
4163 last_git_branch,
4164 last_git_commit,
4165 })
4166 .into_response()
4167}
4168
4169#[derive(Deserialize)]
4176struct EmbedQuery {
4177 run_id: Option<String>,
4178 theme: Option<String>,
4179}
4180
4181async fn embed_handler(
4182 State(state): State<AppState>,
4183 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4184 Query(query): Query<EmbedQuery>,
4185) -> Response {
4186 let entry = {
4187 let reg = state.registry.lock().await;
4188 query.run_id.as_ref().map_or_else(
4189 || reg.entries.first().cloned(),
4190 |id| reg.find_by_run_id(id).cloned(),
4191 )
4192 };
4193
4194 let Some(entry) = entry else {
4195 return Html(
4196 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
4197 .to_string(),
4198 )
4199 .into_response();
4200 };
4201
4202 let dark = query.theme.as_deref() == Some("dark");
4203 let languages: Vec<(String, u64, u64)> = entry
4204 .json_path
4205 .as_ref()
4206 .and_then(|p| read_json(p).ok())
4207 .map(|run| {
4208 run.totals_by_language
4209 .iter()
4210 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
4211 .collect()
4212 })
4213 .unwrap_or_default();
4214
4215 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
4216}
4217
4218fn render_embed_widget(
4219 entry: &RegistryEntry,
4220 languages: &[(String, u64, u64)],
4221 dark: bool,
4222 csp_nonce: &str,
4223) -> String {
4224 let s = &entry.summary;
4225 let total = s.code_lines + s.comment_lines + s.blank_lines;
4226 let code_pct = s
4227 .code_lines
4228 .checked_mul(100)
4229 .and_then(|n| n.checked_div(total))
4230 .unwrap_or(0);
4231
4232 let (bg, fg, surface, muted, border) = if dark {
4233 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
4234 } else {
4235 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
4236 };
4237
4238 let mut lang_rows = String::new();
4239 for (name, files, code) in languages {
4240 write!(
4241 lang_rows,
4242 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
4243 escape_html(name),
4244 format_number(*files),
4245 format_number(*code),
4246 )
4247 .ok();
4248 }
4249
4250 let lang_table = if lang_rows.is_empty() {
4251 String::new()
4252 } else {
4253 format!(
4254 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
4255 )
4256 };
4257
4258 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
4259 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
4260 let project_esc = escape_html(&entry.project_label);
4261 let code_lines = format_number(s.code_lines);
4262 let comment_lines = format_number(s.comment_lines);
4263 let files = format_number(s.files_analyzed);
4264 let code_raw = s.code_lines;
4265 let comment_raw = s.comment_lines;
4266 let blank_raw = s.blank_lines;
4267
4268 format!(
4269 r#"<!doctype html>
4270<html lang="en">
4271<head>
4272 <meta charset="utf-8">
4273 <meta name="viewport" content="width=device-width,initial-scale=1">
4274 <title>OxideSLOC — {project_esc}</title>
4275 <script src="/static/chart.js"></script>
4276 <style nonce="{csp_nonce}">
4277 *{{box-sizing:border-box;margin:0;padding:0}}
4278 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
4279 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
4280 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
4281 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
4282 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
4283 .card .v{{font-size:18px;font-weight:700}}
4284 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
4285 .row{{display:flex;gap:12px;align-items:flex-start}}
4286 .pie{{width:120px;height:120px;flex-shrink:0}}
4287 .lt{{border-collapse:collapse;width:100%;flex:1}}
4288 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
4289 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
4290 .n{{text-align:right}}
4291 .footer{{margin-top:10px;color:{muted};font-size:10px}}
4292 </style>
4293</head>
4294<body>
4295 <h2>{project_esc}</h2>
4296 <div class="sub">{timestamp} · run {run_short}</div>
4297 <div class="cards">
4298 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
4299 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
4300 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
4301 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
4302 </div>
4303 <div class="row">
4304 <canvas class="pie" id="c"></canvas>
4305 {lang_table}
4306 </div>
4307 <div class="footer">oxide-sloc</div>
4308 <script nonce="{csp_nonce}">
4309 new Chart(document.getElementById('c'),{{
4310 type:'doughnut',
4311 data:{{
4312 labels:['Code','Comments','Blank'],
4313 datasets:[{{
4314 data:[{code_raw},{comment_raw},{blank_raw}],
4315 backgroundColor:['#4a78ee','#b35428','#aaa'],
4316 borderWidth:0
4317 }}]
4318 }},
4319 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
4320 }});
4321 </script>
4322</body>
4323</html>"#
4324 )
4325}
4326
4327#[allow(clippy::too_many_arguments)]
4328fn persist_run_artifacts(
4329 run: &sloc_core::AnalysisRun,
4330 report_html: &str,
4331 run_dir: &Path,
4332 generate_json: bool,
4333 generate_html: bool,
4334 generate_pdf: bool,
4335 report_title: &str,
4336 file_stem: &str,
4337 result_context: RunResultContext,
4338) -> Result<(RunArtifacts, PendingPdf)> {
4339 fs::create_dir_all(run_dir)
4340 .with_context(|| format!("failed to create output directory {}", run_dir.display()))?;
4341
4342 let mut html_path = None;
4343 let mut pdf_path = None;
4344 let mut json_path = None;
4345 let mut pending_pdf: Option<(PathBuf, PathBuf, bool)> = None;
4346
4347 if generate_html {
4348 let path = run_dir.join(format!("report_{file_stem}.html"));
4349 fs::write(&path, report_html)
4350 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
4351 html_path = Some(path);
4352 }
4353
4354 if generate_json {
4355 let path = run_dir.join(format!("result_{file_stem}.json"));
4356 let json = serde_json::to_string_pretty(run)
4357 .context("failed to serialize analysis run to JSON")?;
4358 fs::write(&path, json)
4359 .with_context(|| format!("failed to write JSON report to {}", path.display()))?;
4360 json_path = Some(path);
4361 }
4362
4363 if generate_pdf {
4364 let source_html_path = if let Some(existing) = html_path.as_ref() {
4365 existing.clone()
4366 } else {
4367 let temp_html = run_dir.join("_report_rendered.html");
4368 fs::write(&temp_html, report_html).with_context(|| {
4369 format!(
4370 "failed to write temporary HTML report to {}",
4371 temp_html.display()
4372 )
4373 })?;
4374 temp_html
4375 };
4376
4377 let pdf_dest = run_dir.join(format!("report_{file_stem}.pdf"));
4378 let cleanup_src = !generate_html;
4379 pdf_path = Some(pdf_dest.clone());
4380 pending_pdf = Some((source_html_path, pdf_dest, cleanup_src));
4381 }
4382
4383 let scan_config_path = Some(run_dir.join(format!("scan-config_{file_stem}.json")));
4384
4385 Ok((
4386 RunArtifacts {
4387 output_dir: run_dir.to_path_buf(),
4388 html_path,
4389 pdf_path,
4390 json_path,
4391 scan_config_path,
4392 report_title: report_title.to_string(),
4393 result_context,
4394 },
4395 pending_pdf,
4396 ))
4397}
4398
4399fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
4402 let exact = dir.join("scan-config.json");
4403 if exact.exists() {
4404 return Some(exact);
4405 }
4406 fs::read_dir(dir).ok().and_then(|entries| {
4407 entries
4408 .filter_map(|e| e.ok())
4409 .find(|e| {
4410 let name = e.file_name();
4411 let name = name.to_string_lossy();
4412 name.starts_with("scan-config") && name.ends_with(".json")
4413 })
4414 .map(|e| e.path())
4415 })
4416}
4417
4418fn resolve_output_root(raw: Option<&str>) -> PathBuf {
4419 let value = raw.unwrap_or("out/web").trim();
4420 let path = if value.is_empty() {
4421 PathBuf::from("out/web")
4422 } else {
4423 PathBuf::from(value)
4424 };
4425
4426 if path.is_absolute() {
4427 path
4428 } else {
4429 workspace_root().join(path)
4430 }
4431}
4432
4433fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
4435 std::env::var("SLOC_GIT_CLONES_DIR")
4436 .map(PathBuf::from)
4437 .unwrap_or_else(|_| output_root.join("git-clones"))
4438}
4439
4440pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
4443 let safe: String = repo_url
4444 .chars()
4445 .map(|c| {
4446 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
4447 c
4448 } else {
4449 '_'
4450 }
4451 })
4452 .take(80)
4453 .collect();
4454 clones_dir.join(safe)
4455}
4456
4457pub(crate) fn scan_path_to_artifacts(
4460 scan_path: &Path,
4461 base_config: &AppConfig,
4462 label: &str,
4463) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
4464 let mut config = base_config.clone();
4465 config.discovery.root_paths = vec![scan_path.to_path_buf()];
4466 config.reporting.report_title = label.to_owned();
4467 let run = analyze(&config, "git")?;
4468 let html = render_html(&run)?;
4469 let run_id = run.tool.run_id.clone();
4470 let project_label = sanitize_project_label(label);
4471 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
4472 let file_stem = {
4473 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
4474 if commit.is_empty() {
4475 project_label.clone()
4476 } else {
4477 format!("{project_label}_{commit}")
4478 }
4479 };
4480 let (artifacts, _pending_pdf) = persist_run_artifacts(
4481 &run,
4482 &html,
4483 &output_dir,
4484 true,
4485 true,
4486 false,
4487 label,
4488 &file_stem,
4489 RunResultContext::default(),
4490 )?;
4491 Ok((run_id, artifacts, run))
4492}
4493
4494async fn restart_poll_schedules(state: &AppState) {
4496 let store = state.schedules.lock().await;
4497 let poll_schedules: Vec<_> = store
4498 .schedules
4499 .iter()
4500 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
4501 .cloned()
4502 .collect();
4503 drop(store);
4504 for schedule in poll_schedules {
4505 let interval = schedule.interval_secs.unwrap_or(300);
4506 let st = state.clone();
4507 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
4508 }
4509}
4510
4511fn split_patterns(raw: Option<&str>) -> Vec<String> {
4512 raw.unwrap_or("")
4513 .lines()
4514 .flat_map(|line| line.split(','))
4515 .map(str::trim)
4516 .filter(|part| !part.is_empty())
4517 .map(ToOwned::to_owned)
4518 .collect()
4519}
4520
4521fn build_sub_run(
4522 parent: &AnalysisRun,
4523 sub: &sloc_core::SubmoduleSummary,
4524 parent_path: &str,
4525) -> AnalysisRun {
4526 let sub_files: Vec<_> = parent
4527 .per_file_records
4528 .iter()
4529 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
4530 .cloned()
4531 .collect();
4532 let mut config = parent.effective_configuration.clone();
4533 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
4534 AnalysisRun {
4535 tool: parent.tool.clone(),
4536 environment: parent.environment.clone(),
4537 effective_configuration: config,
4538 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
4539 summary_totals: SummaryTotals {
4540 files_considered: sub.files_analyzed,
4541 files_analyzed: sub.files_analyzed,
4542 files_skipped: 0,
4543 total_physical_lines: sub.total_physical_lines,
4544 code_lines: sub.code_lines,
4545 comment_lines: sub.comment_lines,
4546 blank_lines: sub.blank_lines,
4547 mixed_lines_separate: 0,
4548 functions: 0,
4549 classes: 0,
4550 variables: 0,
4551 imports: 0,
4552 },
4553 totals_by_language: sub.language_summaries.clone(),
4554 per_file_records: sub_files,
4555 skipped_file_records: vec![],
4556 warnings: vec![],
4557 submodule_summaries: vec![],
4558 git_commit_short: parent.git_commit_short.clone(),
4559 git_commit_long: parent.git_commit_long.clone(),
4560 git_branch: parent.git_branch.clone(),
4561 git_commit_author: parent.git_commit_author.clone(),
4562 git_commit_date: parent.git_commit_date.clone(),
4563 git_tags: parent.git_tags.clone(),
4564 }
4565}
4566
4567pub(crate) fn sanitize_project_label(raw: &str) -> String {
4568 let candidate = Path::new(raw)
4569 .file_name()
4570 .and_then(|name| name.to_str())
4571 .unwrap_or("project");
4572
4573 let mut value = String::with_capacity(candidate.len());
4574 for ch in candidate.chars() {
4575 if ch.is_ascii_alphanumeric() {
4576 value.push(ch.to_ascii_lowercase());
4577 } else {
4578 value.push('-');
4579 }
4580 }
4581
4582 let compact = value.trim_matches('-').to_string();
4583 if compact.is_empty() {
4584 "project".to_string()
4585 } else {
4586 compact
4587 }
4588}
4589
4590fn strip_unc_prefix(path: PathBuf) -> PathBuf {
4593 let s = path.to_string_lossy();
4594 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
4595 return PathBuf::from(format!(r"\\{rest}"));
4596 }
4597 if let Some(rest) = s.strip_prefix(r"\\?\") {
4598 return PathBuf::from(rest);
4599 }
4600 path
4601}
4602
4603fn display_path(path: &Path) -> String {
4604 let s = path.to_string_lossy();
4605 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
4610 return format!(r"\\{rest}");
4611 }
4612 if let Some(rest) = s.strip_prefix(r"\\?\") {
4613 return rest.to_owned();
4614 }
4615 s.into_owned()
4616}
4617
4618fn sanitize_path_str(s: &str) -> String {
4619 if let Some(rest) = s.strip_prefix("//?/UNC/") {
4623 return format!("//{rest}");
4624 }
4625 if let Some(rest) = s.strip_prefix("//?/") {
4626 return rest.to_owned();
4627 }
4628 display_path(Path::new(s))
4629}
4630
4631fn workspace_root() -> PathBuf {
4632 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
4634 let p = PathBuf::from(root);
4635 if p.is_dir() {
4636 return p;
4637 }
4638 }
4639
4640 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
4643}
4644
4645fn make_git_label(repo: &str, ref_name: &str) -> String {
4647 if repo.is_empty() || ref_name.is_empty() {
4648 return String::new();
4649 }
4650 let base = repo
4651 .trim_end_matches('/')
4652 .trim_end_matches(".git")
4653 .rsplit('/')
4654 .next()
4655 .unwrap_or("repo");
4656 let ref_safe: String = ref_name
4657 .chars()
4658 .map(|c| {
4659 if c.is_alphanumeric() || c == '-' || c == '.' {
4660 c
4661 } else {
4662 '_'
4663 }
4664 })
4665 .collect();
4666 format!("{base}_at_{ref_safe}_sloc")
4667}
4668
4669fn desktop_dir() -> PathBuf {
4671 if let Ok(profile) = std::env::var("USERPROFILE") {
4672 let p = PathBuf::from(profile).join("Desktop");
4673 if p.exists() {
4674 return p;
4675 }
4676 }
4677 if let Ok(home) = std::env::var("HOME") {
4678 let p = PathBuf::from(home).join("Desktop");
4679 if p.exists() {
4680 return p;
4681 }
4682 }
4683 workspace_root().join("out").join("web")
4684}
4685
4686fn resolve_input_path(raw: &str) -> PathBuf {
4687 let trimmed = raw.trim();
4688 if trimmed.is_empty() {
4689 return workspace_root().join("samples").join("basic");
4690 }
4691
4692 let candidate = PathBuf::from(trimmed);
4693 let resolved = if candidate.is_absolute() {
4694 candidate
4695 } else {
4696 let rooted = workspace_root().join(&candidate);
4697 if rooted.exists() {
4698 rooted
4699 } else {
4700 workspace_root().join(candidate)
4701 }
4702 };
4703
4704 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
4707 PathBuf::from(display_path(&canonical))
4708}
4709
4710#[allow(clippy::too_many_lines)]
4711fn build_preview_html(
4712 root: &Path,
4713 include_patterns: &[String],
4714 exclude_patterns: &[String],
4715) -> Result<String> {
4716 if !root.exists() {
4717 return Ok(format!(
4718 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
4719 escape_html(&display_path(root))
4720 ));
4721 }
4722
4723 let _selected = display_path(root);
4724 let mut stats = PreviewStats::default();
4725 let mut rows = Vec::new();
4726 let mut languages = Vec::new();
4727 let mut budget = PreviewBudget {
4728 shown: 0,
4729 max_entries: 600,
4730 max_depth: 9,
4731 };
4732 let mut next_row_id = 1usize;
4733
4734 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
4735 || root.to_string_lossy().into_owned(),
4736 std::string::ToString::to_string,
4737 );
4738 let root_modified = root
4739 .metadata()
4740 .ok()
4741 .and_then(|meta| meta.modified().ok())
4742 .map_or_else(|| "-".to_string(), format_system_time);
4743
4744 rows.push(PreviewRow {
4745 row_id: 0,
4746 parent_row_id: None,
4747 depth: 0,
4748 name: format!("{root_name}/"),
4749 kind: PreviewKind::Dir,
4750 is_dir: true,
4751 language: None,
4752 modified: root_modified,
4753 type_label: "Directory".to_string(),
4754 });
4755 collect_preview_rows(
4756 root,
4757 root,
4758 0,
4759 Some(0),
4760 &mut next_row_id,
4761 &mut budget,
4762 &mut stats,
4763 &mut rows,
4764 &mut languages,
4765 include_patterns,
4766 exclude_patterns,
4767 )?;
4768
4769 let mut out = String::new();
4770 out.push_str(r#"<div class="explorer-wrap">"#);
4771 out.push_str(r#"<div class="explorer-toolbar compact">"#);
4772 out.push_str(r#"<div class="explorer-title-group">"#);
4773 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
4774 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
4775 out.push_str(r"</div></div>");
4776
4777 out.push_str(r#"<div class="scope-stats">"#);
4778 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();
4779 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();
4780 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();
4781 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();
4782 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();
4783 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>"#);
4784 out.push_str(r"</div>");
4785
4786 out.push_str(r#"<div class="scope-info-row">"#);
4787 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
4788 if languages.is_empty() {
4789 out.push_str(
4790 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
4791 );
4792 } else {
4793 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
4794 for language in &languages {
4795 if let Some(icon) = language_icon_file(language) {
4796 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();
4797 } else if let Some(svg) = language_inline_svg(language) {
4798 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();
4799 } else {
4800 write!(
4801 out,
4802 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
4803 escape_html(&language.to_ascii_lowercase()),
4804 escape_html(language)
4805 )
4806 .ok();
4807 }
4808 }
4809 }
4810 out.push_str(r"</div></div>");
4811 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>"#);
4812 out.push_str(r"</div>");
4813
4814 out.push_str(r#"<div class="file-explorer-shell">"#);
4815 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>"#);
4816 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>"#);
4817 out.push_str(r#"<div class="file-explorer-tree">"#);
4818 for row in rows {
4819 let status_label = row.kind.label();
4820 let lang_attr = row.language.unwrap_or("");
4821 let toggle_html = if row.is_dir {
4822 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
4823 .to_string()
4824 } else {
4825 r#"<span class="tree-bullet">•</span>"#.to_string()
4826 };
4827 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();
4828 }
4829 if budget.shown >= budget.max_entries {
4830 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>"#);
4831 }
4832 out.push_str(r"</div></div></div>");
4833
4834 Ok(out)
4835}
4836
4837#[derive(Default)]
4838struct PreviewStats {
4839 directories: usize,
4840 files: usize,
4841 supported: usize,
4842 skipped: usize,
4843 unsupported: usize,
4844}
4845
4846struct PreviewRow {
4847 row_id: usize,
4848 parent_row_id: Option<usize>,
4849 depth: usize,
4850 name: String,
4851 kind: PreviewKind,
4852 is_dir: bool,
4853 language: Option<&'static str>,
4854 modified: String,
4855 type_label: String,
4856}
4857
4858#[derive(Copy, Clone)]
4859enum PreviewKind {
4860 Dir,
4861 Supported,
4862 Skipped,
4863 Unsupported,
4864}
4865
4866impl PreviewKind {
4867 const fn filter_key(self) -> &'static str {
4868 match self {
4869 Self::Dir => "dir",
4870 Self::Supported => "supported",
4871 Self::Skipped => "skipped",
4872 Self::Unsupported => "unsupported",
4873 }
4874 }
4875
4876 const fn label(self) -> &'static str {
4877 match self {
4878 Self::Dir => "dir",
4879 Self::Supported => "supported",
4880 Self::Skipped => "skipped by policy",
4881 Self::Unsupported => "unsupported",
4882 }
4883 }
4884
4885 const fn badge_class(self) -> &'static str {
4886 match self {
4887 Self::Dir => "badge badge-dir",
4888 Self::Supported => "badge badge-scan",
4889 Self::Skipped => "badge badge-skip",
4890 Self::Unsupported => "badge badge-unsupported",
4891 }
4892 }
4893
4894 const fn node_class(self) -> &'static str {
4895 match self {
4896 Self::Dir => "tree-node-dir",
4897 Self::Supported => "tree-node-supported",
4898 Self::Skipped => "tree-node-skipped",
4899 Self::Unsupported => "tree-node-unsupported",
4900 }
4901 }
4902}
4903
4904struct PreviewBudget {
4905 shown: usize,
4906 max_entries: usize,
4907 max_depth: usize,
4908}
4909
4910#[allow(clippy::too_many_arguments)]
4913fn handle_preview_dir_entry(
4914 root: &Path,
4915 path: &Path,
4916 name: &str,
4917 modified: String,
4918 depth: usize,
4919 parent_row_id: Option<usize>,
4920 row_id: usize,
4921 next_row_id: &mut usize,
4922 budget: &mut PreviewBudget,
4923 stats: &mut PreviewStats,
4924 rows: &mut Vec<PreviewRow>,
4925 languages: &mut Vec<&'static str>,
4926 include_patterns: &[String],
4927 exclude_patterns: &[String],
4928) -> Result<()> {
4929 let relative = preview_relative_path(root, path);
4930 if should_skip_preview_directory(&relative, exclude_patterns) {
4931 return Ok(());
4932 }
4933 stats.directories += 1;
4934 rows.push(PreviewRow {
4935 row_id,
4936 parent_row_id,
4937 depth: depth + 1,
4938 name: format!("{name}/"),
4939 kind: PreviewKind::Dir,
4940 is_dir: true,
4941 language: None,
4942 modified,
4943 type_label: "Directory".to_string(),
4944 });
4945 budget.shown += 1;
4946 if !matches!(name, ".git" | "node_modules" | "target") {
4947 collect_preview_rows(
4948 root,
4949 path,
4950 depth + 1,
4951 Some(row_id),
4952 next_row_id,
4953 budget,
4954 stats,
4955 rows,
4956 languages,
4957 include_patterns,
4958 exclude_patterns,
4959 )?;
4960 }
4961 Ok(())
4962}
4963
4964#[allow(clippy::too_many_arguments)]
4966fn handle_preview_file_entry(
4967 root: &Path,
4968 path: &Path,
4969 name: &str,
4970 modified: String,
4971 depth: usize,
4972 parent_row_id: Option<usize>,
4973 row_id: usize,
4974 budget: &mut PreviewBudget,
4975 stats: &mut PreviewStats,
4976 rows: &mut Vec<PreviewRow>,
4977 languages: &mut Vec<&'static str>,
4978 include_patterns: &[String],
4979 exclude_patterns: &[String],
4980) {
4981 let relative = preview_relative_path(root, path);
4982 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
4983 return;
4984 }
4985 stats.files += 1;
4986 let kind = classify_preview_file(name);
4987 match kind {
4988 PreviewKind::Supported => stats.supported += 1,
4989 PreviewKind::Skipped => stats.skipped += 1,
4990 PreviewKind::Unsupported => stats.unsupported += 1,
4991 PreviewKind::Dir => {}
4992 }
4993 let language = detect_language_name(name);
4994 if let Some(lang) = language {
4995 if !languages.contains(&lang) {
4996 languages.push(lang);
4997 }
4998 }
4999 rows.push(PreviewRow {
5000 row_id,
5001 parent_row_id,
5002 depth: depth + 1,
5003 name: name.to_owned(),
5004 kind,
5005 is_dir: false,
5006 language,
5007 modified,
5008 type_label: preview_type_label(name, language, kind),
5009 });
5010 budget.shown += 1;
5011}
5012
5013#[allow(clippy::too_many_arguments)]
5014#[allow(clippy::too_many_lines)]
5015fn collect_preview_rows(
5016 root: &Path,
5017 dir: &Path,
5018 depth: usize,
5019 parent_row_id: Option<usize>,
5020 next_row_id: &mut usize,
5021 budget: &mut PreviewBudget,
5022 stats: &mut PreviewStats,
5023 rows: &mut Vec<PreviewRow>,
5024 languages: &mut Vec<&'static str>,
5025 include_patterns: &[String],
5026 exclude_patterns: &[String],
5027) -> Result<()> {
5028 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
5029 return Ok(());
5030 }
5031
5032 let mut entries = fs::read_dir(dir)
5033 .with_context(|| format!("failed to read directory {}", dir.display()))?
5034 .filter_map(std::result::Result::ok)
5035 .collect::<Vec<_>>();
5036 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
5037
5038 for entry in entries {
5039 if budget.shown >= budget.max_entries {
5040 break;
5041 }
5042
5043 let path = entry.path();
5044 let name = entry.file_name().to_string_lossy().into_owned();
5045 let Ok(metadata) = entry.metadata() else {
5046 continue;
5047 };
5048 let row_id = *next_row_id;
5049 *next_row_id += 1;
5050 let modified = metadata
5051 .modified()
5052 .ok()
5053 .map_or_else(|| "-".to_string(), format_system_time);
5054
5055 if metadata.is_dir() {
5056 handle_preview_dir_entry(
5057 root,
5058 &path,
5059 &name,
5060 modified,
5061 depth,
5062 parent_row_id,
5063 row_id,
5064 next_row_id,
5065 budget,
5066 stats,
5067 rows,
5068 languages,
5069 include_patterns,
5070 exclude_patterns,
5071 )?;
5072 continue;
5073 }
5074
5075 if metadata.is_file() {
5076 handle_preview_file_entry(
5077 root,
5078 &path,
5079 &name,
5080 modified,
5081 depth,
5082 parent_row_id,
5083 row_id,
5084 budget,
5085 stats,
5086 rows,
5087 languages,
5088 include_patterns,
5089 exclude_patterns,
5090 );
5091 }
5092 }
5093
5094 Ok(())
5095}
5096
5097fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
5098 if let Some(language) = language {
5099 return format!("{language} source");
5100 }
5101 let lower = name.to_ascii_lowercase();
5102 let ext = Path::new(&lower)
5103 .extension()
5104 .and_then(|e| e.to_str())
5105 .unwrap_or("");
5106 match kind {
5107 PreviewKind::Skipped => {
5108 if lower.ends_with(".min.js") {
5109 "Minified asset".to_string()
5110 } else if [
5111 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
5112 ]
5113 .contains(&ext)
5114 {
5115 "Binary or archive".to_string()
5116 } else {
5117 "Skipped file".to_string()
5118 }
5119 }
5120 PreviewKind::Unsupported => {
5121 if ext.is_empty() {
5122 "Unsupported file".to_string()
5123 } else {
5124 format!("{} file", ext.to_ascii_uppercase())
5125 }
5126 }
5127 PreviewKind::Supported => "Supported source".to_string(),
5128 PreviewKind::Dir => "Directory".to_string(),
5129 }
5130}
5131
5132fn format_system_time(time: SystemTime) -> String {
5133 #[allow(clippy::cast_possible_wrap)]
5134 let secs = match time.duration_since(UNIX_EPOCH) {
5135 Ok(duration) => duration.as_secs() as i64,
5136 Err(_) => return "-".to_string(),
5137 };
5138 let days = secs.div_euclid(86_400);
5139 let secs_of_day = secs.rem_euclid(86_400);
5140 let (year, month, day) = civil_from_days(days);
5141 let hour = secs_of_day / 3_600;
5142 let minute = (secs_of_day % 3_600) / 60;
5143 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
5144}
5145
5146#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
5147fn civil_from_days(days: i64) -> (i32, u32, u32) {
5148 let z = days + 719_468;
5149 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
5150 let doe = z - era * 146_097;
5151 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
5152 let y = yoe + era * 400;
5153 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
5154 let mp = (5 * doy + 2) / 153;
5155 let d = doy - (153 * mp + 2) / 5 + 1;
5156 let m = mp + if mp < 10 { 3 } else { -9 };
5157 let year = y + i64::from(m <= 2);
5158 (year as i32, m as u32, d as u32)
5159}
5160
5161#[allow(clippy::case_sensitive_file_extension_comparisons)]
5164fn detect_language_name(name: &str) -> Option<&'static str> {
5165 let lower = name.to_ascii_lowercase();
5166 if lower.ends_with(".c") || lower.ends_with(".h") {
5167 Some("C")
5168 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
5169 .iter()
5170 .any(|s| lower.ends_with(s))
5171 {
5172 Some("C++")
5173 } else if lower.ends_with(".cs") {
5174 Some("C#")
5175 } else if lower.ends_with(".py") {
5176 Some("Python")
5177 } else if lower.ends_with(".sh") {
5178 Some("Shell")
5179 } else if [".ps1", ".psm1", ".psd1"]
5180 .iter()
5181 .any(|s| lower.ends_with(s))
5182 {
5183 Some("PowerShell")
5184 } else {
5185 None
5186 }
5187}
5188
5189fn language_icon_file(language: &str) -> Option<&'static str> {
5190 match language {
5191 "C" => Some("c.png"),
5192 "C++" => Some("cpp.png"),
5193 "C#" => Some("c-sharp.png"),
5194 "Python" => Some("python.png"),
5195 "Shell" => Some("shell.png"),
5196 "PowerShell" => Some("powershell.png"),
5197 "JavaScript" => Some("java-script.png"),
5198 "HTML" => Some("html-5.png"),
5199 "Java" => Some("java.png"),
5200 "Visual Basic" => Some("visual-basic.png"),
5201 "Assembly" => Some("asm.png"),
5202 "Go" => Some("go.png"),
5203 "R" => Some("r.png"),
5204 "XML" => Some("xml.png"),
5205 "Groovy" => Some("groovy.png"),
5206 "Dockerfile" => Some("docker.png"),
5207 "Makefile" => Some("makefile.svg"),
5208 "Perl" => Some("perl.svg"),
5209 _ => None,
5210 }
5211}
5212
5213fn language_inline_svg(language: &str) -> Option<&'static str> {
5218 match language {
5219 "Rust" => Some(
5220 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>"##,
5221 ),
5222 "TypeScript" => Some(
5223 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>"##,
5224 ),
5225 _ => None,
5226 }
5227}
5228
5229#[allow(clippy::case_sensitive_file_extension_comparisons)]
5232fn classify_preview_file(name: &str) -> PreviewKind {
5233 let lower = name.to_ascii_lowercase();
5234
5235 let scannable = [
5236 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
5237 ".psm1", ".psd1",
5238 ]
5239 .iter()
5240 .any(|suffix| lower.ends_with(suffix));
5241
5242 if scannable {
5243 PreviewKind::Supported
5244 } else if lower.ends_with(".min.js")
5245 || lower.ends_with(".lock")
5246 || lower.ends_with(".png")
5247 || lower.ends_with(".jpg")
5248 || lower.ends_with(".jpeg")
5249 || lower.ends_with(".gif")
5250 || lower.ends_with(".zip")
5251 || lower.ends_with(".pdf")
5252 || lower.ends_with(".pyc")
5253 || lower.ends_with(".xz")
5254 || lower.ends_with(".tar")
5255 || lower.ends_with(".gz")
5256 {
5257 PreviewKind::Skipped
5258 } else {
5259 PreviewKind::Unsupported
5260 }
5261}
5262
5263fn preview_relative_path(root: &Path, path: &Path) -> String {
5264 path.strip_prefix(root)
5265 .ok()
5266 .unwrap_or(path)
5267 .to_string_lossy()
5268 .replace('\\', "/")
5269 .trim_matches('/')
5270 .to_string()
5271}
5272
5273fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
5274 if relative.is_empty() {
5275 return false;
5276 }
5277
5278 exclude_patterns.iter().any(|pattern| {
5279 wildcard_match(pattern, relative)
5280 || wildcard_match(pattern, &format!("{relative}/"))
5281 || wildcard_match(pattern, &format!("{relative}/placeholder"))
5282 })
5283}
5284
5285fn should_include_preview_file(
5286 relative: &str,
5287 include_patterns: &[String],
5288 exclude_patterns: &[String],
5289) -> bool {
5290 if relative.is_empty() {
5291 return true;
5292 }
5293
5294 let included = include_patterns.is_empty()
5295 || include_patterns
5296 .iter()
5297 .any(|pattern| wildcard_match(pattern, relative));
5298 let excluded = exclude_patterns
5299 .iter()
5300 .any(|pattern| wildcard_match(pattern, relative));
5301
5302 included && !excluded
5303}
5304
5305fn wildcard_match(pattern: &str, candidate: &str) -> bool {
5306 let pattern = pattern.trim().replace('\\', "/");
5307 let candidate = candidate.trim().replace('\\', "/");
5308 let p = pattern.as_bytes();
5309 let c = candidate.as_bytes();
5310 let mut pi = 0usize;
5311 let mut ci = 0usize;
5312 let mut star: Option<usize> = None;
5313 let mut star_match = 0usize;
5314
5315 while ci < c.len() {
5316 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
5317 pi += 1;
5318 ci += 1;
5319 } else if pi < p.len() && p[pi] == b'*' {
5320 while pi < p.len() && p[pi] == b'*' {
5321 pi += 1;
5322 }
5323 star = Some(pi);
5324 star_match = ci;
5325 } else if let Some(star_pi) = star {
5326 star_match += 1;
5327 ci = star_match;
5328 pi = star_pi;
5329 } else {
5330 return false;
5331 }
5332 }
5333
5334 while pi < p.len() && p[pi] == b'*' {
5335 pi += 1;
5336 }
5337
5338 pi == p.len()
5339}
5340
5341fn escape_html(value: &str) -> String {
5342 value
5343 .replace('&', "&")
5344 .replace('<', "<")
5345 .replace('>', ">")
5346 .replace('"', """)
5347 .replace('\'', "'")
5348}
5349
5350#[derive(Clone)]
5351struct LanguageSummaryRow {
5352 language: String,
5353 files: u64,
5354 physical: u64,
5355 code: u64,
5356 comments: u64,
5357 blank: u64,
5358 mixed: u64,
5359 functions: u64,
5360 classes: u64,
5361 variables: u64,
5362 imports: u64,
5363}
5364
5365#[derive(Clone)]
5366struct SubmoduleRow {
5367 name: String,
5368 relative_path: String,
5369 files_analyzed: u64,
5370 code_lines: u64,
5371 comment_lines: u64,
5372 blank_lines: u64,
5373 total_physical_lines: u64,
5374 html_url: Option<String>,
5375}
5376
5377#[derive(Template)]
5378#[template(
5379 source = r##"
5380<!doctype html>
5381<html lang="en">
5382<head>
5383 <meta charset="utf-8">
5384 <title>OxideSLOC | tmp-sloc</title>
5385 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
5386 <style nonce="{{ csp_nonce }}">
5387 :root {
5388 --bg: #efe9e2;
5389 --surface: #fcfaf7;
5390 --surface-2: #f7f0e8;
5391 --surface-3: #efe3d5;
5392 --line: #dfcfbf;
5393 --line-strong: #cfb29c;
5394 --text: #2f241c;
5395 --muted: #6f6257;
5396 --muted-2: #917f71;
5397 --nav: #b85d33;
5398 --nav-2: #7a371b;
5399 --accent: #2563eb;
5400 --accent-2: #1d4ed8;
5401 --oxide: #b85d33;
5402 --oxide-2: #8f4220;
5403 --success-bg: #eaf9ee;
5404 --success-text: #1c8746;
5405 --warn-bg: #fff2d8;
5406 --warn-text: #926000;
5407 --danger-bg: #fdeaea;
5408 --danger-text: #b33b3b;
5409 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
5410 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
5411 --radius: 14px;
5412 }
5413
5414 body.dark-theme {
5415 --bg: #1b1511;
5416 --surface: #261c17;
5417 --surface-2: #2d221d;
5418 --surface-3: #372922;
5419 --line: #524238;
5420 --line-strong: #6c5649;
5421 --text: #f5ece6;
5422 --muted: #c7b7aa;
5423 --muted-2: #aa9485;
5424 --nav: #b85d33;
5425 --nav-2: #7a371b;
5426 --accent: #6f9bff;
5427 --accent-2: #4a78ee;
5428 --oxide: #d37a4c;
5429 --oxide-2: #b35428;
5430 --success-bg: #163927;
5431 --success-text: #8fe2a8;
5432 --warn-bg: #3c2d11;
5433 --warn-text: #f3cb75;
5434 --danger-bg: #3d1f1f;
5435 --danger-text: #ff9f9f;
5436 --shadow: 0 14px 28px rgba(0,0,0,0.28);
5437 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
5438 }
5439
5440 * { box-sizing: border-box; }
5441 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); }
5442 html { overflow-y: scroll; }
5443 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
5444 .top-nav, .page, .loading { position: relative; z-index: 2; }
5445 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
5446 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
5447 .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); }
5448 .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; }
5449 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
5450 .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)); }
5451 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
5452 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
5453 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
5454 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
5455 .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; }
5456 .nav-project-pill.visible { display:inline-flex; }
5457 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
5458 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
5459 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: wrap; }
5460 .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; }
5461 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
5462 .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; }
5463 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
5464 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
5465 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
5466 .theme-toggle .icon-sun { display:none; }
5467 body.dark-theme .theme-toggle .icon-sun { display:block; }
5468 body.dark-theme .theme-toggle .icon-moon { display:none; }
5469 .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; }
5470 .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;}
5471 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; flex: 1; width: 100%; }
5472 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
5473 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
5474 .workbench-box { border: 1px solid var(--line-strong); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
5475 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
5476 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; }
5477 .wb-stats-header { padding: 10px 24px 0; }
5478 .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
5479 .ws-left { display:flex; align-items:center; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
5480 .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); }
5481 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
5482 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
5483 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
5484 .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; }
5485 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
5486 .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; }
5487 .ws-badge:hover .ws-lang-tooltip { display:block; }
5488 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:9px; }
5489 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
5490 .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; }
5491 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
5492 .ws-divider { display: none; }
5493 .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%; }
5494 .ws-path-link:hover { color:var(--oxide); }
5495 body.dark-theme .ws-path-link { color:var(--oxide); }
5496 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
5497 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
5498 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
5499 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
5500 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
5501 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
5502 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
5503 .ws-mini-box-lg { flex:2 1 0; }
5504 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
5505 .ws-mini-box-br { flex:1.5 1 0; }
5506 .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); }
5507 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
5508 .path-scope-grid { display:grid; grid-template-columns: 1fr 1px auto; gap:0; align-items:stretch; }
5509 .path-scope-grid .input-group { width:100%; align-self:start; }
5510 .git-source-banner { display:flex; align-items:center; gap:10px; padding:10px 14px; background:linear-gradient(135deg,rgba(124,58,237,0.07),rgba(99,40,217,0.05)); border:1.5px solid rgba(124,58,237,0.22); border-radius:9px; margin-bottom:12px; font-size:13px; color:var(--text); flex-wrap:wrap; }
5511 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
5512 .git-source-banner strong { font-weight:800; color:var(--text); }
5513 .git-source-banner code { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:12px; background:rgba(124,58,237,0.10); border:1px solid rgba(124,58,237,0.22); border-radius:5px; padding:1px 7px; color:#5b21b6; }
5514 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
5515 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
5516 .git-source-banner a:hover { text-decoration:underline; }
5517 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
5518 .path-scope-sep { background:var(--line); margin:4px 14px; }
5519 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
5520 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
5521 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
5522 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
5523 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
5524 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
5525 .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; }
5526 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
5527 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
5528 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
5529 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
5530 .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; }
5531 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
5532 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
5533 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
5534 .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; }
5535 .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); }
5536 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
5537 .side-info-card { padding: 18px; }
5538 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
5539 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
5540 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
5541 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
5542 .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); }
5543 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
5544 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
5545 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
5546 .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; }
5547 .layout { display:grid; grid-template-columns: 218px minmax(0, 1fr); gap: 18px; align-items:stretch; min-height: calc(100vh - 57px); }
5548 .layout[data-active-step="4"] { align-items: start; min-height: auto; }
5549 .side-stack { display:grid; gap: 16px; align-items:start; align-self: stretch; width: 218px; max-width: 218px; }
5550 .step-nav { padding: 20px 16px; position: sticky; top: 57px; z-index: 25; }
5551 .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); }
5552 .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; }
5553 .step-button:hover { background: var(--surface-2); }
5554 .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); }
5555 .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; }
5556 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
5557 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
5558 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
5559 .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); }
5560 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
5561 .step-nav-sum-row:last-child { border-bottom:none; }
5562 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
5563 .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; }
5564 .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
5565 .quick-scan-section { padding: 10px 4px 14px; }
5566 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
5567 .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; }
5568 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
5569 .quick-scan-btn:active { transform:translateY(0); }
5570 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
5571 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
5572 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
5573 @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);} }
5574 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
5575 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
5576 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
5577 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
5578 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
5579 .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; }
5580 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
5581 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
5582 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
5583 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
5584 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
5585 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
5586 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
5587 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
5588 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
5589 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
5590 .card-body { padding: 22px; }
5591 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
5592 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
5593 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
5594 .section { margin-bottom: 22px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
5595 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
5596 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
5597 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
5598 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
5599 .field { min-width:0; }
5600 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
5601 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; }
5602 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); }
5603 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
5604 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); }
5605 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
5606 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
5607 .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; }
5608 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
5609 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
5610 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
5611 .input-group.compact { grid-template-columns: 1fr auto auto; }
5612 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
5613 .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)); }
5614 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
5615 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
5616 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
5617 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
5618 .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; }
5619 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
5620 .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; }
5621 .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); }
5622 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
5623 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
5624 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
5625 button.secondary { background: var(--surface); }
5626 .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); }
5627 .section + .wizard-actions { border-top: none; padding-top: 0; }
5628 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
5629 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
5630 .field-help-grid.coupled-help { margin-top: 12px; }
5631 .field-help-grid.preset-grid { align-items: start; }
5632 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:stretch; margin-bottom: 16px; }
5633 .preset-inline-row .field { margin: 0; }
5634 .preset-inline-row .explainer-card { margin: 0; }
5635 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
5636 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
5637 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
5638 .output-field-row .field { margin: 0; }
5639 .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; }
5640 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
5641 .step3-subtitle { margin-bottom: 28px; }
5642 .counting-intro { margin-bottom: 8px; max-width: none; }
5643 .ieee-note { margin-bottom: 22px; padding: 9px 14px; border-left: 3px solid var(--oxide); background: linear-gradient(180deg, rgba(184,93,51,0.07), transparent), var(--surface-2); border-radius: 0 8px 8px 0; font-size: 13px; color: var(--muted); line-height: 1.5; font-style: italic; }
5644 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
5645 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
5646 .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; }
5647 .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; }
5648 .section-spacer-top { margin-top: 28px; }
5649 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
5650 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
5651 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
5652 .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); }
5653 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
5654 .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; }
5655 .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; }
5656 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
5657 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
5658 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
5659 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
5660 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
5661 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
5662 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
5663 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; }
5664 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
5665 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
5666 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
5667 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
5668 .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); }
5669 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
5670 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
5671 .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; }
5672 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
5673 .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; }
5674 .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; }
5675 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
5676 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
5677 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
5678 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
5679 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
5680 .advanced-rule-description strong { color: var(--text); }
5681 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
5682 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
5683 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
5684 .review-link:hover { text-decoration: underline; }
5685 .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; }
5686 .artifact-card { position:relative; padding: 16px; cursor:pointer; }
5687 .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
5688 .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; }
5689 .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
5690 .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; }
5691 .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
5692 .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
5693 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
5694 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
5695 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
5696 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
5697 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
5698 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
5699 .review-card ul { padding-left: 18px; margin: 0; }
5700 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
5701 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
5702 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
5703 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
5704 .review-card { min-height: 200px; }
5705 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
5706 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
5707 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
5708 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
5709 .lang-overflow-chip { position:relative; cursor:default; }
5710 .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; }
5711 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
5712 .git-inline-row { align-items:start; }
5713 .mixed-line-card { display:flex; flex-direction:column; }
5714 .preset-inline-row .toggle-card { justify-content: center; }
5715 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
5716 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
5717 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
5718 .explorer-title { font-size: 18px; font-weight: 850; }
5719 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
5720 .explorer-subtitle.wide { max-width: none; }
5721 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
5722 .better-spacing { align-items:flex-start; justify-content:flex-end; }
5723 .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; }
5724 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
5725 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
5726 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
5727 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
5728 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
5729 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
5730 .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; }
5731 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
5732 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
5733 .scope-stat-button.supported { background: var(--success-bg); }
5734 .scope-stat-button.skipped { background: var(--warn-bg); }
5735 .scope-stat-button.unsupported { background: var(--danger-bg); }
5736 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
5737 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
5738 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
5739 [data-tooltip] { position: relative; }
5740 [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); }
5741 [data-tooltip]:hover::after { display: block; }
5742 .scope-stat-button[data-tooltip] { cursor: pointer; }
5743 .badge[data-tooltip] { cursor: help; }
5744 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
5745 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
5746 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
5747 .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; }
5748 .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; }
5749 code { display:inline-block; margin-top:0; padding:2px 7px; }
5750 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
5751 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
5752 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
5753 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
5754 .language-pill.muted-pill { color: var(--muted); }
5755 button.language-pill { appearance:none; cursor:pointer; }
5756 .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); }
5757 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
5758 .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; }
5759 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
5760 .file-explorer-search-row { margin-left: auto; }
5761 .explorer-filter-select { min-width: 170px; width: 170px; }
5762 .explorer-search { min-width: 300px; width: 300px; }
5763 .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); }
5764 .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; }
5765 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
5766 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
5767 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
5768 .file-explorer-tree { max-height: 560px; overflow:auto; }
5769 .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); }
5770 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
5771 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
5772 .tree-row.hidden-by-filter { display:none !important; }
5773 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 9px 0; }
5774 .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; }
5775 .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; }
5776 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
5777 .tree-bullet { color: var(--muted-2); width: 28px; text-align:center; flex: 0 0 28px; font-size: 14px; }
5778 .tree-node { display:inline-flex; align-items:center; min-width:0; }
5779 .tree-node-dir { color: var(--text); font-weight: 800; }
5780 .tree-node-supported { color: var(--success-text); }
5781 .tree-node-skipped { color: var(--warn-text); }
5782 .tree-node-unsupported { color: var(--danger-text); }
5783 .tree-node-more { color: var(--muted-2); font-style: italic; }
5784 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 13px; }
5785 .tree-status-cell { display:flex; justify-content:flex-start; }
5786 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
5787 .loading { position: fixed; inset: 0; display:none; align-items:center; justify-content:center; background: rgba(17,24,39,0.35); z-index: 100; backdrop-filter: blur(2px); }
5788 .loading.active { display:flex; }
5789 .loading-card { width: min(560px, calc(100vw - 40px)); border-radius: 18px; border: 1px solid var(--line); background: var(--surface); box-shadow: 0 20px 48px rgba(0,0,0,0.22); padding: 28px 32px; }
5790 .progress-bar { width:100%; height:6px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
5791 .progress-bar span { display:block; width:42%; height:100%; background: linear-gradient(90deg, var(--accent-2), var(--oxide,#d37a4c)); animation: pulseBar 1.6s ease-in-out infinite; }
5792 @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
5793 .lc-badge { display:inline-flex;align-items:center;gap:8px;background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.28);border-radius:999px;padding:5px 14px 5px 10px;font-size:12px;font-weight:700;color:var(--accent-2);margin-bottom:16px; }
5794 .lc-dot { width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:lcPulse 1.4s ease-in-out infinite;flex:0 0 auto; }
5795 @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);} }
5796 .lc-title { font-size:1.25rem;font-weight:800;margin:0 0 6px; }
5797 .lc-sub { color:var(--muted);font-size:0.88rem;margin:0 0 16px; }
5798 .lc-path { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:8px 14px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;color:var(--muted);word-break:break-all;margin-bottom:16px; }
5799 .lc-metrics { display:flex;gap:12px;margin-bottom:16px; }
5800 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:10px 16px;flex:0 0 auto; }
5801 .lc-metric-label { font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:3px; }
5802 .lc-metric-value { font-size:1.05rem;font-weight:700;color:var(--text); }
5803 .lc-warn { background:rgba(230,160,50,0.12);border:1px solid rgba(230,160,50,0.3);border-radius:8px;padding:10px 14px;font-size:12px;color:#8a6a10;margin-top:14px; }
5804 .lc-err { background:rgba(180,40,40,0.08);border:1px solid rgba(180,40,40,0.25);border-radius:8px;padding:12px 16px;margin-top:14px; }
5805 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
5806 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
5807 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
5808 .lc-outline-btn { display:inline-flex;align-items:center;padding:9px 20px;border-radius:999px;background:transparent;color:var(--nav,#b85d33);border:2px solid var(--nav,#b85d33);font-size:13px;font-weight:700;text-decoration:none;cursor:pointer; }
5809 .hidden { display:none !important; }
5810 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
5811 .site-footer a{color:var(--muted);}
5812 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
5813 @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; } }
5814 .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;}
5815 @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));}}
5816 .nav-dropdown{position:relative;display:inline-flex;}.nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}.nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}.nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}.nav-dropdown-menu a:last-child{border-bottom:none;}.nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}.nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
5817 </style>
5818</head>
5819<body>
5820 <div class="background-watermarks" aria-hidden="true">
5821 <img src="/images/logo/logo-text.png" alt="" />
5822 <img src="/images/logo/logo-text.png" alt="" />
5823 <img src="/images/logo/logo-text.png" alt="" />
5824 <img src="/images/logo/logo-text.png" alt="" />
5825 <img src="/images/logo/logo-text.png" alt="" />
5826 <img src="/images/logo/logo-text.png" alt="" />
5827 <img src="/images/logo/logo-text.png" alt="" />
5828 <img src="/images/logo/logo-text.png" alt="" />
5829 <img src="/images/logo/logo-text.png" alt="" />
5830 <img src="/images/logo/logo-text.png" alt="" />
5831 <img src="/images/logo/logo-text.png" alt="" />
5832 <img src="/images/logo/logo-text.png" alt="" />
5833 <img src="/images/logo/logo-text.png" alt="" />
5834 <img src="/images/logo/logo-text.png" alt="" />
5835 </div>
5836 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
5837 <div class="top-nav">
5838 <div class="top-nav-inner">
5839 <a class="brand" href="/">
5840 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
5841 <div class="brand-copy">
5842 <div class="brand-title">OxideSLOC</div>
5843 <div class="brand-subtitle">Local analysis workbench</div>
5844 </div>
5845 </a>
5846 <div class="nav-project-slot">
5847 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
5848 <span class="nav-project-label">Project</span>
5849 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
5850 </div>
5851 </div>
5852 <div class="nav-status">
5853 <a class="nav-pill" href="/">Home</a>
5854 <a class="nav-pill" href="/view-reports">View Reports</a>
5855 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
5856 <div class="nav-dropdown">
5857 <button class="nav-dropdown-btn" type="button">Git Tools <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
5858 <div class="nav-dropdown-menu">
5859 <a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
5860 <a href="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Webhooks</a>
5861 </div>
5862 </div>
5863 <div class="server-status-wrap">
5864 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
5865 <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>
5866 </div>
5867 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
5868 <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>
5869 <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>
5870 </button>
5871 </div>
5872 </div>
5873 </div>
5874
5875 <div class="loading" id="loading">
5876 <div class="loading-card">
5877 <div class="lc-badge"><span class="lc-dot"></span>Analysis running</div>
5878 <h2 class="lc-title">Analyzing your project…</h2>
5879 <p class="lc-sub">Results are saved automatically — you can leave this page.</p>
5880 <div class="lc-path" id="lc-path"></div>
5881 <div class="lc-metrics">
5882 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
5883 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
5884 </div>
5885 <div class="progress-bar"><span></span></div>
5886 <div class="lc-warn hidden" id="lc-warn">This is taking longer than usual. Large repositories can take several minutes — the analysis is still running.</div>
5887 <div class="lc-err hidden" id="lc-err"><strong>Analysis failed</strong><p id="lc-err-msg">An unexpected error occurred. Check that the path exists and is readable.</p></div>
5888 <div class="lc-actions hidden" id="lc-actions">
5889 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
5890 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
5891 </div>
5892 </div>
5893 </div>
5894
5895 <div class="page">
5896 <div class="workbench-strip">
5897 <div class="workbench-box wb-stats">
5898 <div class="wb-stats-header">
5899 <span class="wb-stats-title">Analysis session</span>
5900 </div>
5901 <div class="ws-left">
5902 <div class="ws-stat">
5903 <span class="ws-label">Analyzers</span>
5904 <span class="ws-value">
5905 <span class="ws-badge">41 languages
5906 <div class="ws-lang-tooltip">
5907 <div class="ws-lang-tooltip-hdr">41 supported languages</div>
5908 <div class="ws-lang-grid">
5909 <span class="ws-lang-item">Assembly</span>
5910 <span class="ws-lang-item">C</span>
5911 <span class="ws-lang-item">C++</span>
5912 <span class="ws-lang-item">C#</span>
5913 <span class="ws-lang-item">Clojure</span>
5914 <span class="ws-lang-item">CSS</span>
5915 <span class="ws-lang-item">Dart</span>
5916 <span class="ws-lang-item">Dockerfile</span>
5917 <span class="ws-lang-item">Elixir</span>
5918 <span class="ws-lang-item">Erlang</span>
5919 <span class="ws-lang-item">F#</span>
5920 <span class="ws-lang-item">Go</span>
5921 <span class="ws-lang-item">Groovy</span>
5922 <span class="ws-lang-item">Haskell</span>
5923 <span class="ws-lang-item">HTML</span>
5924 <span class="ws-lang-item">Java</span>
5925 <span class="ws-lang-item">JavaScript</span>
5926 <span class="ws-lang-item">Julia</span>
5927 <span class="ws-lang-item">Kotlin</span>
5928 <span class="ws-lang-item">Lua</span>
5929 <span class="ws-lang-item">Makefile</span>
5930 <span class="ws-lang-item">Nim</span>
5931 <span class="ws-lang-item">Obj-C</span>
5932 <span class="ws-lang-item">OCaml</span>
5933 <span class="ws-lang-item">Perl</span>
5934 <span class="ws-lang-item">PHP</span>
5935 <span class="ws-lang-item">PowerShell</span>
5936 <span class="ws-lang-item">Python</span>
5937 <span class="ws-lang-item">R</span>
5938 <span class="ws-lang-item">Ruby</span>
5939 <span class="ws-lang-item">Rust</span>
5940 <span class="ws-lang-item">Scala</span>
5941 <span class="ws-lang-item">SCSS</span>
5942 <span class="ws-lang-item">Shell</span>
5943 <span class="ws-lang-item">SQL</span>
5944 <span class="ws-lang-item">Svelte</span>
5945 <span class="ws-lang-item">Swift</span>
5946 <span class="ws-lang-item">TypeScript</span>
5947 <span class="ws-lang-item">Vue</span>
5948 <span class="ws-lang-item">XML</span>
5949 <span class="ws-lang-item">Zig</span>
5950 </div>
5951 </div>
5952 </span>
5953 </span>
5954 </div>
5955 <div class="ws-divider"></div>
5956 <div class="ws-stat"><span class="ws-label">Mode</span><span class="ws-value">Localhost workbench</span></div>
5957 <div class="ws-divider"></div>
5958 <div class="ws-stat ws-stat-clamp"><span class="ws-label">Active project</span><span class="ws-value" id="live-report-title">—</span></div>
5959 <div class="ws-divider"></div>
5960 <div class="ws-stat ws-stat-output">
5961 <span class="ws-label">Output</span>
5962 <span class="ws-value">
5963 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
5964 <span id="ws-output-root">project/sloc</span>
5965 </button>
5966 </span>
5967 </div>
5968 </div>
5969 </div>
5970 <div class="workbench-box ws-history-group">
5971 <div class="ws-history-label">Scan history</div>
5972 <div class="ws-history-inner">
5973 <div class="ws-mini-box ws-mini-box-sm">
5974 <div class="ws-mini-label">Scans</div>
5975 <div class="ws-mini-value" id="ws-scan-count">—</div>
5976 </div>
5977 <div class="ws-mini-box ws-mini-box-lg">
5978 <div class="ws-mini-label">Last Scan</div>
5979 <div class="ws-mini-value" id="ws-last-scan">—</div>
5980 </div>
5981 <div class="ws-mini-box ws-mini-box-br">
5982 <div class="ws-mini-label">Branch</div>
5983 <div class="ws-mini-value" id="ws-branch">—</div>
5984 </div>
5985 </div>
5986 </div>
5987 </div>
5988
5989 <div class="layout">
5990 <aside class="side-stack">
5991 <section class="step-nav">
5992 <h3>Guided scan setup</h3>
5993 <button type="button" class="step-button active" data-step-target="1"><span class="step-num">1</span><span>Select project</span></button>
5994 <button type="button" class="step-button" data-step-target="2"><span class="step-num">2</span><span>Counting rules</span></button>
5995 <button type="button" class="step-button" data-step-target="3"><span class="step-num">3</span><span>Outputs and reports</span></button>
5996 <button type="button" class="step-button" data-step-target="4"><span class="step-num">4</span><span>Review and run</span></button>
5997
5998 <div class="step-nav-info" id="step-nav-info">
5999 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
6000 <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>
6001 </div>
6002
6003 <div class="quick-scan-divider"></div>
6004 <div class="quick-scan-section">
6005 <div class="quick-scan-label">No customization needed?</div>
6006 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
6007 <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>
6008 Quick Scan
6009 </button>
6010 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
6011 </div>
6012 </section>
6013
6014 </aside>
6015
6016 <section class="card">
6017 <div class="card-header">
6018 <div class="card-title-row">
6019 <div>
6020 <h1 class="card-title">Guided scan configuration</h1>
6021 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
6022 </div>
6023 <div class="wizard-progress" aria-label="Scan setup progress">
6024 <div class="wizard-progress-top">
6025 <span class="wizard-progress-label">Setup progress</span>
6026 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
6027 </div>
6028 <div class="wizard-progress-track">
6029 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
6030 </div>
6031 </div>
6032 </div>
6033 </div>
6034 <div class="card-body">
6035 <form method="post" action="/analyze" id="analyze-form">
6036 <div class="wizard-step active" data-step="1">
6037 <div class="section">
6038 <div class="section-kicker">Step 1</div>
6039 <h2>Select project and preview scope</h2>
6040 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
6041 <div class="field">
6042 <label for="path">Project path</label>
6043 {% if !git_repo.is_empty() %}
6044 <div class="git-source-banner">
6045 <svg viewBox="0 0 24 24"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/><circle cx="6" cy="6" r="3"/></svg>
6046 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
6047 <a href="/git-browser">← Back to Git Browser</a>
6048 </div>
6049 {% endif %}
6050 <div class="path-scope-grid">
6051 <div class="input-group">
6052 {% if !git_repo.is_empty() %}
6053 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required />
6054 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
6055 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
6056 {% else %}
6057 <input id="path" name="path" type="text" value="tmp-sloc" placeholder="/path/to/repository" required />
6058 <button type="button" class="mini-button oxide" id="browse-path">Browse</button>
6059 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
6060 {% endif %}
6061 </div>
6062 <div class="path-scope-sep"></div>
6063 <div class="scope-legend-row">
6064 <span class="scope-legend-label">Scope legend:</span>
6065 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
6066 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
6067 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
6068 </div>
6069 </div>
6070 {% if git_repo.is_empty() %}
6071 <div class="hint">Browse opens the native folder picker through the Rust backend, so you do not need to type local paths manually.</div>
6072 {% else %}
6073 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
6074 {% endif %}
6075 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
6076 </div>
6077
6078 <div style="height:1px;background:var(--line);margin:28px 0;"></div>
6079
6080 <div id="preview-panel" style="margin-top:0;">
6081 <div class="preview-error">Loading preview...</div>
6082 </div>
6083 </div>
6084
6085 <div class="section">
6086 <div class="field-grid">
6087 <div class="field">
6088 <label for="include_globs">Include globs</label>
6089 <textarea id="include_globs" name="include_globs" placeholder="examples: src/**/*.py scripts/*.sh"></textarea>
6090 <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>
6091 </div>
6092 <div class="field">
6093 <label for="exclude_globs">Exclude globs</label>
6094 <textarea id="exclude_globs" name="exclude_globs" placeholder="examples: vendor/** **/*.min.js"></textarea>
6095 <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>
6096 </div>
6097 </div>
6098 <div class="glob-guidance-grid">
6099 <div class="glob-guidance-card">
6100 <strong>How to read them</strong>
6101 <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>
6102 </div>
6103 <div class="glob-guidance-card">
6104 <strong>Common include examples</strong>
6105 <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
6106 </div>
6107 <div class="glob-guidance-card">
6108 <strong>Common exclude examples</strong>
6109 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
6110 </div>
6111 </div>
6112 </div>
6113
6114 <div class="section" style="margin-top:14px;">
6115 <div class="preset-inline-row git-inline-row">
6116 <div class="toggle-card" style="margin:0;">
6117 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
6118 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
6119 <label class="checkbox">
6120 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
6121 <div>
6122 <span>Detect and separate git submodules</span>
6123 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
6124 </div>
6125 </label>
6126 </div>
6127 <div class="explainer-card prominent" style="margin:0;">
6128 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
6129 <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>
6130 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
6131 path = libs/core
6132 url = https://github.com/org/core.git
6133
6134[submodule "libs/ui"]
6135 path = libs/ui
6136 url = https://github.com/org/ui.git</div>
6137 </div>
6138 </div>
6139 </div>
6140
6141 <div class="wizard-actions">
6142 <div class="left"></div>
6143 <div class="right">
6144 <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
6145 </div>
6146 </div>
6147 </div>
6148
6149 <div class="wizard-step" data-step="2">
6150 <div class="section">
6151 <div class="section-kicker">Step 2</div>
6152 <h2>Choose counting behavior</h2>
6153 <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.</p>
6154 <div class="ieee-note">Counting methodology follows IEEE Std 1045-1992 physical SLOC.</div>
6155 <div class="subsection-bar">Primary line classification</div>
6156 <div class="preset-inline-row" style="align-items:start;">
6157 <div class="toggle-card mixed-line-card" style="margin:0;">
6158 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
6159 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
6160 <select id="mixed_line_policy" name="mixed_line_policy">
6161 <option value="code_only">Code only</option>
6162 <option value="code_and_comment">Code and comment</option>
6163 <option value="comment_only">Comment only</option>
6164 <option value="separate_mixed_category">Separate mixed category</option>
6165 </select>
6166 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
6167 </div>
6168 <div class="explainer-card prominent" style="margin:0;">
6169 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
6170 <div class="explainer-body" id="mixed-policy-description"></div>
6171 <div class="code-sample" id="mixed-policy-example"></div>
6172 </div>
6173 </div>
6174 </div>
6175
6176 <div class="subsection-bar">Additional scan rules</div>
6177 <div class="scan-rules-grid">
6178 <div class="preset-inline-row">
6179 <div class="toggle-card" style="margin:0;">
6180 <div class="field-help-title">Generated files</div>
6181 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
6182 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
6183 </div>
6184 <div class="explainer-card prominent" style="margin:0;">
6185 <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>
6186 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
6187# Files matching codegen patterns are excluded:
6188# *.generated.cs *.pb.go *.g.dart</div>
6189 </div>
6190 </div>
6191 <div class="preset-inline-row">
6192 <div class="toggle-card" style="margin:0;">
6193 <div class="field-help-title">Minified files</div>
6194 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
6195 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
6196 </div>
6197 <div class="explainer-card prominent" style="margin:0;">
6198 <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>
6199 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
6200# Heuristic: very long lines + low whitespace ratio
6201# jquery.min.js bundle.min.css → skipped</div>
6202 </div>
6203 </div>
6204 <div class="preset-inline-row">
6205 <div class="toggle-card" style="margin:0;">
6206 <div class="field-help-title">Vendor directories</div>
6207 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
6208 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
6209 </div>
6210 <div class="explainer-card prominent" style="margin:0;">
6211 <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>
6212 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
6213# Directories named vendor/ node_modules/ third_party/
6214# → entire subtree is excluded from totals</div>
6215 </div>
6216 </div>
6217 <div class="preset-inline-row">
6218 <div class="toggle-card" style="margin:0;">
6219 <div class="field-help-title">Lockfiles and manifests</div>
6220 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
6221 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
6222 </div>
6223 <div class="explainer-card prominent" style="margin:0;">
6224 <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>
6225 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
6226# Files like package-lock.json Cargo.lock yarn.lock
6227# → skipped unless this is enabled</div>
6228 </div>
6229 </div>
6230 <div class="preset-inline-row">
6231 <div class="toggle-card" style="margin:0;">
6232 <div class="field-help-title">Binary handling</div>
6233 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
6234 <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>
6235 </div>
6236 <div class="explainer-card prominent" style="margin:0;">
6237 <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>
6238 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
6239# Detected via long lines + low whitespace heuristic
6240# .png .exe .so → skipped silently</div>
6241 </div>
6242 </div>
6243 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
6244 <div class="toggle-card" style="margin:0;">
6245 <div class="field-help-title">Python docstrings</div>
6246 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
6247 <label class="checkbox">
6248 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
6249 <span>Count as comment-style lines</span>
6250 </label>
6251 </div>
6252 <div class="explainer-card prominent" style="margin:0;">
6253 <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>
6254 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
6255 </div>
6256 </div>
6257 </div>
6258 <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px;">
6259 <div class="always-tracked-tip">
6260 <div class="always-tracked-tip-icon">ℹ</div>
6261 <div class="always-tracked-tip-body">
6262 <div class="field-help-title">Always tracked — not configurable</div>
6263 <h4>Comment and blank-line basics</h4>
6264 <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>
6265 </div>
6266 </div>
6267 <div class="always-tracked-tip">
6268 <div class="always-tracked-tip-icon">→</div>
6269 <div class="always-tracked-tip-body">
6270 <div class="field-help-title">What these settings change</div>
6271 <h4>Lines on the boundary</h4>
6272 <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>
6273 </div>
6274 </div>
6275 </div>
6276
6277 <div class="wizard-actions">
6278 <div class="left">
6279 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
6280 </div>
6281 <div class="right">
6282 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
6283 </div>
6284 </div>
6285 </div>
6286
6287 <div class="wizard-step" data-step="3">
6288 <div class="section">
6289 <div class="section-kicker">Step 3</div>
6290 <h2>Output and report identity</h2>
6291 <p class="card-subtitle step3-subtitle" style="white-space:nowrap;">Choose where generated files should be saved, what the exported report title should be, and which artifact bundle fits your workflow.</p>
6292 <div class="preset-inline-row" style="align-items:start;">
6293 <div class="toggle-card" style="margin:0;">
6294 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
6295 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
6296 <select id="scan_preset">
6297 <option value="balanced">Balanced local scan</option>
6298 <option value="code_focused">Code focused</option>
6299 <option value="comment_audit">Comment audit</option>
6300 <option value="deep_review">Deep review</option>
6301 </select>
6302 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
6303 </div>
6304 <div class="explainer-card">
6305 <div class="field-help-title">Selected scan preset</div>
6306 <div class="explainer-body" id="scan-preset-description"></div>
6307 <div class="preset-summary-row" id="scan-preset-summary"></div>
6308 <div class="code-sample" id="scan-preset-example"></div>
6309 <div class="preset-note" id="scan-preset-note"></div>
6310 </div>
6311 </div>
6312 <hr class="step3-separator" />
6313 <div class="preset-inline-row" style="align-items:start;">
6314 <div class="toggle-card" style="margin:0;">
6315 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
6316 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
6317 <select id="artifact_preset">
6318 <option value="review">Review bundle</option>
6319 <option value="full">Full bundle</option>
6320 <option value="html_only">HTML only</option>
6321 <option value="machine">Machine bundle</option>
6322 </select>
6323 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
6324 </div>
6325 <div class="explainer-card">
6326 <div class="field-help-title">Selected artifact preset</div>
6327 <div class="explainer-body" id="artifact-preset-description"></div>
6328 <div class="preset-summary-row" id="artifact-preset-summary"></div>
6329 <div class="code-sample" id="artifact-preset-example"></div>
6330 </div>
6331 </div>
6332 </div>
6333
6334 <div class="section section-spacer-top">
6335 <div class="output-field-row">
6336 <div class="field">
6337 <label for="output_dir">Output directory</label>
6338 <div class="input-group compact">
6339 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
6340 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
6341 <button type="button" class="mini-button" id="use-default-output">Use default</button>
6342 </div>
6343 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
6344 </div>
6345 <div class="output-field-aside">
6346 <strong>Where reports land</strong>
6347 Each run creates a timestamped subfolder here containing the selected artifacts. If the path does not exist it will be created automatically. This path is separate from the project being scanned and does not affect what files are analyzed.
6348 </div>
6349 </div>
6350 </div>
6351
6352 <div class="section section-spacer-top">
6353 <div class="output-field-row">
6354 <div class="field">
6355 <label for="report_title">Report title</label>
6356 <input id="report_title" name="report_title" type="text" value="tmp-sloc" placeholder="Project report title" />
6357 <div class="hint">Appears in HTML and PDF output headers.</div>
6358 </div>
6359 <div class="output-field-aside">
6360 <strong>Shown in exported artifacts</strong>
6361 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.
6362 </div>
6363 </div>
6364 </div>
6365
6366 <div class="section">
6367 <div class="section-kicker">Artifacts</div>
6368 <div class="artifact-grid">
6369 <div class="artifact-card selected" data-artifact="html">
6370 <div class="marker">✓</div>
6371 <div class="artifact-icon">H</div>
6372 <h4>HTML report</h4>
6373 <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
6374 <div class="artifact-tags">
6375 <span class="soft-chip">Best for visual review</span>
6376 <span class="soft-chip">Embeddable preview</span>
6377 </div>
6378 <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
6379 </div>
6380 <div class="artifact-card selected" data-artifact="pdf">
6381 <div class="marker">✓</div>
6382 <div class="artifact-icon">P</div>
6383 <h4>PDF export</h4>
6384 <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
6385 <div class="artifact-tags">
6386 <span class="soft-chip">Portable snapshot</span>
6387 <span class="soft-chip">Good for handoff</span>
6388 </div>
6389 <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
6390 </div>
6391 <div class="artifact-card selected" data-artifact="json" style="opacity:0.75;pointer-events:none;">
6392 <div class="marker" style="background:var(--oxide);border-color:var(--oxide);color:#fff;">✓</div>
6393 <div class="artifact-icon">J</div>
6394 <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--oxide-2);">Always on</span></h4>
6395 <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
6396 <div class="artifact-tags">
6397 <span class="soft-chip">Required for compare</span>
6398 <span class="soft-chip">Auto-enabled</span>
6399 </div>
6400 <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
6401 </div>
6402 </div>
6403 <div class="hint" style="margin-top:16px;">Artifact cards are selectable. Presets above can also toggle them for common workflows.</div>
6404 </div>
6405
6406 <div class="wizard-actions">
6407 <div class="left">
6408 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
6409 </div>
6410 <div class="right">
6411 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
6412 </div>
6413 </div>
6414 </div>
6415
6416 <div class="wizard-step" data-step="4">
6417 <div class="section">
6418 <div class="section-kicker">Step 4</div>
6419 <h2>Review selections and run</h2>
6420 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
6421 <div class="review-grid">
6422 <div class="review-card highlight">
6423 <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>
6424 <ul id="review-scan-summary"></ul>
6425 </div>
6426 <div class="review-card highlight">
6427 <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>
6428 <ul id="review-count-summary"></ul>
6429 </div>
6430 <div class="review-card">
6431 <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>
6432 <ul id="review-artifact-summary"></ul>
6433 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
6434 </div>
6435 <div class="review-card">
6436 <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>
6437 <ul id="review-preview-summary"></ul>
6438 </div>
6439 </div>
6440 </div>
6441
6442 <div class="wizard-actions">
6443 <div class="left">
6444 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
6445 </div>
6446 <div class="right">
6447 <button type="submit" id="submit-button" class="primary">Run analysis</button>
6448 </div>
6449 </div>
6450 </div></form>
6451 </div>
6452 </section>
6453 </div>
6454 </div>
6455
6456 <script nonce="{{ csp_nonce }}">
6457 (function () {
6458 function startScanPhase() {
6459 var phaseEl = document.getElementById("scan-phase");
6460 if (!phaseEl) return;
6461 var phases = [
6462 "Discovering files...",
6463 "Decoding file encodings...",
6464 "Detecting languages...",
6465 "Analyzing source lines...",
6466 "Applying counting policies...",
6467 "Aggregating results...",
6468 "Rendering report..."
6469 ];
6470 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
6471 var i = 0;
6472 function next() {
6473 phaseEl.style.opacity = "0";
6474 setTimeout(function () {
6475 phaseEl.textContent = phases[i];
6476 phaseEl.style.opacity = "0.85";
6477 var delay = durations[i] || 1800;
6478 i++;
6479 if (i < phases.length) { setTimeout(next, delay); }
6480 }, 200);
6481 }
6482 next();
6483 }
6484
6485 var form = document.getElementById("analyze-form");
6486 var loading = document.getElementById("loading");
6487 var submitButton = document.getElementById("submit-button");
6488 var pathInput = document.getElementById("path");
6489 var GIT_MODE = !!(pathInput && pathInput.readOnly);
6490 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
6491 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
6492 var outputDirInput = document.getElementById("output_dir");
6493 var reportTitleInput = document.getElementById("report_title");
6494 var previewPanel = document.getElementById("preview-panel");
6495 var refreshButton = document.getElementById("refresh-preview");
6496 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
6497 var useSamplePath = document.getElementById("use-sample-path");
6498 var useDefaultOutput = document.getElementById("use-default-output");
6499 var browsePath = document.getElementById("browse-path");
6500 var browseOutputDir = document.getElementById("browse-output-dir");
6501 var themeToggle = document.getElementById("theme-toggle");
6502 var mixedLinePolicy = document.getElementById("mixed_line_policy");
6503 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
6504 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
6505 var scanPreset = document.getElementById("scan_preset");
6506 var artifactPreset = document.getElementById("artifact_preset");
6507 var includeGlobsInput = document.getElementById("include_globs");
6508 var excludeGlobsInput = document.getElementById("exclude_globs");
6509 var liveReportTitle = document.getElementById("live-report-title");
6510 var navProjectPill = document.getElementById("nav-project-pill");
6511 var navProjectTitle = document.getElementById("nav-project-title");
6512 var reportTitlePreview = null;
6513 var wizardProgressFill = document.getElementById("wizard-progress-fill");
6514 var wizardProgressValue = document.getElementById("wizard-progress-value");
6515 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
6516 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
6517 var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
6518 var reportTitleTouched = false;
6519 var currentStep = 1;
6520 var previewTimer = null;
6521 var quickScanBtn = document.getElementById("quick-scan-btn");
6522
6523 function dismissAnalysisModal() {
6524 if (loading) loading.classList.remove("active");
6525 ["lc-err","lc-warn","lc-actions"].forEach(function(id) {
6526 var el = document.getElementById(id);
6527 if (el) el.classList.add("hidden");
6528 });
6529 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
6530 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
6531 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
6532 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
6533 }
6534
6535 var lcDismissBtn = document.getElementById("lc-dismiss");
6536 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
6537
6538 function startAsyncAnalysis(formData) {
6539 var gitRepo = (formData.get("git_repo") || "").toString();
6540 var gitRef = (formData.get("git_ref") || "").toString();
6541 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
6542 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
6543
6544 var pathEl = document.getElementById("lc-path");
6545 if (pathEl) pathEl.textContent = displayPath;
6546
6547 ["lc-err","lc-warn","lc-actions"].forEach(function(id) {
6548 var el = document.getElementById(id);
6549 if (el) el.classList.add("hidden");
6550 });
6551 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
6552 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
6553
6554 if (loading) loading.classList.add("active");
6555
6556 var startTime = Date.now();
6557 var elapsedTimer = setInterval(function() {
6558 var s = Math.floor((Date.now() - startTime) / 1000);
6559 var el = document.getElementById("lc-elapsed");
6560 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
6561 }, 1000);
6562
6563 var warnShown = false, pollRetries = 0;
6564
6565 function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
6566
6567 function lcShowError(msg) {
6568 clearInterval(elapsedTimer);
6569 lcSetPhase("Failed");
6570 var msgEl = document.getElementById("lc-err-msg");
6571 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
6572 var errEl = document.getElementById("lc-err");
6573 var actEl = document.getElementById("lc-actions");
6574 if (errEl) errEl.classList.remove("hidden");
6575 if (actEl) actEl.classList.remove("hidden");
6576 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
6577 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
6578 }
6579
6580 function lcPoll(waitId) {
6581 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
6582 .then(function(r) {
6583 if (!r.ok) throw new Error("HTTP " + r.status);
6584 return r.json();
6585 })
6586 .then(function(data) {
6587 pollRetries = 0;
6588 if (data.state === "complete") {
6589 clearInterval(elapsedTimer);
6590 lcSetPhase("Done");
6591 window.location.href = "/runs/" + encodeURIComponent(data.run_id) + "/result";
6592 } else if (data.state === "failed") {
6593 lcShowError(data.message);
6594 } else {
6595 var s = Math.floor((Date.now() - startTime) / 1000);
6596 if (s > 90 && !warnShown) {
6597 warnShown = true;
6598 var w = document.getElementById("lc-warn");
6599 if (w) w.classList.remove("hidden");
6600 }
6601 lcSetPhase(s < 10 ? "Starting" : s < 30 ? "Scanning files" : "Analyzing");
6602 setTimeout(function() { lcPoll(waitId); }, 1500);
6603 }
6604 })
6605 .catch(function() {
6606 pollRetries++;
6607 if (pollRetries >= 5) {
6608 lcShowError("Lost connection to server. Reload to check status.");
6609 } else {
6610 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
6611 }
6612 });
6613 }
6614
6615 var params = new URLSearchParams(formData);
6616 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
6617 .then(function(r) {
6618 var waitId = r.headers.get("x-wait-id");
6619 if (!waitId) { window.location.href = "/scan"; return; }
6620 setTimeout(function() { lcPoll(waitId); }, 1500);
6621 })
6622 .catch(function(err) {
6623 lcShowError("Could not reach server: " + (err.message || err));
6624 });
6625 }
6626
6627 if (quickScanBtn) {
6628 quickScanBtn.addEventListener("click", function () {
6629 var pathVal = pathInput ? pathInput.value.trim() : "";
6630 if (!pathVal) {
6631 alert("Please enter or browse to a project path first.");
6632 return;
6633 }
6634 quickScanBtn.disabled = true;
6635 quickScanBtn.textContent = "Scanning...";
6636 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
6637 startAsyncAnalysis(new FormData(form));
6638 });
6639 }
6640
6641 var mixedPolicyInfo = {
6642 code_only: {
6643 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.",
6644 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'
6645 },
6646 code_and_comment: {
6647 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.",
6648 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'
6649 },
6650 comment_only: {
6651 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.",
6652 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'
6653 },
6654 separate_mixed_category: {
6655 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.",
6656 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'
6657 }
6658 };
6659
6660 var scanPresetInfo = {
6661 balanced: {
6662 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.",
6663 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
6664 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
6665 note: "Best when you want a stable local overview before making deeper adjustments.",
6666 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
6667 },
6668 code_focused: {
6669 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
6670 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
6671 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
6672 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
6673 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
6674 },
6675 comment_audit: {
6676 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
6677 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
6678 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
6679 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
6680 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
6681 },
6682 deep_review: {
6683 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
6684 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
6685 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
6686 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
6687 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
6688 }
6689 };
6690
6691 var artifactPresetInfo = {
6692 review: {
6693 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.",
6694 chips: ["HTML", "PDF"],
6695 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
6696 },
6697 full: {
6698 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.",
6699 chips: ["HTML", "PDF", "JSON"],
6700 example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
6701 },
6702 html_only: {
6703 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.",
6704 chips: ["HTML only", "Fast local review"],
6705 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
6706 },
6707 machine: {
6708 description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
6709 chips: ["HTML", "JSON"],
6710 example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
6711 }
6712 };
6713
6714 function applyTheme(theme) {
6715 if (theme === "dark") document.body.classList.add("dark-theme");
6716 else document.body.classList.remove("dark-theme");
6717 }
6718
6719 function loadSavedTheme() {
6720 var saved = null;
6721 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
6722 applyTheme(saved === "dark" ? "dark" : "light");
6723 }
6724
6725 function updateScrollProgress() {
6726 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
6727 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
6728 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
6729 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
6730 var step = Math.min(Math.max(currentStep, 1), 4);
6731 var base = stepBase[step];
6732 var end = stepEnd[step];
6733
6734 var scrollFrac = 0;
6735 var activePanel = document.querySelector(".wizard-step.active");
6736 if (activePanel) {
6737 var scrollTop = window.scrollY || window.pageYOffset || 0;
6738 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
6739 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
6740 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
6741 var scrolled = scrollTop + viewH - panelTop;
6742 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
6743 }
6744
6745 var percent = Math.round(base + (end - base) * scrollFrac);
6746 percent = Math.min(end, Math.max(base, percent));
6747 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
6748 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
6749 }
6750
6751 function updateWizardProgress() {
6752 updateScrollProgress();
6753 }
6754
6755 var stepDescriptions = [
6756 "Choose a project folder, apply scope filters, and preview which files will be counted.",
6757 "Configure how mixed code-plus-comment lines and docstrings are classified.",
6758 "Pick your output formats, scan preset, and where reports are saved.",
6759 "Review all settings and launch the analysis."
6760 ];
6761
6762 function updateStepNav(step) {
6763 var infoLabel = document.getElementById("step-nav-info-label");
6764 var infoDesc = document.getElementById("step-nav-info-desc");
6765 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
6766 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
6767
6768 }
6769
6770 function setStep(step, pushHistory) {
6771 currentStep = step;
6772 stepPanels.forEach(function (panel) {
6773 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
6774 });
6775 stepButtons.forEach(function (button) {
6776 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
6777 });
6778 var layoutEl = document.querySelector(".layout");
6779 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
6780 updateWizardProgress();
6781 updateStepNav(step);
6782
6783 if (pushHistory !== false) {
6784 try {
6785 history.pushState({ wizardStep: step }, "", "#step" + step);
6786 } catch (e) {}
6787 }
6788
6789 window.scrollTo({ top: 0, behavior: "instant" });
6790 }
6791
6792 window.addEventListener("popstate", function (e) {
6793 if (e.state && e.state.wizardStep) {
6794 setStep(e.state.wizardStep, false);
6795 } else {
6796 var hashMatch = location.hash.match(/^#step([1-4])$/);
6797 if (hashMatch) setStep(Number(hashMatch[1]), false);
6798 }
6799 });
6800
6801 function inferTitleFromPath(value) {
6802 if (!value) return "project";
6803 var cleaned = value.replace(/[\/\\]+$/, "");
6804 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
6805 return parts.length ? parts[parts.length - 1] : value;
6806 }
6807
6808 function updateReportTitleFromPath() {
6809 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "tmp-sloc");
6810 if (!reportTitleTouched) {
6811 reportTitleInput.value = inferred;
6812 }
6813 var title = reportTitleInput.value || inferred;
6814 if (liveReportTitle) liveReportTitle.textContent = title;
6815 if (reportTitlePreview) reportTitlePreview.textContent = title;
6816 document.title = "OxideSLOC | " + title;
6817
6818 var projectPath = (pathInput.value || "").trim();
6819 if (navProjectPill && navProjectTitle) {
6820 if (projectPath.length > 0) {
6821 navProjectTitle.textContent = inferred;
6822 navProjectPill.classList.add("visible");
6823 } else {
6824 navProjectTitle.textContent = "";
6825 navProjectPill.classList.remove("visible");
6826 }
6827 }
6828 }
6829
6830 function updateMixedPolicyUI() {
6831 var key = mixedLinePolicy.value || "code_only";
6832 var info = mixedPolicyInfo[key];
6833 document.getElementById("mixed-policy-description").textContent = info.description;
6834 document.getElementById("mixed-policy-example").textContent = info.example;
6835 }
6836
6837 function updatePythonDocstringUI() {
6838 var checked = !!pythonDocstrings.checked;
6839 document.getElementById("python-docstring-example").textContent = checked
6840 ? 'def greet():\n """Greet the user.""" ← comment\n print("hi")'
6841 : 'def greet():\n """Greet the user.""" ← not counted\n print("hi")';
6842 document.getElementById("python-docstring-live-help").textContent = checked
6843 ? "Enabled: docstrings contribute to comment-style totals."
6844 : "Disabled: docstrings are not counted as comment content.";
6845 }
6846
6847 function renderPresetChips(targetId, chips) {
6848 var target = document.getElementById(targetId);
6849 if (!target) return;
6850 target.innerHTML = (chips || []).map(function (chip) {
6851 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
6852 }).join('');
6853 }
6854
6855 function updatePresetDescriptions() {
6856 var scanInfo = scanPresetInfo[scanPreset.value];
6857 var artifactInfo = artifactPresetInfo[artifactPreset.value];
6858 document.getElementById("scan-preset-description").textContent = scanInfo.description;
6859 document.getElementById("scan-preset-example").textContent = scanInfo.example;
6860 document.getElementById("scan-preset-note").textContent = scanInfo.note;
6861 document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
6862 document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
6863 renderPresetChips("scan-preset-summary", scanInfo.chips);
6864 renderPresetChips("artifact-preset-summary", artifactInfo.chips);
6865 }
6866
6867 function applyScanPreset() {
6868 var info = scanPresetInfo[scanPreset.value];
6869 if (!info || !info.apply) return;
6870 mixedLinePolicy.value = info.apply.mixed;
6871 pythonDocstrings.checked = !!info.apply.docstrings;
6872 document.getElementById("generated_file_detection").value = info.apply.generated;
6873 document.getElementById("minified_file_detection").value = info.apply.minified;
6874 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
6875 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
6876 document.getElementById("binary_file_behavior").value = info.apply.binary;
6877 updateMixedPolicyUI();
6878 updatePythonDocstringUI();
6879 }
6880
6881 function applyArtifactPreset() {
6882 var enabled = { html: false, pdf: false, json: false };
6883 if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
6884 if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; enabled.json = true; }
6885 if (artifactPreset.value === "html_only") { enabled.html = true; }
6886 if (artifactPreset.value === "machine") { enabled.json = true; enabled.html = true; }
6887
6888 artifactCards.forEach(function (card) {
6889 var artifact = card.getAttribute("data-artifact");
6890 var checked = !!enabled[artifact];
6891 var checkbox = card.querySelector(".artifact-checkbox");
6892 checkbox.checked = checked;
6893 card.classList.toggle("selected", checked);
6894 });
6895 }
6896
6897 function toggleArtifactCard(card) {
6898 var checkbox = card.querySelector(".artifact-checkbox");
6899 checkbox.checked = !checkbox.checked;
6900 card.classList.toggle("selected", checkbox.checked);
6901 }
6902
6903 function updateReview() {
6904 var scanSummary = document.getElementById("review-scan-summary");
6905 var countSummary = document.getElementById("review-count-summary");
6906 var artifactSummary = document.getElementById("review-artifact-summary");
6907 var outputSummary = document.getElementById("review-output-summary");
6908 var previewSummary = document.getElementById("review-preview-summary");
6909 var readinessSummary = document.getElementById("review-readiness-summary");
6910 var includeText = document.getElementById("include_globs").value.trim();
6911 var excludeText = document.getElementById("exclude_globs").value.trim();
6912 var sidePathPreview = document.getElementById("side-path-preview");
6913 var sideOutputPreview = document.getElementById("side-output-preview");
6914 var sideTitlePreview = document.getElementById("side-title-preview");
6915
6916 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "tmp-sloc"; }
6917 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
6918 if (sideTitlePreview) {
6919 var rt = document.getElementById("report_title");
6920 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
6921 }
6922
6923 scanSummary.innerHTML = ""
6924 + "<li>Path: " + escapeHtml(pathInput.value || "tmp-sloc") + "</li>"
6925 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
6926 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
6927
6928 countSummary.innerHTML = ""
6929 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
6930 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
6931 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
6932 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
6933 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
6934 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
6935 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
6936 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
6937
6938 var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.querySelector("h4").textContent; });
6939 artifactSummary.innerHTML = ""
6940 + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
6941 + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
6942
6943 outputSummary.innerHTML = ""
6944 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
6945 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value || "tmp-sloc")) + "</li>";
6946
6947 if (previewSummary) {
6948 if (GIT_MODE) {
6949 previewSummary.innerHTML = '<li style="color:var(--muted-text,#888);font-style:italic;">Scope preview is not pre-computed in git-browser mode — the repository will be cloned and fully analyzed during the scan run.</li>';
6950 } else {
6951 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
6952 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
6953 var statMap = {};
6954 statButtons.forEach(function (button) {
6955 var valueNode = button.querySelector('.scope-stat-value');
6956 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
6957 });
6958 previewSummary.innerHTML = ''
6959 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
6960 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
6961 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
6962 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
6963 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
6964 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
6965
6966 if (readinessSummary) {
6967 var selectedArtifactsCount = selectedArtifacts.length;
6968 readinessSummary.innerHTML = ''
6969 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
6970 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
6971 + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
6972 + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
6973 }
6974 } // end else (non-GIT_MODE)
6975 }
6976 }
6977
6978 function escapeHtml(value) {
6979 return String(value)
6980 .replace(/&/g, "&")
6981 .replace(/</g, "<")
6982 .replace(/>/g, ">")
6983 .replace(/"/g, """)
6984 .replace(/'/g, "'");
6985 }
6986
6987 function isPythonVisible() {
6988 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
6989 }
6990
6991 function syncPythonVisibility() {
6992 var html = previewPanel.textContent || "";
6993 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
6994 pythonWraps.forEach(function (node) {
6995 node.classList.toggle("hidden", !hasPython);
6996 });
6997 }
6998
6999 function attachPreviewInteractions() {
7000 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
7001 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
7002 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
7003 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
7004 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
7005 var searchInput = previewPanel.querySelector("#explorer-search");
7006 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
7007 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
7008 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
7009 var activeFilter = "all";
7010 var activeLanguage = "";
7011 var searchTerm = "";
7012 var currentSortKey = null;
7013 var currentSortOrder = "asc";
7014 var childRows = {};
7015
7016 rows.forEach(function (row) {
7017 var parentId = row.getAttribute("data-parent-id") || "";
7018 var rowId = row.getAttribute("data-row-id") || "";
7019 if (!childRows[parentId]) childRows[parentId] = [];
7020 childRows[parentId].push(rowId);
7021 });
7022
7023 function rowById(id) {
7024 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
7025 }
7026
7027 function hasCollapsedAncestor(row) {
7028 var parentId = row.getAttribute("data-parent-id");
7029 while (parentId) {
7030 var parent = rowById(parentId);
7031 if (!parent) break;
7032 if (parent.getAttribute("data-expanded") === "false") return true;
7033 parentId = parent.getAttribute("data-parent-id");
7034 }
7035 return false;
7036 }
7037
7038 function updateToggleGlyph(row) {
7039 var toggle = row.querySelector(".tree-toggle");
7040 if (!toggle) return;
7041 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
7042 }
7043
7044 function rowSortValue(row, key) {
7045 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
7046 }
7047
7048 function updateSortButtons() {
7049 sortButtons.forEach(function (button) {
7050 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
7051 var indicator = button.querySelector(".tree-sort-indicator");
7052 button.classList.toggle("active", isActive);
7053 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
7054 if (indicator) {
7055 indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
7056 }
7057 });
7058 }
7059
7060 function sortSiblingRows() {
7061 if (!treeContainer) {
7062 updateSortButtons();
7063 return;
7064 }
7065
7066 var rowMap = {};
7067 var childrenMap = {};
7068 rows.forEach(function (row) {
7069 var rowId = row.getAttribute("data-row-id");
7070 var parentId = row.getAttribute("data-parent-id") || "";
7071 rowMap[rowId] = row;
7072 if (!childrenMap[parentId]) childrenMap[parentId] = [];
7073 childrenMap[parentId].push(rowId);
7074 });
7075
7076 Object.keys(childrenMap).forEach(function (parentId) {
7077 if (!parentId) return;
7078 childrenMap[parentId].sort(function (a, b) {
7079 var rowA = rowMap[a];
7080 var rowB = rowMap[b];
7081 if (!currentSortKey) {
7082 return Number(a) - Number(b);
7083 }
7084 var valueA = rowSortValue(rowA, currentSortKey);
7085 var valueB = rowSortValue(rowB, currentSortKey);
7086 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
7087 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
7088 var fallbackA = rowSortValue(rowA, "name");
7089 var fallbackB = rowSortValue(rowB, "name");
7090 if (fallbackA < fallbackB) return -1;
7091 if (fallbackA > fallbackB) return 1;
7092 return Number(a) - Number(b);
7093 });
7094 });
7095
7096 var orderedIds = [];
7097 function pushChildren(parentId) {
7098 (childrenMap[parentId] || []).forEach(function (childId) {
7099 orderedIds.push(childId);
7100 pushChildren(childId);
7101 });
7102 }
7103
7104 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
7105 orderedIds.push(topId);
7106 pushChildren(topId);
7107 });
7108
7109 orderedIds.forEach(function (id) {
7110 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
7111 });
7112 updateSortButtons();
7113 }
7114
7115 function updateLanguageButtons() {
7116 languageButtons.forEach(function (button) {
7117 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
7118 var isActive = languageValue === activeLanguage;
7119 button.classList.toggle("active", isActive);
7120 });
7121 }
7122
7123 function rowSelfMatches(row) {
7124 var kind = row.getAttribute("data-kind");
7125 var status = row.getAttribute("data-status");
7126 var language = (row.getAttribute("data-language") || "").toLowerCase();
7127 var name = row.getAttribute("data-name-lower") || "";
7128 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
7129 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
7130 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
7131 var passesLanguage = !activeLanguage || language === activeLanguage;
7132 return passesFilter && passesSearch && passesLanguage;
7133 }
7134
7135 function hasMatchingDescendant(rowId) {
7136 return (childRows[rowId] || []).some(function (childId) {
7137 var childRow = rowById(childId);
7138 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
7139 });
7140 }
7141
7142 function rowMatches(row) {
7143 if (rowSelfMatches(row)) return true;
7144 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
7145 }
7146
7147 function resetViewState() {
7148 activeFilter = "all";
7149 activeLanguage = "";
7150 searchTerm = "";
7151 currentSortKey = null;
7152 currentSortOrder = "asc";
7153 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
7154 if (searchInput) searchInput.value = "";
7155 if (filterSelect) filterSelect.value = "all";
7156 updateLanguageButtons();
7157 }
7158
7159 function applyVisibility() {
7160 rows.forEach(function (row) {
7161 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
7162 row.classList.toggle("hidden-by-filter", !visible);
7163 row.style.display = visible ? "grid" : "none";
7164 });
7165 buttons.forEach(function (button) {
7166 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
7167 });
7168 if (filterSelect) filterSelect.value = activeFilter;
7169 }
7170
7171 buttons.forEach(function (button) {
7172 button.addEventListener("click", function () {
7173 var filterValue = button.getAttribute("data-filter") || "all";
7174 if (filterValue === "reset-view") {
7175 resetViewState();
7176 sortSiblingRows();
7177 applyVisibility();
7178 return;
7179 }
7180 activeFilter = filterValue;
7181 applyVisibility();
7182 });
7183 });
7184
7185 rows.forEach(function (row) {
7186 updateToggleGlyph(row);
7187 var toggle = row.querySelector(".tree-toggle");
7188 if (toggle) {
7189 toggle.addEventListener("click", function () {
7190 var expanded = row.getAttribute("data-expanded") !== "false";
7191 row.setAttribute("data-expanded", expanded ? "false" : "true");
7192 updateToggleGlyph(row);
7193 applyVisibility();
7194 });
7195 }
7196 });
7197
7198 actionButtons.forEach(function (button) {
7199 button.addEventListener("click", function () {
7200 var action = button.getAttribute("data-explorer-action");
7201 if (action === "expand-all") {
7202 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
7203 } else if (action === "collapse-all") {
7204 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
7205 } else if (action === "clear-filters") {
7206 resetViewState();
7207 }
7208 sortSiblingRows();
7209 applyVisibility();
7210 });
7211 });
7212
7213 if (filterSelect) {
7214 filterSelect.addEventListener("change", function () {
7215 activeFilter = filterSelect.value || "all";
7216 applyVisibility();
7217 });
7218 }
7219
7220 languageButtons.forEach(function (button) {
7221 button.addEventListener("click", function () {
7222 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
7223 updateLanguageButtons();
7224 applyVisibility();
7225 });
7226 });
7227
7228 sortButtons.forEach(function (button) {
7229 button.addEventListener("click", function () {
7230 var sortKey = button.getAttribute("data-sort-key");
7231 if (currentSortKey === sortKey) {
7232 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
7233 } else {
7234 currentSortKey = sortKey;
7235 currentSortOrder = "asc";
7236 }
7237 sortSiblingRows();
7238 applyVisibility();
7239 });
7240 });
7241
7242 if (searchInput) {
7243 searchInput.addEventListener("input", function () {
7244 searchTerm = searchInput.value.trim().toLowerCase();
7245 applyVisibility();
7246 });
7247 }
7248
7249 updateLanguageButtons();
7250 sortSiblingRows();
7251 applyVisibility();
7252 }
7253
7254 function loadPreview() {
7255 if (!previewPanel || !pathInput) return;
7256 if (GIT_MODE) {
7257 previewPanel.innerHTML = '<div class="preview-error" style="color:var(--muted);font-style:italic;">Preview is not available for remote git refs. The scan will check out the source at runtime.</div>';
7258 return;
7259 }
7260 var path = pathInput.value || "tmp-sloc";
7261 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
7262 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
7263 previewPanel.innerHTML = '<div class="preview-error">Refreshing preview...</div>';
7264 var previewUrl = "/preview?path=" + encodeURIComponent(path)
7265 + "&include_globs=" + encodeURIComponent(includeValue)
7266 + "&exclude_globs=" + encodeURIComponent(excludeValue);
7267 fetch(previewUrl)
7268 .then(function (response) { return response.text(); })
7269 .then(function (html) {
7270 previewPanel.innerHTML = html;
7271 attachPreviewInteractions();
7272 syncPythonVisibility();
7273 updateReview();
7274 setTimeout(collapseLanguagePills, 50);
7275 })
7276 .catch(function (err) {
7277 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
7278 });
7279 }
7280
7281 function pickDirectory(targetInput, kind) {
7282 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
7283 if (browseButton) browseButton.disabled = true;
7284
7285 if (previewPanel && targetInput === pathInput) {
7286 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
7287 }
7288
7289 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
7290 .then(function (response) { return response.json(); })
7291 .then(function (data) {
7292 if (data && data.selected_path) {
7293 targetInput.value = data.selected_path;
7294
7295 if (targetInput === pathInput) {
7296 updateReportTitleFromPath();
7297 autoSetOutputDir(data.selected_path);
7298 fetchProjectHistory(data.selected_path);
7299 loadPreview();
7300 }
7301
7302 updateReview();
7303 } else if (targetInput === pathInput) {
7304 // Cancelled — keep existing value and refresh preview with current path
7305 loadPreview();
7306 }
7307 })
7308 .catch(function () {
7309 window.alert("Directory picker request failed.");
7310 if (previewPanel && targetInput === pathInput) {
7311 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
7312 }
7313 })
7314 .finally(function () {
7315 if (browseButton) browseButton.disabled = false;
7316 });
7317 }
7318
7319 if (themeToggle) {
7320 themeToggle.addEventListener("click", function () {
7321 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
7322 applyTheme(nextTheme);
7323 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
7324 });
7325 }
7326
7327 stepButtons.forEach(function (button) {
7328 button.addEventListener("click", function () {
7329 setStep(Number(button.getAttribute("data-step-target")));
7330 });
7331 });
7332
7333 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
7334 button.addEventListener("click", function () {
7335 setStep(Number(button.getAttribute("data-step-target")) || 1);
7336 });
7337 });
7338
7339 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
7340 button.addEventListener("click", function () {
7341 updateReview();
7342 setStep(Number(button.getAttribute("data-next")));
7343 });
7344 });
7345
7346 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
7347 button.addEventListener("click", function () {
7348 setStep(Number(button.getAttribute("data-prev")));
7349 });
7350 });
7351
7352 if (useSamplePath) {
7353 useSamplePath.addEventListener("click", function () {
7354 pathInput.value = "tmp-sloc";
7355 updateReportTitleFromPath();
7356 loadPreview();
7357 });
7358 }
7359
7360 if (useDefaultOutput) {
7361 useDefaultOutput.addEventListener("click", function () {
7362 delete outputDirInput.dataset.userEdited;
7363 autoSetOutputDir(pathInput ? pathInput.value : "");
7364 updateReview();
7365 });
7366 }
7367
7368 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
7369 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
7370
7371 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
7372
7373 // ── Language pill overflow: collapse to "+N more" chip ─────────────
7374 function collapseLanguagePills() {
7375 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
7376 rows.forEach(function(row) {
7377 // Remove any previous overflow chip
7378 var prev = row.querySelector('.lang-overflow-chip');
7379 if (prev) prev.remove();
7380 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
7381 pills.forEach(function(p) { p.style.display = ''; });
7382 if (!pills.length) return;
7383
7384 // Measure after restoring all pills
7385 var containerRight = row.getBoundingClientRect().right;
7386 var hidden = [];
7387 for (var i = pills.length - 1; i >= 1; i--) {
7388 var rect = pills[i].getBoundingClientRect();
7389 if (rect.right > containerRight + 2) {
7390 hidden.unshift(pills[i]);
7391 pills[i].style.display = 'none';
7392 } else {
7393 break;
7394 }
7395 }
7396
7397 if (hidden.length) {
7398 var chip = document.createElement('button');
7399 chip.type = 'button';
7400 chip.className = 'language-pill lang-overflow-chip';
7401 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
7402 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
7403 row.appendChild(chip);
7404 }
7405 });
7406 }
7407
7408 // Run after preview loads (preview panel populates language pills)
7409 var _origLoadPreviewCb = window.__previewLoaded;
7410 document.addEventListener('previewLoaded', collapseLanguagePills);
7411 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
7412 setTimeout(collapseLanguagePills, 400);
7413
7414 // ── Project history & output dir auto-set ──────────────────────────
7415 var wsOutputRoot = document.getElementById("ws-output-root");
7416 var wsScanCount = document.getElementById("ws-scan-count");
7417 var wsLastScan = document.getElementById("ws-last-scan");
7418 var historyBadge = document.getElementById("path-history-badge");
7419 var historyTimer = null;
7420
7421 var wsOutputLink = document.getElementById("ws-output-link");
7422 function syncStripOutputRoot() {
7423 var val = outputDirInput ? outputDirInput.value : "";
7424 var display = val || "project/sloc";
7425 if (wsOutputRoot) wsOutputRoot.textContent = display;
7426 if (wsOutputLink) wsOutputLink.dataset.folder = val;
7427 }
7428
7429 function autoSetOutputDir(projectPath) {
7430 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
7431 if (GIT_MODE && GIT_OUTPUT_DIR) {
7432 outputDirInput.value = GIT_OUTPUT_DIR;
7433 syncStripOutputRoot();
7434 updateReview();
7435 return;
7436 }
7437 if (!projectPath || !projectPath.trim()) return;
7438 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
7439 outputDirInput.value = cleaned + "/sloc";
7440 syncStripOutputRoot();
7441 updateReview();
7442 }
7443
7444 var wsBranch = document.getElementById("ws-branch");
7445
7446 function fetchProjectHistory(projectPath) {
7447 if (!projectPath || !projectPath.trim()) {
7448 if (wsScanCount) wsScanCount.textContent = "—";
7449 if (wsLastScan) wsLastScan.textContent = "—";
7450 if (wsBranch) wsBranch.textContent = "—";
7451 if (historyBadge) historyBadge.style.display = "none";
7452 return;
7453 }
7454 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
7455 .then(function (r) { return r.ok ? r.json() : null; })
7456 .then(function (data) {
7457 if (!data) return;
7458 var countStr = data.scan_count > 0
7459 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
7460 : "never";
7461 var tsStr = data.last_scan_timestamp
7462 ? data.last_scan_timestamp.replace(" UTC","")
7463 : "—";
7464 if (wsScanCount) wsScanCount.textContent = countStr;
7465 if (wsLastScan) wsLastScan.textContent = tsStr;
7466 if (wsBranch) wsBranch.textContent = data.last_git_branch || "—";
7467 if (data.scan_count > 0) {
7468 if (historyBadge) {
7469 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
7470 historyBadge.textContent = data.scan_count + " previous scan" +
7471 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
7472 "Last: " + (data.last_scan_timestamp || "—") +
7473 " — " + (data.last_scan_code_lines ? Number(data.last_scan_code_lines).toLocaleString() : "?") + " code lines.";
7474 historyBadge.className = "path-history-badge found";
7475 historyBadge.style.display = "";
7476 }
7477 } else {
7478 if (historyBadge) historyBadge.style.display = "none";
7479 }
7480 })
7481 .catch(function () {});
7482 }
7483
7484 function onPathChange() {
7485 var val = pathInput ? pathInput.value : "";
7486 updateReportTitleFromPath();
7487 autoSetOutputDir(val);
7488 clearTimeout(historyTimer);
7489 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
7490 if (previewTimer) clearTimeout(previewTimer);
7491 previewTimer = setTimeout(loadPreview, 280);
7492 }
7493
7494 if (pathInput) {
7495 pathInput.addEventListener("input", onPathChange);
7496 }
7497
7498 if (outputDirInput) {
7499 outputDirInput.addEventListener("input", function () {
7500 outputDirInput.dataset.userEdited = "1";
7501 syncStripOutputRoot();
7502 updateReview();
7503 });
7504 }
7505
7506 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
7507 if (!node) return;
7508 node.addEventListener("input", function () {
7509 updateReview();
7510 if (previewTimer) clearTimeout(previewTimer);
7511 previewTimer = setTimeout(loadPreview, 280);
7512 });
7513 });
7514
7515 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
7516 var node = document.getElementById(id);
7517 if (node) node.addEventListener("change", updateReview);
7518 });
7519
7520 if (reportTitleInput) {
7521 reportTitleInput.addEventListener("input", function () {
7522 reportTitleTouched = reportTitleInput.value.trim().length > 0;
7523 updateReportTitleFromPath();
7524 updateReview();
7525 });
7526 }
7527
7528 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
7529 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
7530 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); });
7531 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); });
7532
7533 artifactCards.forEach(function (card) {
7534 card.addEventListener("click", function () {
7535 toggleArtifactCard(card);
7536 updateReview();
7537 });
7538 });
7539
7540 if (form && loading && submitButton) {
7541 form.addEventListener("submit", function (e) {
7542 e.preventDefault();
7543 submitButton.disabled = true;
7544 submitButton.textContent = "Scanning...";
7545 startAsyncAnalysis(new FormData(form));
7546 });
7547 }
7548
7549 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
7550 btn.addEventListener('click', function () {
7551 var folder = btn.getAttribute('data-folder') || btn.dataset.folder || '';
7552 if (!folder) return;
7553 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
7554 });
7555 });
7556
7557 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
7558 if (wsOutputLink) {
7559 wsOutputLink.addEventListener('click', function () {
7560 var folder = wsOutputLink.dataset.folder || '';
7561 if (!folder) return;
7562 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
7563 });
7564 }
7565
7566 loadSavedTheme();
7567 updateMixedPolicyUI();
7568 updatePythonDocstringUI();
7569 applyScanPreset();
7570 updatePresetDescriptions();
7571 applyArtifactPreset();
7572 updateReview();
7573 updateScrollProgress(); // initialise bar to 0% (step 1)
7574 window.addEventListener("scroll", updateScrollProgress, { passive: true });
7575 onPathChange(); // seed output dir, history badge, and preview from initial path
7576 loadPreview();
7577 updateStepNav(1);
7578
7579 // Restore step from URL hash on initial load (e.g., back-forward cache)
7580 (function() {
7581 var hashMatch = location.hash.match(/^#step([1-4])$/);
7582 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
7583 })();
7584
7585 (function randomizeWatermarks() {
7586 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
7587 if (!wms.length) return;
7588 var placed = [];
7589 function tooClose(top, left) {
7590 for (var i = 0; i < placed.length; i++) {
7591 var dt = Math.abs(placed[i][0] - top);
7592 var dl = Math.abs(placed[i][1] - left);
7593 if (dt < 16 && dl < 12) return true;
7594 }
7595 return false;
7596 }
7597 function pick(leftBand) {
7598 for (var attempt = 0; attempt < 50; attempt++) {
7599 var top = Math.random() * 88 + 2;
7600 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
7601 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
7602 }
7603 var top = Math.random() * 88 + 2;
7604 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
7605 placed.push([top, left]);
7606 return [top, left];
7607 }
7608 var half = Math.floor(wms.length / 2);
7609 wms.forEach(function (img, i) {
7610 var pos = pick(i < half);
7611 var size = Math.floor(Math.random() * 80 + 110);
7612 var rot = (Math.random() * 360).toFixed(1);
7613 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
7614 img.style.width=size+"px";img.style.top=pos[0].toFixed(1)+"%";img.style.left=pos[1].toFixed(1)+"%";img.style.transform="rotate("+rot+"deg)";img.style.opacity=op;
7615 });
7616 })();
7617
7618 (function spawnCodeParticles() {
7619 var container = document.getElementById('code-particles');
7620 if (!container) return;
7621 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'];
7622 for (var i = 0; i < 38; i++) {
7623 (function(idx) {
7624 var el = document.createElement('span');
7625 el.className = 'code-particle';
7626 el.textContent = snippets[idx % snippets.length];
7627 var left = Math.random() * 94 + 2;
7628 var top = Math.random() * 88 + 6;
7629 var dur = (Math.random() * 10 + 9).toFixed(1);
7630 var delay = (Math.random() * 18).toFixed(1);
7631 var rot = (Math.random() * 26 - 13).toFixed(1);
7632 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7633 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
7634 container.appendChild(el);
7635 })(i);
7636 }
7637 })();
7638 })();
7639 </script>
7640 <script nonce="{{ csp_nonce }}">
7641 (function () {
7642 var raw = {{ prefill_json|safe }};
7643 if (!raw || typeof raw !== 'object' || !raw.path) return;
7644 function setVal(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
7645 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
7646 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
7647 setVal('path-input', raw.path || '');
7648 setVal('include-globs', raw.include_globs || '');
7649 setVal('exclude-globs', raw.exclude_globs || '');
7650 setVal('output-dir', raw.output_dir || '');
7651 setVal('report-title', raw.report_title || '');
7652 if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
7653 setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
7654 setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
7655 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
7656 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
7657 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
7658 if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
7659 setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
7660 setChecked('generate-html', raw.generate_html !== false);
7661 setChecked('generate-pdf', !!raw.generate_pdf);
7662 // Trigger dynamic UI updates after pre-fill.
7663 setTimeout(function () {
7664 var pathEl = document.getElementById('path-input');
7665 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
7666 var policyEl = document.getElementById('mixed-line-policy');
7667 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
7668 }, 80);
7669 })();
7670 </script>
7671 <footer class="site-footer">
7672 oxide-sloc v{{ version }} — local source line analysis workbench ·
7673 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7674 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7675 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7676 </footer>
7677</body>
7678</html>
7679"##,
7680 ext = "html"
7681)]
7682struct IndexTemplate {
7683 version: &'static str,
7684 prefill_json: String,
7685 csp_nonce: String,
7686 git_repo: String,
7687 git_ref: String,
7688 git_label_json: String,
7689 git_output_dir_json: String,
7690}
7691
7692#[derive(Template)]
7695#[template(
7696 source = r##"
7697<!doctype html>
7698<html lang="en">
7699<head>
7700 <meta charset="utf-8">
7701 <meta name="viewport" content="width=device-width, initial-scale=1">
7702 <title>OxideSLOC — Source Line Analysis Workbench</title>
7703 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7704 <style nonce="{{ csp_nonce }}">
7705 :root {
7706 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
7707 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7708 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
7709 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7710 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
7711 }
7712 body.dark-theme {
7713 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
7714 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
7715 }
7716 *{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);}
7717 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7718 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
7719 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7720 .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;}
7721 @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));}}
7722 .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);}
7723 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
7724 .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));}
7725 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
7726 .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;}
7727 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
7728 .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;}
7729 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
7730 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
7731 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
7732 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
7733 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
7734 .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;}
7735 .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;}
7736 .page{max-width:1100px;margin:0 auto;padding:48px 24px 16px;position:relative;z-index:1;}
7737 .hero{text-align:center;margin-bottom:52px;}
7738 .hero-logo{width:88px;height:97px;object-fit:contain;margin-bottom:6px;filter:drop-shadow(0 8px 22px rgba(184,93,51,0.30));animation:logoBob 3.6s ease-in-out infinite;}
7739 @keyframes logoBob{0%,100%{transform:translateY(0) scale(1);}40%{transform:translateY(-18px) scale(1.07);}60%{transform:translateY(-14px) scale(1.05);}}
7740 .hero-title{font-size:51px;font-weight:900;letter-spacing:-0.04em;margin:0 0 10px;
7741 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
7742 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
7743 animation:titleShimmer 4s linear infinite;}
7744 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
7745 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;}
7746 .hero-subtitle{font-size:18px;color:var(--muted);line-height:1.6;max-width:600px;margin:0 auto;animation:fadeSlideUp 0.9s ease both;}
7747 @keyframes fadeSlideUp{from{opacity:0;transform:translateY(18px);}to{opacity:1;transform:translateY(0);}}
7748 .action-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-bottom:32px;}
7749 @media(max-width:900px){.action-grid{grid-template-columns:1fr 1fr;}}
7750 @media(max-width:480px){.action-grid{grid-template-columns:1fr;}}
7751 .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;}
7752 .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;} .action-card:nth-child(4){animation-delay:0.4s;}
7753 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
7754 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
7755 .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);}
7756 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
7757 .action-card-icon svg{width:26px;height:26px;stroke:currentColor;fill:none;stroke-width:2;}
7758 .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);}
7759 .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);}
7760 .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);}
7761 .action-card-title{font-size:20px;font-weight:850;letter-spacing:-0.02em;margin:0 0 8px;}
7762 .action-card-desc{font-size:14px;color:var(--muted);line-height:1.6;margin:0 0 20px;flex:1;}
7763 .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;}
7764 body.dark-theme .action-card-cta{color:var(--oxide);}
7765 .action-card.view .action-card-cta{color:var(--accent-2);}
7766 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
7767 .action-card.compare .action-card-cta{color:#7c3aed;}
7768 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
7769 .action-card.git-tools .action-card-icon{background:linear-gradient(135deg,#16a34a,#15803d);color:#fff;box-shadow:0 8px 22px rgba(22,163,74,0.28);}
7770 .action-card.git-tools .action-card-cta{color:#15803d;}
7771 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
7772 .action-card:hover .action-card-cta{gap:12px;}
7773 .divider{height:1px;background:var(--line);margin:40px 0;}
7774 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:16px;}
7775 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
7776 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
7777 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:18px 20px;text-align:center;position:relative;cursor:default;
7778 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
7779 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
7780 .info-chip-val{font-size:22px;font-weight:900;color:var(--oxide);}
7781 body.dark-theme .info-chip-val{color:var(--oxide);}
7782 .info-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
7783 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
7784 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
7785 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
7786 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
7787 border:6px solid transparent;border-top-color:var(--text);}
7788 .info-chip:hover .info-chip-tip{display:block;}
7789 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
7790 .site-footer a{color:var(--muted);}
7791 .lan-card{border-radius:var(--radius);border:1.5px solid var(--line-strong);background:var(--surface);box-shadow:var(--shadow);padding:24px 28px;margin-bottom:32px;animation:cardRise 0.7s ease both;}
7792 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
7793 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
7794 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
7795 .lan-badge{display:inline-flex;align-items:center;gap:6px;background:#3b82f6;color:#fff;border-radius:999px;padding:3px 10px;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;}
7796 .lan-badge.local{background:var(--oxide-2);}
7797 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
7798 .lan-url{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:16px;font-weight:700;color:#2563eb;background:rgba(59,130,246,0.08);border-radius:8px;padding:6px 12px;border:1px solid rgba(59,130,246,0.20);}
7799 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
7800 .lan-copy-btn{display:inline-flex;align-items:center;gap:5px;padding:5px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background 0.15s,border-color 0.15s;}
7801 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
7802 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
7803 .lan-auth-row{display:flex;align-items:flex-start;gap:10px;background:rgba(0,0,0,0.03);border-radius:8px;padding:10px 14px;font-size:12px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow-x:auto;}
7804 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
7805 .lan-local-hint{display:table;margin:20px auto 0;text-align:center;padding:7px 20px;border:1px solid rgba(0,0,0,0.08);border-radius:20px;background:rgba(0,0,0,0.03);font-size:11px;color:var(--muted);line-height:1.7;max-width:720px;opacity:0.7;}
7806 .lan-local-hint code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:rgba(0,0,0,0.05);border-radius:4px;padding:1px 5px;font-size:10.5px;color:var(--muted);}
7807 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
7808 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
7809 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
7810 .nav-dropdown{position:relative;display:inline-flex;}.nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}.nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}.nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}.nav-dropdown-menu a:last-child{border-bottom:none;}.nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}.nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
7811 </style>
7812</head>
7813<body>
7814 <div class="background-watermarks" aria-hidden="true">
7815 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7816 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7817 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7818 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7819 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7820 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7821 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7822 </div>
7823 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7824 <div class="top-nav">
7825 <div class="top-nav-inner">
7826 <a class="brand" href="/">
7827 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7828 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Source line analysis workbench</div></div>
7829 </a>
7830 <div class="nav-right">
7831 <a class="nav-pill" href="/">Home</a>
7832 <a class="nav-pill" href="/view-reports">View Reports</a>
7833 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7834 <div class="nav-dropdown">
7835 <button class="nav-dropdown-btn" type="button">Git Tools <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
7836 <div class="nav-dropdown-menu">
7837 <a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
7838 <a href="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Webhooks</a>
7839 </div>
7840 </div>
7841 <div class="server-status-wrap">
7842 {% if server_mode %}
7843 <div class="nav-pill server-online-pill"><span class="status-dot"></span>LAN server</div>
7844 <div class="server-status-tip">OxideSLOC is running in server mode — accessible on your LAN.<br>Use Ctrl+C in the terminal to stop.</div>
7845 {% else %}
7846 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Running locally</div>
7847 <div class="server-status-tip">OxideSLOC is running locally — only accessible from this machine.<br>Press Ctrl+C in the terminal to stop.</div>
7848 {% endif %}
7849 </div>
7850 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7851 <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>
7852 <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>
7853 </button>
7854 </div>
7855 </div>
7856 </div>
7857
7858 <div class="page">
7859 <div class="hero">
7860 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
7861 <h1 class="hero-title">OxideSLOC</h1>
7862 <p class="hero-subtitle">A fast, self-contained source line analysis workbench. Count code, track history, and compare scan snapshots — no setup required.</p>
7863 </div>
7864
7865 <div class="action-grid">
7866 <a class="action-card scan" href="/scan-setup">
7867 <div class="action-card-icon">
7868 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
7869 </div>
7870 <div class="action-card-title">Scan Project</div>
7871 <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>
7872 <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>
7873 </a>
7874
7875 <a class="action-card view" href="/view-reports">
7876 <div class="action-card-icon">
7877 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
7878 </div>
7879 <div class="action-card-title">View Reports</div>
7880 <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>
7881 <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>
7882 </a>
7883
7884 <a class="action-card compare" href="/compare-scans">
7885 <div class="action-card-icon">
7886 <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>
7887 </div>
7888 <div class="action-card-title">Compare Scans</div>
7889 <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>
7890 <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>
7891 </a>
7892
7893 <a class="action-card git-tools" href="/git-browser">
7894 <div class="action-card-icon">
7895 <svg viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"></circle><circle cx="6" cy="6" r="3"></circle><path d="M13 6h3a2 2 0 0 1 2 2v7"></path><line x1="6" y1="9" x2="6" y2="21"></line></svg>
7896 </div>
7897 <div class="action-card-title">Git Browser</div>
7898 <p class="action-card-desc">Browse repository branches and commits in the Git Browser, or configure webhook triggers and automated scan schedules.</p>
7899 <span class="action-card-cta">Open Git Browser <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>
7900 </a>
7901 </div>
7902
7903 {% if server_mode %}
7904 <div class="lan-card server">
7905 <div class="lan-card-header">
7906 <span class="lan-badge">LAN server</span>
7907 Accessible on your network
7908 </div>
7909 {% if let Some(ip) = lan_ip %}
7910 <div class="lan-url-row">
7911 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
7912 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
7913 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
7914 Copy URL
7915 </button>
7916 </div>
7917 <p class="lan-hint">Share this address with anyone on the same network. They will be asked to authenticate.</p>
7918 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
7919 {% else %}
7920 <p class="lan-hint">Could not auto-detect your LAN IP. Find it with <code>hostname -I</code> (Linux) or <code>ipconfig</code> (Windows), then open <code>http://<your-ip>:{{ port }}</code>.</p>
7921 {% endif %}
7922 </div>
7923 {% endif %}
7924
7925 <div class="divider"></div>
7926
7927 <div class="info-strip">
7928 <div class="info-chip">
7929 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
7930 <div class="info-chip-val">41</div>
7931 <div class="info-chip-label">Languages</div>
7932 </div>
7933 <div class="info-chip">
7934 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
7935 <div class="info-chip-val">100%</div>
7936 <div class="info-chip-label">Self-contained</div>
7937 </div>
7938 <div class="info-chip">
7939 <div class="info-chip-tip">Self-contained HTML reports with<br>light/dark theme — share without a server</div>
7940 <div class="info-chip-val">HTML</div>
7941 <div class="info-chip-label">Exportable reports</div>
7942 </div>
7943 <div class="info-chip">
7944 <div class="info-chip-tip">Detects .gitmodules and produces<br>per-submodule breakdowns automatically</div>
7945 <div class="info-chip-val">Git</div>
7946 <div class="info-chip-label">Submodule support</div>
7947 </div>
7948 <div class="info-chip">
7949 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
7950 <div class="info-chip-val">IEEE</div>
7951 <div class="info-chip-label">1045-1992</div>
7952 </div>
7953 </div>
7954
7955 {% if lan_ip.is_none() %}
7956 <div class="lan-local-hint">
7957 <strong>Want teammates on the same network to access this?</strong><br>
7958 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
7959 </div>
7960 {% endif %}
7961 </div>
7962
7963 <footer class="site-footer">
7964 oxide-sloc v{{ version }} — local source line analysis workbench ·
7965 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7966 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7967 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7968 </footer>
7969
7970 <script nonce="{{ csp_nonce }}">
7971 (function () {
7972 var storageKey = 'oxide-sloc-theme';
7973 var body = document.body;
7974 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
7975 var toggle = document.getElementById('theme-toggle');
7976 if (toggle) toggle.addEventListener('click', function () {
7977 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
7978 body.classList.toggle('dark-theme', next === 'dark');
7979 try { localStorage.setItem(storageKey, next); } catch(e) {}
7980 });
7981 var copyBtn = document.getElementById('lan-copy-btn');
7982 if (copyBtn) copyBtn.addEventListener('click', function() {
7983 var btn = this;
7984 var el = document.getElementById('lan-url-val');
7985 if (!el) return;
7986 var url = el.textContent.trim();
7987 if (navigator.clipboard) {
7988 navigator.clipboard.writeText(url).then(function() {
7989 var orig = btn.innerHTML;
7990 btn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg> Copied!';
7991 setTimeout(function() { btn.innerHTML = orig; }, 1800);
7992 });
7993 }
7994 });
7995 (function randomizeWatermarks() {
7996 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7997 if (!wms.length) return;
7998 var placed = [];
7999 function tooClose(top, left) {
8000 for (var i = 0; i < placed.length; i++) {
8001 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
8002 if (dt < 16 && dl < 12) return true;
8003 }
8004 return false;
8005 }
8006 function pick(leftBand) {
8007 for (var attempt = 0; attempt < 50; attempt++) {
8008 var top = Math.random() * 88 + 2;
8009 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
8010 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
8011 }
8012 var top = Math.random() * 88 + 2;
8013 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
8014 placed.push([top, left]); return [top, left];
8015 }
8016 var half = Math.floor(wms.length / 2);
8017 wms.forEach(function (img, i) {
8018 var pos = pick(i < half);
8019 var size = Math.floor(Math.random() * 100 + 120);
8020 var rot = (Math.random() * 360).toFixed(1);
8021 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
8022 img.style.width=size+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
8023 });
8024 })();
8025
8026 (function spawnCodeParticles() {
8027 var container = document.getElementById('code-particles');
8028 if (!container) return;
8029 var snippets = [
8030 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
8031 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
8032 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
8033 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
8034 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
8035 ];
8036 var count = 38;
8037 for (var i = 0; i < count; i++) {
8038 (function(idx) {
8039 var el = document.createElement('span');
8040 el.className = 'code-particle';
8041 var text = snippets[idx % snippets.length];
8042 el.textContent = text;
8043 var left = Math.random() * 94 + 2;
8044 var top = Math.random() * 88 + 6;
8045 var dur = (Math.random() * 10 + 9).toFixed(1);
8046 var delay = (Math.random() * 18).toFixed(1);
8047 var rot = (Math.random() * 26 - 13).toFixed(1);
8048 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
8049 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
8050 + '--rot:' + rot + 'deg;--op:' + op + ';'
8051 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
8052 container.appendChild(el);
8053 })(i);
8054 }
8055 })();
8056 })();
8057 </script>
8058</body>
8059</html>
8060"##,
8061 ext = "html"
8062)]
8063struct SplashTemplate {
8064 csp_nonce: String,
8065 server_mode: bool,
8066 lan_ip: Option<String>,
8067 port: u16,
8068 version: &'static str,
8069}
8070
8071#[derive(Template)]
8074#[template(
8075 source = r##"
8076<!doctype html>
8077<html lang="en">
8078<head>
8079 <meta charset="utf-8">
8080 <meta name="viewport" content="width=device-width, initial-scale=1">
8081 <title>OxideSLOC — Start a Scan</title>
8082 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8083 <style nonce="{{ csp_nonce }}">
8084 :root {
8085 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
8086 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
8087 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
8088 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
8089 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
8090 }
8091 body.dark-theme {
8092 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
8093 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
8094 }
8095 *{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);}
8096 .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);}
8097 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
8098 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
8099 .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));}
8100 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
8101 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
8102 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;}
8103 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
8104 .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;}
8105 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
8106 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
8107 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
8108 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
8109 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
8110 .page{max-width:960px;margin:0 auto;padding:40px 24px 64px;position:relative;z-index:1;}
8111 .page-header{text-align:center;margin-bottom:32px;}
8112 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
8113 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
8114 .breadcrumb{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted);margin-bottom:28px;}
8115 .breadcrumb a{color:var(--muted);text-decoration:none;} .breadcrumb a:hover{color:var(--oxide);}
8116 .breadcrumb svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2;}
8117 /* Cards */
8118 .option-grid{display:flex;flex-direction:column;gap:16px;}
8119 .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;}
8120 .option-card:hover{border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
8121 /* Two-column layout inside each card */
8122 .card-body{display:grid;grid-template-columns:1fr 240px;gap:24px;align-items:center;}
8123 .card-left{display:flex;align-items:flex-start;gap:16px;min-width:0;}
8124 .option-icon{width:46px;height:46px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex:0 0 auto;}
8125 .option-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
8126 .option-icon.new-scan{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 6px 18px rgba(184,80,40,0.28);}
8127 .option-icon.load-config{background:linear-gradient(135deg,#3b82f6,#1d4ed8);color:#fff;box-shadow:0 6px 18px rgba(59,130,246,0.28);}
8128 .option-icon.rescan{background:linear-gradient(135deg,#8b5cf6,#6d28d9);color:#fff;box-shadow:0 6px 18px rgba(139,92,246,0.28);}
8129 .card-text{min-width:0;}
8130 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 4px;}
8131 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
8132 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
8133 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
8134 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
8135 /* Right CTA column */
8136 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
8137 .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;}
8138 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
8139 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
8140 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
8141 body.dark-theme .btn-secondary{color:var(--oxide);}
8142 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
8143 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
8144 /* File input overlay — must be full-width so it aligns with other card-right buttons */
8145 .file-input-wrap{position:relative;width:100%;}
8146 .file-input-wrap .btn{width:100%;}
8147 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
8148 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
8149 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
8150 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
8151 .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;}
8152 @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));}}
8153 /* Recent list (card 3 — full-width section below header) */
8154 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
8155 .recent-list{display:flex;flex-direction:column;gap:8px;}
8156 .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;}
8157 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
8158 .recent-item-info{flex:1;min-width:0;}
8159 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
8160 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
8161 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
8162 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
8163 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
8164 .site-footer a{color:var(--muted);}
8165 @media(max-width:680px){
8166 .card-body{grid-template-columns:1fr;}
8167 .card-right{flex-direction:row;flex-wrap:wrap;}
8168 .btn{flex:1;}
8169 }
8170 .nav-dropdown{position:relative;display:inline-flex;}.nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}.nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}.nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}.nav-dropdown-menu a:last-child{border-bottom:none;}.nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}.nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
8171 </style>
8172</head>
8173<body>
8174 <div class="background-watermarks" aria-hidden="true">
8175 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8176 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8177 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8178 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8179 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8180 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8181 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8182 </div>
8183 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8184 <div class="top-nav">
8185 <div class="top-nav-inner">
8186 <a class="brand" href="/">
8187 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
8188 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Source line analysis workbench</div></div>
8189 </a>
8190 <div class="nav-right">
8191 <a class="nav-pill" href="/">Home</a>
8192 <a class="nav-pill" href="/view-reports">View Reports</a>
8193 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
8194 <div class="nav-dropdown">
8195 <button class="nav-dropdown-btn" type="button">Git Tools <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
8196 <div class="nav-dropdown-menu">
8197 <a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
8198 <a href="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Webhooks</a>
8199 </div>
8200 </div>
8201 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
8202 <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>
8203 <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>
8204 </button>
8205 </div>
8206 </div>
8207 </div>
8208
8209 <div class="page">
8210 <div class="breadcrumb">
8211 <a href="/">Home</a>
8212 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
8213 <span>Scan Setup</span>
8214 </div>
8215
8216 <div class="page-header">
8217 <h1>How would you like to scan?</h1>
8218 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
8219 </div>
8220
8221 <div class="option-grid">
8222
8223 <!-- Option 1: New scan -->
8224 <div class="option-card">
8225 <div class="card-body">
8226 <div class="card-left">
8227 <div class="option-icon new-scan">
8228 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
8229 </div>
8230 <div class="card-text">
8231 <div class="option-title">Start a new scan</div>
8232 <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>
8233 <ul class="feature-list">
8234 <li>Live project scope preview before you run</li>
8235 <li>4 line-counting modes with interactive examples</li>
8236 <li>HTML, PDF, and JSON output — your choice</li>
8237 <li>IEEE 1045-1992 compliant physical SLOC counting</li>
8238 </ul>
8239 </div>
8240 </div>
8241 <div class="card-right">
8242 <a class="btn btn-primary" href="/scan">
8243 Configure & scan
8244 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
8245 </a>
8246 <p class="card-tip">Full 4-step setup · all options</p>
8247 </div>
8248 </div>
8249 </div>
8250
8251 <!-- Option 2: Load from config file -->
8252 <div class="option-card">
8253 <div class="card-body">
8254 <div class="card-left">
8255 <div class="option-icon load-config">
8256 <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>
8257 </div>
8258 <div class="card-text">
8259 <div class="option-title">Load a saved config</div>
8260 <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>
8261 <ul class="feature-list">
8262 <li>All 15 settings restored from the file</li>
8263 <li>Fully editable — change path or output dir</li>
8264 <li>Works with any scan-config.json</li>
8265 </ul>
8266 </div>
8267 </div>
8268 <div class="card-right">
8269 <div class="file-input-wrap">
8270 <button class="btn btn-secondary" id="load-config-btn" type="button">
8271 <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>
8272 Choose config file
8273 </button>
8274 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
8275 </div>
8276 <p class="card-tip" id="config-file-name">Exported after every scan</p>
8277 </div>
8278 </div>
8279 </div>
8280
8281 <!-- Option 3: Re-scan recent project -->
8282 <div class="option-card" id="recent-card">
8283 <div class="card-body">
8284 <div class="card-left" style="grid-column:1/-1;">
8285 <div class="option-icon rescan">
8286 <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>
8287 </div>
8288 <div class="card-text">
8289 <div class="option-title">Re-scan a recent project</div>
8290 <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>
8291 <ul class="feature-list">
8292 <li>All 15+ settings restored from the saved config</li>
8293 <li>Path and output dir are editable before running</li>
8294 <li>Only scans with a saved config appear here</li>
8295 </ul>
8296 </div>
8297 </div>
8298 </div>
8299 <div class="section-divider"></div>
8300 <div class="recent-list" id="recent-list">
8301 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
8302 </div>
8303 </div>
8304
8305 </div>
8306 </div>
8307
8308 <footer class="site-footer">
8309 oxide-sloc v{{ version }} — local source line analysis workbench ·
8310 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8311 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8312 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
8313 </footer>
8314
8315 <script nonce="{{ csp_nonce }}">
8316 (function () {
8317 var storageKey = 'oxide-sloc-theme';
8318 var body = document.body;
8319 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
8320 var toggle = document.getElementById('theme-toggle');
8321 if (toggle) toggle.addEventListener('click', function () {
8322 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
8323 body.classList.toggle('dark-theme', next === 'dark');
8324 try { localStorage.setItem(storageKey, next); } catch(e) {}
8325 });
8326
8327 (function randomizeWatermarks() {
8328 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8329 if (!wms.length) return;
8330 var placed = [];
8331 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; }
8332 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]; }
8333 var half = Math.floor(wms.length / 2);
8334 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.width=size+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op; });
8335 })();
8336 (function spawnCodeParticles() {
8337 var container = document.getElementById('code-particles');
8338 if (!container) return;
8339 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'];
8340 var count = 38;
8341 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.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s'; container.appendChild(el); })(i); }
8342 })();
8343
8344 // Recent scans data injected from server
8345 var recentScans = {{ recent_scans_json|safe }};
8346
8347 function configToParams(cfg) {
8348 var p = new URLSearchParams();
8349 p.set('prefilled', '1');
8350 if (cfg.path) p.set('path', cfg.path);
8351 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
8352 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
8353 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
8354 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
8355 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
8356 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
8357 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
8358 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
8359 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
8360 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
8361 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
8362 if (cfg.report_title) p.set('report_title', cfg.report_title);
8363 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
8364 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
8365 return p;
8366 }
8367
8368 // Build recent scan list (capped at 3 visible entries)
8369 var list = document.getElementById('recent-list');
8370 var noNote = document.getElementById('no-recent-note');
8371 var hasAny = false;
8372 var MAX_RECENT = 3;
8373 if (Array.isArray(recentScans)) {
8374 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
8375 var shown = 0;
8376 validEntries.forEach(function (entry) {
8377 if (shown >= MAX_RECENT) return;
8378 shown++;
8379 hasAny = true;
8380 var item = document.createElement('div');
8381 item.className = 'recent-item';
8382 item.title = 'Restore all settings and open wizard';
8383 item.innerHTML =
8384 '<div class="recent-item-info">' +
8385 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
8386 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' · ' + escHtml(entry.timestamp || '') + '</div>' +
8387 '</div>' +
8388 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
8389 item.addEventListener('click', function () {
8390 var params = configToParams(entry.config);
8391 window.location.href = '/scan?' + params.toString();
8392 });
8393 list.appendChild(item);
8394 });
8395 if (validEntries.length > MAX_RECENT) {
8396 var moreEl = document.createElement('div');
8397 moreEl.className = 'recent-more-link';
8398 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
8399 list.appendChild(moreEl);
8400 }
8401 }
8402 if (hasAny && noNote) noNote.style.display = 'none';
8403
8404 // Config file loader
8405 var fileInput = document.getElementById('config-file-input');
8406 var fileName = document.getElementById('config-file-name');
8407 if (fileInput) {
8408 fileInput.addEventListener('change', function () {
8409 var file = fileInput.files && fileInput.files[0];
8410 if (!file) return;
8411 if (fileName) fileName.textContent = '✓ ' + file.name;
8412 var reader = new FileReader();
8413 reader.onload = function (e) {
8414 try {
8415 var cfg = JSON.parse(e.target.result);
8416 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
8417 var params = configToParams(cfg);
8418 window.location.href = '/scan?' + params.toString();
8419 } catch (err) {
8420 alert('Could not parse config file: ' + err.message);
8421 }
8422 };
8423 reader.readAsText(file);
8424 });
8425 }
8426
8427 function escHtml(s) {
8428 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
8429 }
8430 })();
8431 </script>
8432</body>
8433</html>
8434"##,
8435 ext = "html"
8436)]
8437struct ScanSetupTemplate {
8438 version: &'static str,
8439 recent_scans_json: String,
8440 csp_nonce: String,
8441}
8442
8443#[derive(Template)]
8444#[template(
8445 source = r##"
8446<!doctype html>
8447<html lang="en">
8448<head>
8449 <meta charset="utf-8">
8450 <meta name="viewport" content="width=device-width, initial-scale=1">
8451 <title>OxideSLOC | {{ report_title }} | Report</title>
8452 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8453 <style nonce="{{ csp_nonce }}">
8454 :root {
8455 --radius: 18px;
8456 --bg: #f5efe8;
8457 --surface: rgba(255,255,255,0.82);
8458 --surface-2: #fbf7f2;
8459 --surface-3: #efe6dc;
8460 --line: #e6d0bf;
8461 --line-strong: #dcb89f;
8462 --text: #43342d;
8463 --muted: #7b675b;
8464 --muted-2: #a08777;
8465 --nav: #b85d33;
8466 --nav-2: #7a371b;
8467 --accent: #6f9bff;
8468 --accent-2: #4a78ee;
8469 --oxide: #d37a4c;
8470 --oxide-2: #b35428;
8471 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
8472 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
8473 --success-bg: #e8f5ed;
8474 --success-text: #1a8f47;
8475 --info-bg: #eef3ff;
8476 --info-text: #4467d8;
8477 }
8478
8479 body.dark-theme {
8480 --bg: #1b1511;
8481 --surface: #261c17;
8482 --surface-2: #2d221d;
8483 --surface-3: #372922;
8484 --line: #524238;
8485 --line-strong: #6c5649;
8486 --text: #f5ece6;
8487 --muted: #c7b7aa;
8488 --muted-2: #aa9485;
8489 --nav: #b85d33;
8490 --nav-2: #7a371b;
8491 --accent: #6f9bff;
8492 --accent-2: #4a78ee;
8493 --oxide: #d37a4c;
8494 --oxide-2: #b35428;
8495 --shadow: 0 18px 42px rgba(0,0,0,0.28);
8496 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
8497 --success-bg: #163927;
8498 --success-text: #8fe2a8;
8499 --info-bg: #1c2847;
8500 --info-text: #a9c1ff;
8501 }
8502
8503 * { box-sizing: border-box; }
8504 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); }
8505 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
8506 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
8507 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
8508 .top-nav, .page { position: relative; z-index: 2; }
8509 .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); }
8510 .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; }
8511 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
8512 .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)); }
8513 .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; }
8514 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
8515 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
8516 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
8517 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
8518 .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; }
8519 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
8520 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
8521 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
8522 .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; }
8523 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
8524 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
8525 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
8526 .theme-toggle .icon-sun { display:none; }
8527 body.dark-theme .theme-toggle .icon-sun { display:block; }
8528 body.dark-theme .theme-toggle .icon-moon { display:none; }
8529 .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; }
8530 .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;}
8531 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
8532 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
8533 .hero, .panel { padding: 22px; }
8534 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
8535 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
8536 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
8537 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
8538 .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; }
8539 .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
8540 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
8541 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
8542 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
8543 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
8544 .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
8545 .delta-card-inline { background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:6px 12px; text-align:center; min-width:80px; }
8546 .delta-card-val { font-size:16px; font-weight:800; }
8547 .delta-card-val.pos { color:#1e7e34; }
8548 .delta-card-val.neg { color:var(--neg); }
8549 .delta-card-val.mod { color:#b35428; }
8550 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
8551 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
8552 .compare-ts { font-size:13px; color:var(--muted); }
8553 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
8554 .compare-arrow { color: var(--muted); }
8555 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
8556 .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; }
8557 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
8558 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
8559 .button, .copy-button {
8560 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;
8561 }
8562 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
8563 @keyframes spin { to { transform: rotate(360deg); } }
8564 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
8565 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
8566 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
8567 .path-item strong { display: block; margin-bottom: 6px; }
8568 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
8569 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
8570 .path-subitem { flex: 1; }
8571 .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); }
8572 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); }
8573 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
8574 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
8575 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
8576 th:first-child, td:first-child { width: 28%; }
8577 th { color: var(--muted); font-weight: 700; }
8578 tr:last-child td { border-bottom: none; }
8579 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
8580 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
8581 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
8582 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
8583 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
8584 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
8585 .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; }
8586 .soft-chip.success { background: var(--success-bg); color: var(--success-text); }
8587 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
8588 .muted { color: var(--muted); }
8589 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
8590 .site-footer a{color:var(--muted);}
8591 .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; }
8592 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
8593 .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; }
8594 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
8595 /* Submodule panel */
8596 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
8597 /* Metrics tables stack */
8598 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
8599 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
8600 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
8601 .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)); }
8602 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
8603 /* Metrics table */
8604 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
8605 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
8606 .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; }
8607 .metrics-table thead th:not(:first-child) { text-align: right; }
8608 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
8609 .metrics-table tbody tr:last-child td { border-bottom: none; }
8610 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
8611 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
8612 .metrics-table tbody tr:hover td { background: var(--surface-2); }
8613 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
8614 .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; }
8615 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
8616 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
8617 .mt-val-pos { color: var(--pos); font-weight: 700; }
8618 .mt-val-neg { color: var(--neg); font-weight: 700; }
8619 .mt-val-zero { color: var(--muted); }
8620 .mt-val-mod { color: var(--oxide-2); }
8621 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
8622 @media (max-width: 1180px) {
8623 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
8624 .nav-project-slot, .nav-status { justify-content:flex-start; }
8625 .hero-top { flex-direction: column; }
8626 }
8627 .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;}
8628 @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));}}
8629 .nav-dropdown{position:relative;display:inline-flex;}.nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}.nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}.nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}.nav-dropdown-menu a:last-child{border-bottom:none;}.nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}.nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
8630 </style>
8631</head>
8632<body>
8633 <div class="background-watermarks" aria-hidden="true">
8634 <img src="/images/logo/logo-text.png" alt="" />
8635 <img src="/images/logo/logo-text.png" alt="" />
8636 <img src="/images/logo/logo-text.png" alt="" />
8637 <img src="/images/logo/logo-text.png" alt="" />
8638 <img src="/images/logo/logo-text.png" alt="" />
8639 <img src="/images/logo/logo-text.png" alt="" />
8640 <img src="/images/logo/logo-text.png" alt="" />
8641 <img src="/images/logo/logo-text.png" alt="" />
8642 <img src="/images/logo/logo-text.png" alt="" />
8643 <img src="/images/logo/logo-text.png" alt="" />
8644 <img src="/images/logo/logo-text.png" alt="" />
8645 <img src="/images/logo/logo-text.png" alt="" />
8646 <img src="/images/logo/logo-text.png" alt="" />
8647 <img src="/images/logo/logo-text.png" alt="" />
8648 </div>
8649 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8650 <div class="top-nav">
8651 <div class="top-nav-inner">
8652 <a class="brand" href="/">
8653 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
8654 <div class="brand-copy">
8655 <div class="brand-title">OxideSLOC</div>
8656 <div class="brand-subtitle">Local analysis workbench</div>
8657 </div>
8658 </a>
8659 <div class="nav-project-slot">
8660 <div class="nav-project-pill"><span class="nav-project-label">Project</span><span class="nav-project-value">{{ report_title }}</span></div>
8661 </div>
8662 <div class="nav-status">
8663 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
8664 <a class="nav-pill" href="/view-reports" style="text-decoration:none;">View Reports</a>
8665 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
8666 <div class="nav-dropdown">
8667 <button class="nav-dropdown-btn" type="button">Git Tools <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
8668 <div class="nav-dropdown-menu">
8669 <a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
8670 <a href="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Webhooks</a>
8671 </div>
8672 </div>
8673 <div class="server-status-wrap">
8674 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
8675 <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>
8676 </div>
8677 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
8678 <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>
8679 <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>
8680 </button>
8681 </div>
8682 </div>
8683 </div>
8684
8685 <div class="page">
8686 <section class="hero">
8687 <div class="hero-top">
8688 <div>
8689 <div class="soft-chip success">Run finished successfully</div>
8690 <h1 class="hero-title">{{ report_title }}</h1>
8691 <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>
8692 </div>
8693 <div class="hero-quick-actions">
8694 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
8695 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
8696 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
8697 </div>
8698 </div>
8699
8700 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
8701 <div class="compare-banner">
8702 <div class="compare-banner-body">
8703 <div class="compare-banner-meta">
8704 <span class="compare-label">Previous scan</span>
8705 <span class="compare-ts">{{ prev_ts }}</span>
8706 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
8707 {% if let Some(prev_code) = prev_run_code_lines %}
8708 <div class="compare-banner-stats" style="margin-top:4px;">
8709 <span>Code before: <strong>{{ prev_code }}</strong></span>
8710 <span class="compare-arrow">→</span>
8711 <span>Code now: <strong>{{ code_lines }}</strong></span>
8712 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
8713 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−{{ removed }} removed</span>{% endif %}
8714 </div>
8715 {% endif %}
8716 </div>
8717 {% if delta_lines_added.is_some() %}
8718 <div class="delta-cards-inline">
8719 <div class="delta-card-inline">
8720 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
8721 <div class="delta-card-lbl">lines added</div>
8722 </div>
8723 <div class="delta-card-inline">
8724 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v }}{% else %}—{% endif %}</div>
8725 <div class="delta-card-lbl">lines removed</div>
8726 </div>
8727 <div class="delta-card-inline">
8728 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
8729 <div class="delta-card-lbl">unmodified lines</div>
8730 </div>
8731 <div class="delta-card-inline">
8732 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
8733 <div class="delta-card-lbl">files modified</div>
8734 </div>
8735 <div class="delta-card-inline">
8736 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
8737 <div class="delta-card-lbl">files added</div>
8738 </div>
8739 <div class="delta-card-inline">
8740 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
8741 <div class="delta-card-lbl">files removed</div>
8742 </div>
8743 <div class="delta-card-inline">
8744 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
8745 <div class="delta-card-lbl">files unchanged</div>
8746 </div>
8747 </div>
8748 {% else %}
8749 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
8750 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
8751 </p>
8752 {% endif %}
8753 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
8754 </div>
8755 </div>
8756 {% endif %}{% endif %}
8757
8758 <div class="action-grid">
8759 <div class="action-card">
8760 <h3>HTML report</h3>
8761 <div class="action-buttons">
8762 {% match html_url %}
8763 {% when Some with (url) %}
8764 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
8765 {% when None %}{% endmatch %}
8766 {% match html_download_url %}
8767 {% when Some with (url) %}
8768 <a class="button secondary" href="{{ url }}">Download HTML</a>
8769 {% when None %}{% endmatch %}
8770 {% match html_path %}
8771 {% when Some with (_path) %}{% when None %}{% endmatch %}
8772 </div>
8773 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
8774 </div>
8775 <div class="action-card">
8776 <h3>PDF report</h3>
8777 <div class="action-buttons">
8778 {% match pdf_url %}
8779 {% when Some with (url) %}
8780 {% if pdf_generating %}
8781 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
8782 <span style="width:14px;height:14px;border:2px solid rgba(255,255,255,0.4);border-top-color:#fff;border-radius:50%;display:inline-block;animation:spin .75s linear infinite;flex:0 0 auto;"></span>
8783 Generating PDF…
8784 </button>
8785 {% else %}
8786 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
8787 {% endif %}
8788 {% when None %}{% endmatch %}
8789 {% match pdf_download_url %}
8790 {% when Some with (url) %}
8791 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
8792 {% when None %}{% endmatch %}
8793 {% match pdf_path %}
8794 {% when Some with (_path) %}{% when None %}{% endmatch %}
8795 </div>
8796 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
8797 </div>
8798 <div class="action-card">
8799 <h3>JSON result</h3>
8800 <div class="action-buttons">
8801 {% match json_url %}
8802 {% when Some with (url) %}
8803 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
8804 {% when None %}{% endmatch %}
8805 {% match json_download_url %}
8806 {% when Some with (url) %}
8807 <a class="button secondary" href="{{ url }}">Download JSON</a>
8808 {% when None %}{% endmatch %}
8809 {% match json_path %}
8810 {% when Some with (_path) %}
8811 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
8812 {% when None %}
8813 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
8814 {% endmatch %}
8815 </div>
8816 </div>
8817 <div class="action-card">
8818 <h3>Scan config</h3>
8819 <div class="action-buttons">
8820 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
8821 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
8822 </div>
8823 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
8824 </div>
8825 </div>
8826 {% if !submodule_rows.is_empty() %}
8827 <div class="submodule-panel">
8828 <div class="toolbar-row">
8829 <div>
8830 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
8831 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
8832 </div>
8833 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
8834 </div>
8835 <div style="overflow:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
8836 <table style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:700px;">
8837 <colgroup>
8838 <col style="width:14%"><col style="width:40%">
8839 <col style="width:6%"><col style="width:8%"><col style="width:6%">
8840 <col style="width:8%"><col style="width:6%"><col style="width:12%">
8841 </colgroup>
8842 <thead>
8843 <tr>
8844 <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;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Submodule</th>
8845 <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;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Path</th>
8846 <th style="padding:9px 10px;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;white-space:nowrap;">Files</th>
8847 <th style="padding:9px 10px;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;white-space:nowrap;">Physical</th>
8848 <th style="padding:9px 10px;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;white-space:nowrap;">Code</th>
8849 <th style="padding:9px 10px;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;white-space:nowrap;">Comments</th>
8850 <th style="padding:9px 10px;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;white-space:nowrap;">Blank</th>
8851 <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;white-space:nowrap;">Report</th>
8852 </tr>
8853 </thead>
8854 <tbody>
8855 {% for row in submodule_rows %}
8856 <tr>
8857 <td style="padding:10px 14px;border-bottom:1px solid var(--line);font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ row.name }}"><strong>{{ row.name }}</strong></td>
8858 <td style="padding:10px 14px;border-bottom:1px solid var(--line);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ row.relative_path }}"><code style="font-size:12px;">{{ row.relative_path }}</code></td>
8859 <td style="padding:10px 10px;border-bottom:1px solid var(--line);text-align:right;">{{ row.files_analyzed }}</td>
8860 <td style="padding:10px 10px;border-bottom:1px solid var(--line);text-align:right;">{{ row.total_physical_lines }}</td>
8861 <td style="padding:10px 10px;border-bottom:1px solid var(--line);text-align:right;">{{ row.code_lines }}</td>
8862 <td style="padding:10px 10px;border-bottom:1px solid var(--line);text-align:right;">{{ row.comment_lines }}</td>
8863 <td style="padding:10px 10px;border-bottom:1px solid var(--line);text-align:right;">{{ row.blank_lines }}</td>
8864 <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>
8865 </tr>
8866 {% endfor %}
8867 </tbody>
8868 </table>
8869 </div>
8870 </div>
8871 {% endif %}
8872
8873 <div class="metrics-tables-stack">
8874
8875 <div class="metrics-table-wrap">
8876 <div class="metrics-table-title">Files</div>
8877 <table class="metrics-table">
8878 <thead>
8879 <tr>
8880 <th>Metric</th>
8881 <th>This Run</th>
8882 <th>Previous</th>
8883 <th>Change</th>
8884 </tr>
8885 </thead>
8886 <tbody>
8887 <tr>
8888 <td>Files analyzed</td>
8889 <td class="mt-val-large">{{ files_analyzed }}</td>
8890 <td>{{ prev_fa_str }}</td>
8891 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
8892 </tr>
8893 <tr>
8894 <td>Files skipped</td>
8895 <td>{{ files_skipped }}</td>
8896 <td>{{ prev_fs_str }}</td>
8897 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
8898 </tr>
8899 <tr>
8900 <td>Files modified</td>
8901 <td class="mt-val-na">—</td>
8902 <td class="mt-val-na">—</td>
8903 <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>
8904 </tr>
8905 <tr>
8906 <td>Files unchanged</td>
8907 <td class="mt-val-na">—</td>
8908 <td class="mt-val-na">—</td>
8909 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
8910 </tr>
8911 </tbody>
8912 </table>
8913 </div>
8914
8915 <div class="metrics-table-wrap">
8916 <div class="metrics-table-title">Line Counts</div>
8917 <table class="metrics-table">
8918 <thead>
8919 <tr>
8920 <th>Metric</th>
8921 <th>This Run</th>
8922 <th>Previous</th>
8923 <th>Change</th>
8924 </tr>
8925 </thead>
8926 <tbody>
8927 <tr>
8928 <td>Physical lines</td>
8929 <td class="mt-val-large">{{ physical_lines }}</td>
8930 <td>{{ prev_pl_str }}</td>
8931 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
8932 </tr>
8933 <tr>
8934 <td>Code lines</td>
8935 <td class="mt-val-large">{{ code_lines }}</td>
8936 <td>{{ prev_cl_str }}</td>
8937 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
8938 </tr>
8939 <tr>
8940 <td>Comment lines</td>
8941 <td>{{ comment_lines }}</td>
8942 <td>{{ prev_cml_str }}</td>
8943 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
8944 </tr>
8945 <tr>
8946 <td>Blank lines</td>
8947 <td>{{ blank_lines }}</td>
8948 <td>{{ prev_bl_str }}</td>
8949 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
8950 </tr>
8951 <tr>
8952 <td>Mixed (separate)</td>
8953 <td>{{ mixed_lines }}</td>
8954 <td class="mt-val-na">—</td>
8955 <td class="mt-val-na">—</td>
8956 </tr>
8957 </tbody>
8958 </table>
8959 </div>
8960
8961 <div class="metrics-tables-lower">
8962 <div class="metrics-table-wrap">
8963 <div class="metrics-table-title">Code Structure</div>
8964 <table class="metrics-table">
8965 <thead>
8966 <tr>
8967 <th>Metric</th>
8968 <th>This Run</th>
8969 </tr>
8970 </thead>
8971 <tbody>
8972 <tr>
8973 <td>Functions</td>
8974 <td>{{ functions }}</td>
8975 </tr>
8976 <tr>
8977 <td>Classes / Types</td>
8978 <td>{{ classes }}</td>
8979 </tr>
8980 <tr>
8981 <td>Variables</td>
8982 <td>{{ variables }}</td>
8983 </tr>
8984 <tr>
8985 <td>Imports</td>
8986 <td>{{ imports }}</td>
8987 </tr>
8988 </tbody>
8989 </table>
8990 </div>
8991
8992 <div class="metrics-table-wrap">
8993 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
8994 <table class="metrics-table">
8995 <thead>
8996 <tr>
8997 <th>Metric</th>
8998 <th>Change</th>
8999 </tr>
9000 </thead>
9001 <tbody>
9002 <tr>
9003 <td>Lines added</td>
9004 <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>
9005 </tr>
9006 <tr>
9007 <td>Lines removed</td>
9008 <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>
9009 </tr>
9010 <tr>
9011 <td>Lines modified (net)</td>
9012 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
9013 </tr>
9014 <tr>
9015 <td>Lines unmodified</td>
9016 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
9017 </tr>
9018 </tbody>
9019 </table>
9020 </div>
9021 </div>
9022
9023 </div>
9024
9025 <div class="path-list">
9026 <div class="path-item">
9027 <div class="path-item-label">Project path</div>
9028 <code>{{ project_path }}</code>
9029 </div>
9030 <div class="path-item">
9031 <div class="path-item-label">Git branch</div>
9032 {% if let Some(branch) = git_branch %}
9033 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
9034 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
9035 {% else %}
9036 <code style="color:var(--muted)">—</code>
9037 {% endif %}
9038 </div>
9039 <div class="path-item">
9040 <div class="path-item-label">Output folder</div>
9041 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
9042 </div>
9043 <div class="path-item">
9044 <div class="path-item-label">Run ID</div>
9045 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
9046 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
9047 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
9048 </div>
9049 </div>
9050 </div>
9051 </section>
9052
9053 <section class="panel" style="margin-bottom: 18px;">
9054 <div class="toolbar-row">
9055 <div>
9056 <h2>Language breakdown</h2>
9057 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
9058 </div>
9059 </div>
9060 <div id="result-lang-charts" style="margin:0 0 18px;"></div>
9061 <table>
9062 <thead>
9063 <tr>
9064 <th>Language</th>
9065 <th>Files</th>
9066 <th>Physical</th>
9067 <th>Code</th>
9068 <th>Comments</th>
9069 <th>Blank</th>
9070 <th>Mixed</th>
9071 <th>Functions</th>
9072 <th>Classes</th>
9073 <th>Variables</th>
9074 <th>Imports</th>
9075 </tr>
9076 </thead>
9077 <tbody>
9078 {% for row in language_rows %}
9079 <tr>
9080 <td>{{ row.language }}</td>
9081 <td>{{ row.files }}</td>
9082 <td>{{ row.physical }}</td>
9083 <td>{{ row.code }}</td>
9084 <td>{{ row.comments }}</td>
9085 <td>{{ row.blank }}</td>
9086 <td>{{ row.mixed }}</td>
9087 <td>{{ row.functions }}</td>
9088 <td>{{ row.classes }}</td>
9089 <td>{{ row.variables }}</td>
9090 <td>{{ row.imports }}</td>
9091 </tr>
9092 {% endfor %}
9093 </tbody>
9094 </table>
9095 </section>
9096
9097 </div>
9098
9099 <script nonce="{{ csp_nonce }}">
9100 (function () {
9101 var body = document.body;
9102 var themeToggle = document.getElementById('theme-toggle');
9103 var storageKey = 'oxide-sloc-theme';
9104
9105 function applyTheme(theme) {
9106 body.classList.toggle('dark-theme', theme === 'dark');
9107 }
9108
9109 function loadSavedTheme() {
9110 try {
9111 var saved = localStorage.getItem(storageKey);
9112 if (saved === 'dark' || saved === 'light') {
9113 applyTheme(saved);
9114 }
9115 } catch (e) {}
9116 }
9117
9118 if (themeToggle) {
9119 themeToggle.addEventListener('click', function () {
9120 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
9121 applyTheme(nextTheme);
9122 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
9123 });
9124 }
9125
9126 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
9127 button.addEventListener('click', function () {
9128 var value = button.getAttribute('data-copy-value') || '';
9129 if (!value) return;
9130 if (navigator.clipboard && navigator.clipboard.writeText) {
9131 navigator.clipboard.writeText(value).catch(function () {});
9132 }
9133 });
9134 });
9135
9136 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
9137 btn.addEventListener('click', function () {
9138 var folder = btn.getAttribute('data-folder') || '';
9139 if (!folder) return;
9140 fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
9141 });
9142 });
9143
9144 loadSavedTheme();
9145
9146 // ── Language overview charts ───────────────────────────────────────────
9147 (function(){
9148 var D={{ lang_chart_json|safe }};
9149 if(!D||!D.length)return;
9150 var el=document.getElementById('result-lang-charts');
9151 if(!el)return;
9152 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
9153 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
9154 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
9155 function fmt(n){return Number(n).toLocaleString();}
9156 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
9157 function px(n){return Math.round(n);}
9158 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
9159 var cx=120,cy=120,Ro=100,Ri=54,DW=420,DH=Math.max(270,24+D.length*22);
9160 var ds='<svg viewBox="0 0 '+DW+' '+DH+'" width="'+DW+'" height="'+DH+'" style="display:block;" xmlns="http://www.w3.org/2000/svg">';
9161 if(D.length===1){
9162 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
9163 ds+='<circle cx="'+cx+'" cy="'+cy+'" r="'+rm+'" fill="none" stroke="'+COLS[0]+'" stroke-width="'+rsw+'"/>';
9164 } else {
9165 var ang=-Math.PI/2;
9166 D.forEach(function(d,i){
9167 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
9168 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
9169 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
9170 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
9171 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
9172 ds+='<path 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="'+(COLS[i%COLS.length])+'" stroke="white" stroke-width="2"/>';
9173 ang+=sw;
9174 });
9175 }
9176 ds+='<text x="'+cx+'" y="'+(cy-6)+'" text-anchor="middle" font-family="'+FONT+'" font-size="22" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
9177 ds+='<text x="'+cx+'" y="'+(cy+16)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
9178 D.forEach(function(d,i){
9179 var ly=12+i*22;
9180 if(ly+16>DH)return;
9181 ds+='<rect x="'+(cx+Ro+14)+'" y="'+ly+'" width="13" height="13" fill="'+(COLS[i%COLS.length])+'" rx="3"/>';
9182 ds+='<text x="'+(cx+Ro+32)+'" y="'+(ly+11)+'" font-family="'+FONT+'" font-size="12" fill="#43342d">'+esc(d.lang)+'</text>';
9183 });
9184 ds+='</svg>';
9185 var maxT=Math.max.apply(null,D.map(function(d){return d.code+d.comments+d.blanks;}))||1;
9186 var LW=104,BW=280,rHb=30,bH=22,SH=D.length*rHb+36;
9187 var bs='<svg viewBox="0 0 '+(LW+BW+62)+' '+SH+'" width="'+(LW+BW+62)+'" height="'+SH+'" style="display:block;" xmlns="http://www.w3.org/2000/svg">';
9188 D.forEach(function(d,i){
9189 var y=10+i*rHb,x=LW;
9190 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
9191 bs+='<text x="'+(LW-6)+'" y="'+(y+bH/2+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="#43342d">'+esc(d.lang)+'</text>';
9192 if(cW>0)bs+='<rect x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';x+=cW;
9193 if(cmW>0)bs+='<rect x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';x+=cmW;
9194 if(blW>0)bs+='<rect x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';
9195 bs+='<text x="'+(LW+BW+5)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="11" fill="#7b675b">'+fmt(d.code+d.comments+d.blanks)+'</text>';
9196 });
9197 var ly=SH-16;
9198 bs+='<rect x="'+LW+'" y="'+ly+'" width="10" height="10" fill="'+OX+'"/><text x="'+(LW+14)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>';
9199 bs+='<rect x="'+(LW+56)+'" y="'+ly+'" width="10" height="10" fill="'+GN+'"/><text x="'+(LW+70)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>';
9200 bs+='<rect x="'+(LW+158)+'" y="'+ly+'" width="10" height="10" fill="'+GY+'"/><text x="'+(LW+172)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>';
9201 bs+='</svg>';
9202 var lbl='font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;text-align:center;';
9203 el.innerHTML='<div style="overflow-x:auto;text-align:center;padding:6px 0;">'+
9204 '<table style="display:inline-table;border-collapse:separate;border-spacing:56px 0;margin:0 auto;">'+
9205 '<tr>'+
9206 '<td style="vertical-align:top;padding:0;"><p style="'+lbl+'">Code Lines by Language</p>'+ds+'</td>'+
9207 '<td style="vertical-align:top;padding:0;"><p style="'+lbl+'">Line Mix per Language</p>'+bs+'</td>'+
9208 '</tr>'+
9209 '</table>'+
9210 '</div>';
9211 })();
9212
9213 (function randomizeWatermarks() {
9214 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
9215 if (!wms.length) return;
9216 var placed = [];
9217 function tooClose(top, left) {
9218 for (var i = 0; i < placed.length; i++) {
9219 var dt = Math.abs(placed[i][0] - top);
9220 var dl = Math.abs(placed[i][1] - left);
9221 if (dt < 20 && dl < 18) return true;
9222 }
9223 return false;
9224 }
9225 function pick(leftBand) {
9226 for (var attempt = 0; attempt < 50; attempt++) {
9227 var top = Math.random() * 85 + 5;
9228 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
9229 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
9230 }
9231 var top = Math.random() * 85 + 5;
9232 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
9233 placed.push([top, left]);
9234 return [top, left];
9235 }
9236 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
9237 var half = Math.floor(wms.length / 2);
9238 wms.forEach(function (img, i) {
9239 var pos = pick(i < half);
9240 var size = Math.floor(Math.random() * 100 + 160);
9241 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
9242 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
9243 img.style.width=size+"px";img.style.top=pos[0].toFixed(1)+"%";img.style.left=pos[1].toFixed(1)+"%";img.style.transform="rotate("+rot.toFixed(1)+"deg)";img.style.opacity=op;
9244 });
9245 })();
9246
9247 (function spawnCodeParticles() {
9248 var container = document.getElementById('code-particles');
9249 if (!container) return;
9250 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'];
9251 for (var i = 0; i < 38; i++) {
9252 (function(idx) {
9253 var el = document.createElement('span');
9254 el.className = 'code-particle';
9255 el.textContent = snippets[idx % snippets.length];
9256 var left = Math.random() * 94 + 2;
9257 var top = Math.random() * 88 + 6;
9258 var dur = (Math.random() * 10 + 9).toFixed(1);
9259 var delay = (Math.random() * 18).toFixed(1);
9260 var rot = (Math.random() * 26 - 13).toFixed(1);
9261 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
9262 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
9263 container.appendChild(el);
9264 })(i);
9265 }
9266 })();
9267
9268 {% if pdf_generating %}
9269 // Poll for PDF readiness and swap the disabled button to a live link once done.
9270 (function() {
9271 var openBtn = document.getElementById('pdf-open-btn');
9272 var dlBtn = document.getElementById('pdf-download-btn');
9273 function checkPdf() {
9274 fetch('/api/runs/{{ run_id }}/pdf-status')
9275 .then(function(r) { return r.json(); })
9276 .then(function(d) {
9277 if (d.ready) {
9278 if (openBtn) {
9279 var a = document.createElement('a');
9280 a.className = 'button';
9281 a.id = 'pdf-open-btn';
9282 a.href = '/runs/{{ run_id }}/pdf';
9283 a.target = '_blank';
9284 a.rel = 'noopener';
9285 a.textContent = 'Open PDF';
9286 openBtn.replaceWith(a);
9287 }
9288 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
9289 } else {
9290 setTimeout(checkPdf, 3000);
9291 }
9292 })
9293 .catch(function() { setTimeout(checkPdf, 5000); });
9294 }
9295 setTimeout(checkPdf, 3000);
9296 })();
9297 {% endif %}
9298
9299 })();
9300 </script>
9301 <footer class="site-footer">
9302 oxide-sloc v{{ version }} — local source line analysis workbench ·
9303 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
9304 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
9305 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
9306 </footer>
9307</body>
9308</html>
9309"##,
9310 ext = "html"
9311)]
9312struct ResultTemplate {
9313 version: &'static str,
9314 report_title: String,
9315 project_path: String,
9316 output_dir: String,
9317 run_id: String,
9318 files_analyzed: u64,
9319 files_skipped: u64,
9320 physical_lines: u64,
9321 code_lines: u64,
9322 comment_lines: u64,
9323 blank_lines: u64,
9324 mixed_lines: u64,
9325 functions: u64,
9326 classes: u64,
9327 variables: u64,
9328 imports: u64,
9329 html_url: Option<String>,
9330 pdf_url: Option<String>,
9331 json_url: Option<String>,
9332 html_download_url: Option<String>,
9333 pdf_download_url: Option<String>,
9334 json_download_url: Option<String>,
9335 html_path: Option<String>,
9336 pdf_path: Option<String>,
9337 json_path: Option<String>,
9338 language_rows: Vec<LanguageSummaryRow>,
9339 prev_run_id: Option<String>,
9340 prev_run_timestamp: Option<String>,
9341 prev_run_code_lines: Option<u64>,
9342 prev_fa_str: String,
9344 prev_fs_str: String,
9345 prev_pl_str: String,
9346 prev_cl_str: String,
9347 prev_cml_str: String,
9348 prev_bl_str: String,
9349 delta_fa_str: String,
9351 delta_fa_class: String,
9352 delta_fs_str: String,
9353 delta_fs_class: String,
9354 delta_pl_str: String,
9355 delta_pl_class: String,
9356 delta_cl_str: String,
9357 delta_cl_class: String,
9358 delta_cml_str: String,
9359 delta_cml_class: String,
9360 delta_bl_str: String,
9361 delta_bl_class: String,
9362 delta_lines_added: Option<i64>,
9364 delta_lines_removed: Option<i64>,
9365 delta_lines_net_str: String,
9366 delta_lines_net_class: String,
9367 delta_files_added: Option<usize>,
9368 delta_files_removed: Option<usize>,
9369 delta_files_modified: Option<usize>,
9370 delta_files_unchanged: Option<usize>,
9371 delta_unmodified_lines: Option<u64>,
9372 git_branch: Option<String>,
9374 git_commit: Option<String>,
9375 git_author: Option<String>,
9376 prev_scan_count: usize,
9378 current_scan_number: usize,
9379 submodule_rows: Vec<SubmoduleRow>,
9381 scan_config_url: String,
9382 lang_chart_json: String,
9383 pdf_generating: bool,
9384 csp_nonce: String,
9385}
9386
9387#[derive(Template)]
9388#[template(
9389 source = r##"
9390<!doctype html>
9391<html lang="en">
9392<head>
9393 <meta charset="utf-8">
9394 <meta name="viewport" content="width=device-width, initial-scale=1">
9395 <title>OxideSLOC | Analyzing…</title>
9396 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9397 <style nonce="{{ csp_nonce }}">
9398 :root {
9399 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
9400 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
9401 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#4a78ee;
9402 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
9403 }
9404 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
9405 *{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);}
9406 .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);}
9407 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
9408 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
9409 .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));}
9410 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
9411 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
9412 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;}
9413 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
9414 .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;}
9415 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
9416 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
9417 .page-body{max-width:1720px;margin:0 auto;padding:32px 24px 80px;}
9418 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
9419 .wait-badge{display:inline-flex;align-items:center;gap:8px;background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.3);border-radius:999px;padding:5px 14px 5px 10px;font-size:12px;font-weight:700;color:var(--accent-2);margin-bottom:20px;}
9420 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
9421 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
9422 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
9423 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
9424 .path-block{background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 16px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.85rem;color:var(--muted);word-break:break-all;margin-bottom:24px;}
9425 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
9426 .metric-card{background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:12px 18px;min-width:140px;}
9427 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
9428 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
9429 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
9430 .progress-bar{height:100%;width:0%;border-radius:999px;background:linear-gradient(90deg,var(--accent-2),var(--oxide));animation:indeterminate 1.8s ease-in-out infinite;}
9431 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
9432 .hidden{display:none!important;}
9433 .warn-slow{background:rgba(230,160,50,0.12);border:1px solid rgba(230,160,50,0.3);border-radius:10px;padding:12px 16px;font-size:13px;color:#8a6a10;margin-bottom:20px;}
9434 .err-panel{background:rgba(180,40,40,0.08);border:1px solid rgba(180,40,40,0.25);border-radius:10px;padding:14px 18px;margin-bottom:20px;}
9435 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
9436 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
9437 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
9438 .btn-primary{display:inline-flex;align-items:center;gap:8px;padding:10px 22px;border-radius:999px;background:linear-gradient(135deg,var(--oxide),var(--nav-2));color:#fff;font-size:13px;font-weight:700;text-decoration:none;border:none;cursor:pointer;transition:transform .15s,box-shadow .15s;box-shadow:0 4px 12px rgba(185,93,51,0.3);}
9439 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
9440 .btn-outline{display:inline-flex;align-items:center;gap:8px;padding:10px 22px;border-radius:999px;background:transparent;color:var(--nav);border:2px solid var(--nav);font-size:13px;font-weight:700;text-decoration:none;cursor:pointer;transition:background .15s,transform .15s;}
9441 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
9442 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
9443 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
9444 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
9445 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
9446 .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;}
9447 @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));}}
9448 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
9449 .site-footer a{color:var(--muted);}
9450 .theme-toggle{width:38px;height:38px;justify-content:center;padding:0;cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;display:inline-flex;align-items:center;}
9451 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
9452 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
9453 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
9454 </style>
9455</head>
9456<body>
9457 <div class="background-watermarks" aria-hidden="true">
9458 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9459 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9460 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9461 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9462 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9463 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9464 </div>
9465 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
9466 <nav class="top-nav">
9467 <div class="top-nav-inner">
9468 <a href="/" class="brand">
9469 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
9470 <div class="brand-copy">
9471 <h1 class="brand-title">OxideSLOC</h1>
9472 <div class="brand-subtitle">Source Line Analysis</div>
9473 </div>
9474 </a>
9475 <div class="nav-right">
9476 <a href="/view-reports" class="nav-pill">View Reports</a>
9477 <a href="/scan" class="nav-pill">New Scan</a>
9478 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
9479 <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>
9480 <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>
9481 </button>
9482 </div>
9483 </div>
9484 </nav>
9485 <div class="page-body">
9486 <div class="wait-panel">
9487 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
9488 <h2 class="wait-title">Analyzing your project…</h2>
9489 <p class="wait-sub">This may take a few minutes for large repositories. You can leave this page — results are saved automatically.</p>
9490 <div class="path-block">{{ project_path }}</div>
9491 <div class="metrics-row">
9492 <div class="metric-card">
9493 <div class="metric-label">Elapsed</div>
9494 <div class="metric-value" id="elapsed">0s</div>
9495 </div>
9496 <div class="metric-card">
9497 <div class="metric-label">Phase</div>
9498 <div class="metric-value" id="phase">Starting</div>
9499 </div>
9500 </div>
9501 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
9502 <div class="warn-slow hidden" id="warn-slow">
9503 This is taking longer than usual. Large repositories with many files can take several minutes. Hang tight — the analysis is still running in the background.
9504 </div>
9505 <div class="err-panel hidden" id="err-panel">
9506 <strong>Analysis failed</strong>
9507 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
9508 </div>
9509 <div class="actions hidden" id="actions">
9510 <a href="/scan" class="btn-primary">Try Again</a>
9511 <a href="/view-reports" class="btn-outline">View Reports</a>
9512 </div>
9513 </div>
9514 </div>
9515 <script nonce="{{ csp_nonce }}">
9516 (function() {
9517 var WAIT_ID = {{ wait_id_json|safe }};
9518 var startTime = Date.now();
9519 var pollInterval = 1500;
9520 var retries = 0;
9521 var maxRetries = 5;
9522 var warnShown = false;
9523
9524 function elapsed() {
9525 return Math.floor((Date.now() - startTime) / 1000);
9526 }
9527
9528 function updateElapsed() {
9529 var s = elapsed();
9530 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
9531 }
9532
9533 function setPhase(txt) {
9534 document.getElementById('phase').textContent = txt;
9535 }
9536
9537 var elapsedTimer = setInterval(updateElapsed, 1000);
9538
9539 function poll() {
9540 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
9541 .then(function(r) {
9542 if (!r.ok) throw new Error('HTTP ' + r.status);
9543 return r.json();
9544 })
9545 .then(function(data) {
9546 retries = 0;
9547 if (data.state === 'complete') {
9548 clearInterval(elapsedTimer);
9549 setPhase('Done');
9550 window.location.href = '/runs/' + encodeURIComponent(data.run_id) + '/result';
9551 } else if (data.state === 'failed') {
9552 clearInterval(elapsedTimer);
9553 setPhase('Failed');
9554 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
9555 document.getElementById('err-panel').classList.remove('hidden');
9556 document.getElementById('actions').classList.remove('hidden');
9557 } else {
9558 // still running
9559 var s = elapsed();
9560 if (s > 90 && !warnShown) {
9561 warnShown = true;
9562 document.getElementById('warn-slow').classList.remove('hidden');
9563 }
9564 setPhase(s < 10 ? 'Starting' : s < 30 ? 'Scanning files' : 'Analyzing');
9565 setTimeout(poll, pollInterval);
9566 }
9567 })
9568 .catch(function(err) {
9569 retries++;
9570 if (retries >= maxRetries) {
9571 clearInterval(elapsedTimer);
9572 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
9573 document.getElementById('err-panel').classList.remove('hidden');
9574 document.getElementById('actions').classList.remove('hidden');
9575 } else {
9576 // exponential back-off capped at 8s
9577 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
9578 }
9579 });
9580 }
9581
9582 setTimeout(poll, pollInterval);
9583 })();
9584 </script>
9585 <footer class="site-footer">
9586 oxide-sloc v{{ version }} — local source line analysis workbench ·
9587 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
9588 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
9589 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
9590 </footer>
9591 <script nonce="{{ csp_nonce }}">
9592 (function(){
9593 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
9594 if(s==="dark")b.classList.add("dark-theme");
9595 var tt=document.getElementById("theme-toggle");
9596 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
9597 })();
9598 (function spawnCodeParticles(){
9599 var c=document.getElementById('code-particles');if(!c)return;
9600 var sn=['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()','sloc_core','render_html','2,163 code'];
9601 for(var i=0;i<32;i++){(function(idx){
9602 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
9603 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
9604 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
9605 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
9606 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
9607 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
9608 c.appendChild(el);
9609 })(i);}
9610 })();
9611 (function randomizeWatermarks(){
9612 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
9613 var placed=[];
9614 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;}
9615 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];}
9616 var half=Math.floor(wms.length/2);
9617 wms.forEach(function(img,i){
9618 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
9619 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
9620 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
9621 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
9622 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
9623 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
9624 });
9625 })();
9626 </script>
9627</body>
9628</html>
9629"##,
9630 ext = "html"
9631)]
9632struct ScanWaitTemplate {
9633 version: &'static str,
9634 wait_id_json: String,
9635 project_path: String,
9636 csp_nonce: String,
9637}
9638
9639#[derive(Template)]
9640#[template(
9641 source = r##"
9642<!doctype html>
9643<html lang="en">
9644<head>
9645 <meta charset="utf-8">
9646 <meta name="viewport" content="width=device-width, initial-scale=1">
9647 <title>OxideSLOC | Error</title>
9648 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9649 <style nonce="{{ csp_nonce }}">
9650 :root {
9651 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
9652 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
9653 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#4a78ee;
9654 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
9655 }
9656 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
9657 *{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);}
9658 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
9659 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
9660 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
9661 .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);}
9662 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
9663 .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));}
9664 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
9665 .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;}
9666 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
9667 .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;}
9668 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
9669 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
9670 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
9671 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
9672 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
9673 .page{max-width:1720px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
9674 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
9675 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
9676 .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;}
9677 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
9678 .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);}
9679 .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;}
9680 .btn-secondary:hover{background:var(--line);}
9681 .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;}
9682 .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;}
9683 .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;}
9684 @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));}}
9685 .nav-dropdown{position:relative;display:inline-flex;}.nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}.nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}.nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}.nav-dropdown-menu a:last-child{border-bottom:none;}.nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}.nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
9686 </style>
9687</head>
9688<body>
9689 <div class="background-watermarks" aria-hidden="true">
9690 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9691 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9692 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9693 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9694 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9695 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9696 </div>
9697 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
9698 <div class="top-nav">
9699 <div class="top-nav-inner">
9700 <a class="brand" href="/">
9701 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
9702 <div class="brand-copy">
9703 <div class="brand-title">OxideSLOC</div>
9704 <div class="brand-subtitle">Local analysis workbench</div>
9705 </div>
9706 </a>
9707 <div class="nav-right">
9708 <a class="nav-pill" href="/">Home</a>
9709 <a class="nav-pill" href="/view-reports">View Reports</a>
9710 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
9711 <div class="nav-dropdown">
9712 <button class="nav-dropdown-btn" type="button">Git Tools <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
9713 <div class="nav-dropdown-menu">
9714 <a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
9715 <a href="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Webhooks</a>
9716 </div>
9717 </div>
9718 <div class="server-status-wrap">
9719 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
9720 <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>
9721 </div>
9722 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
9723 <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>
9724 <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>
9725 </button>
9726 </div>
9727 </div>
9728 </div>
9729
9730 <div class="page">
9731 <div class="panel">
9732 <h1>Analysis failed</h1>
9733 <div class="error-box">{{ message }}</div>
9734 <div class="actions">
9735 <a class="btn-primary" href="/scan">Back to setup</a>
9736 {% if let Some(report_url) = last_report_url %}
9737 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
9738 {% endif %}
9739 <a class="btn-secondary" href="/view-reports">View Reports</a>
9740 </div>
9741 </div>
9742 </div>
9743 <script nonce="{{ csp_nonce }}">
9744 (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");});})();
9745 (function spawnCodeParticles() {
9746 var container = document.getElementById('code-particles');
9747 if (!container) return;
9748 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'];
9749 for (var i = 0; i < 38; i++) {
9750 (function(idx) {
9751 var el = document.createElement('span');
9752 el.className = 'code-particle';
9753 el.textContent = snippets[idx % snippets.length];
9754 var left = Math.random() * 94 + 2;
9755 var top = Math.random() * 88 + 6;
9756 var dur = (Math.random() * 10 + 9).toFixed(1);
9757 var delay = (Math.random() * 18).toFixed(1);
9758 var rot = (Math.random() * 26 - 13).toFixed(1);
9759 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
9760 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
9761 container.appendChild(el);
9762 })(i);
9763 }
9764 })();
9765 (function randomizeWatermarks() {
9766 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
9767 var placed = [];
9768 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; }
9769 function pick(leftBand) { for (var a = 0; a < 50; a++) { var t=Math.random()*88+2, l=leftBand?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=leftBand?Math.random()*24+1:Math.random()*24+74; placed.push([t,l]); return [t,l]; }
9770 var half = Math.floor(wms.length/2);
9771 wms.forEach(function(img, i) {
9772 var pos = pick(i < half);
9773 var w = Math.floor(Math.random()*60+80);
9774 var rot = (Math.random()*40-20).toFixed(1);
9775 var op = (Math.random()*0.08+0.05).toFixed(2);
9776 var animDur = (Math.random()*6+5).toFixed(1);
9777 var animDelay = (Math.random()*10).toFixed(1);
9778 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;img.style.animation='wmFade '+animDur+'s ease-in-out -'+animDelay+'s infinite alternate';
9779 });
9780 })();
9781 </script>
9782</body>
9783</html>
9784"##,
9785 ext = "html"
9786)]
9787struct ErrorTemplate {
9788 message: String,
9789 last_report_url: Option<String>,
9791 last_report_label: Option<String>,
9793 csp_nonce: String,
9794}
9795
9796#[derive(Template)]
9799#[template(
9800 source = r##"
9801<!doctype html>
9802<html lang="en">
9803<head>
9804 <meta charset="utf-8">
9805 <meta name="viewport" content="width=device-width, initial-scale=1">
9806 <title>OxideSLOC | View Reports</title>
9807 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9808 <style nonce="{{ csp_nonce }}">
9809 :root {
9810 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
9811 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
9812 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
9813 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
9814 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
9815 }
9816 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e; }
9817 *{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);}
9818 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
9819 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
9820 .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);}
9821 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
9822 .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));}
9823 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
9824 .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;}
9825 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
9826 .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;}
9827 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
9828 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
9829 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
9830 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
9831 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
9832 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
9833 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
9834 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
9835 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
9836 .panel-meta{font-size:13px;color:var(--muted);}
9837 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
9838 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
9839 .per-page-label{font-size:13px;color:var(--muted);}
9840 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;}
9841 .filter-input{min-width:180px;cursor:text;}
9842 .table-wrap{width:100%;overflow-x:auto;}
9843 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
9844 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;}
9845 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
9846 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
9847 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
9848 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
9849 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
9850 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
9851 tr:last-child td{border-bottom:none;}
9852 tr:hover td{background:var(--surface-2);}
9853 .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);}
9854 .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);}
9855 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
9856 .metric-num{font-weight:700;color:var(--text);}
9857 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
9858 .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;}
9859 .btn:hover{background:var(--line);}
9860 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
9861 .btn.primary:hover{opacity:.9;}
9862 .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;}
9863 .btn-back:hover{background:var(--line);}
9864 .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;}
9865 .export-btn:hover{background:var(--line);}
9866 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
9867 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
9868 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
9869 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
9870 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
9871 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
9872 .pagination-info{font-size:13px;color:var(--muted);}
9873 .pagination-btns{display:flex;gap:6px;}
9874 .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;}
9875 .pg-btn:hover:not(:disabled){background:var(--line);}
9876 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
9877 .pg-btn:disabled{opacity:.35;cursor:default;}
9878 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
9879 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
9880 .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;}
9881 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
9882 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
9883 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
9884 .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);}
9885 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
9886 .stat-chip:hover .stat-chip-tip{opacity:1;}
9887 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
9888 .site-footer a{color:var(--muted);}
9889 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
9890 .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%;}
9891 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
9892 .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;}
9893 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
9894 .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;}
9895 .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;}
9896 .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;}
9897 @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));}}
9898 .nav-dropdown{position:relative;display:inline-flex;}.nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}.nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}.nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}.nav-dropdown-menu a:last-child{border-bottom:none;}.nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}.nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
9899 .vr-toolbar{display:grid;grid-template-columns:1fr auto;gap:6px 20px;margin-bottom:14px;align-items:center;}
9900 .vr-filters{grid-column:1;grid-row:2;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
9901 .vr-hint{grid-column:2;grid-row:1;text-align:right;margin:0;font-size:13px;color:var(--muted);white-space:nowrap;}
9902 .vr-browse{grid-column:2;grid-row:2;justify-self:end;white-space:nowrap;}
9903 .rpt-btn{min-width:58px;justify-content:center;}
9904 .flex-row{display:flex;align-items:center;gap:8px;}
9905 .report-cell{overflow:visible;white-space:normal;}
9906 #history-table col:nth-child(1){width:185px;}
9907 #history-table col:nth-child(2){width:220px;}
9908 #history-table col:nth-child(3){width:100px;}
9909 #history-table col:nth-child(4){width:72px;}
9910 #history-table col:nth-child(5){width:82px;}
9911 #history-table col:nth-child(6){width:82px;}
9912 #history-table col:nth-child(7){width:65px;}
9913 #history-table col:nth-child(8){width:90px;}
9914 #history-table col:nth-child(9){width:85px;}
9915 #history-table col:nth-child(10){width:115px;}
9916 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
9917 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
9918 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
9919 .submod-details summary::-webkit-details-marker{display:none;}
9920.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
9921 .submod-view-btn{display:inline-flex;padding:2px 8px;border-radius:5px;font-size:11px;font-weight:700;background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.22);color:var(--accent-2);text-decoration:none;white-space:nowrap;}
9922 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
9923 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
9924 </style>
9925</head>
9926<body>
9927 <div class="background-watermarks" aria-hidden="true">
9928 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9929 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9930 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9931 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9932 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9933 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9934 </div>
9935 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
9936 <div class="top-nav">
9937 <div class="top-nav-inner">
9938 <a class="brand" href="/">
9939 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
9940 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
9941 </a>
9942 <div class="nav-right">
9943 <a class="nav-pill" href="/">Home</a>
9944 <a class="nav-pill" href="/view-reports">View Reports</a>
9945 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
9946 <div class="nav-dropdown">
9947 <button class="nav-dropdown-btn" type="button">Git Tools <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
9948 <div class="nav-dropdown-menu">
9949 <a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
9950 <a href="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Webhooks</a>
9951 </div>
9952 </div>
9953 <div class="server-status-wrap">
9954 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
9955 <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>
9956 </div>
9957 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
9958 <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>
9959 <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>
9960 </button>
9961 </div>
9962 </div>
9963 </div>
9964
9965 <div class="page">
9966 {% if linked_count > 0 %}
9967 <div class="toast-success">
9968 <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>
9969 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
9970 </div>
9971 {% endif %}
9972 {% if total_scans > 0 %}
9973 <div class="summary-strip">
9974 <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>
9975 <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>
9976 <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>
9977 <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>
9978 </div>
9979 {% endif %}
9980
9981 <section class="panel">
9982 <div class="panel-header">
9983 <div>
9984 <h1>View Reports</h1>
9985 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
9986 </div>
9987 <div class="flex-row">
9988 <button type="button" class="export-btn" id="export-csv-btn">
9989 <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>
9990 Export CSV
9991 </button>
9992 <button type="button" class="export-btn" id="export-xls-btn">
9993 <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>
9994 Export Excel
9995 </button>
9996 <a class="btn-back" href="/">
9997 <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>
9998 Home
9999 </a>
10000 </div>
10001 </div>
10002
10003 <div class="vr-toolbar">
10004 <div class="vr-filters">
10005 {% if !entries.is_empty() %}
10006 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
10007 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
10008 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
10009 {% endif %}
10010 </div>
10011 <p class="vr-hint">Have reports saved on disk? Select a folder to load them into the list.</p>
10012 <button type="button" class="btn vr-browse" id="browse-report-btn">
10013 <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>
10014 Browse for Reports…
10015 </button>
10016 </div>
10017
10018 {% if entries.is_empty() %}
10019 <div class="empty-state">
10020 <strong>No reports with viewable HTML yet</strong>
10021 Run a new analysis from the <a href="/scan">scan page</a>, or use the browse button above to link an existing report.
10022 </div>
10023 {% else %}
10024 <div class="table-wrap">
10025 <table id="history-table">
10026 <colgroup>
10027 <col><col><col><col><col><col><col><col><col><col>
10028 </colgroup>
10029 <thead>
10030 <tr id="history-thead">
10031 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
10032 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
10033 <th>Run ID<div class="col-resize-handle"></div></th>
10034 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
10035 <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>
10036 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
10037 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
10038 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
10039 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
10040 <th>Report<div class="col-resize-handle"></div></th>
10041 </tr>
10042 </thead>
10043 <tbody id="history-tbody">
10044 {% for entry in entries %}
10045 <tr class="history-row" data-run="{{ entry.run_id }}"
10046 data-timestamp="{{ entry.timestamp }}"
10047 data-project="{{ entry.project_label }}"
10048 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
10049 data-skipped="{{ entry.files_skipped }}"
10050 data-comments="{{ entry.comment_lines }}"
10051 data-blank="{{ entry.blank_lines }}"
10052 data-branch="{{ entry.git_branch }}"
10053 data-commit="{{ entry.git_commit }}"
10054 data-html-url="/runs/{{ entry.run_id }}/html">
10055 <td>{{ entry.timestamp }}</td>
10056 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
10057 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
10058 <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
10059 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
10060 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
10061 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
10062 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
10063 <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>
10064 <td class="report-cell">
10065 <div class="actions-cell">
10066 <a class="btn primary rpt-btn" href="/runs/{{ entry.run_id }}/html" target="_blank" rel="noopener" title="View HTML report">View</a>
10067 {% if entry.has_pdf %}<a class="btn primary rpt-btn" href="/runs/{{ entry.run_id }}/pdf" target="_blank" rel="noopener" title="View PDF report">PDF</a>{% endif %}
10068 </div>
10069 {% if !entry.submodule_links.is_empty() %}
10070 <details class="submod-details">
10071 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
10072 <div class="submod-link-list">
10073 {% for sub in entry.submodule_links %}
10074 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
10075 {% endfor %}
10076 </div>
10077 </details>
10078 {% endif %}
10079 </td>
10080 </tr>
10081 {% endfor %}
10082 </tbody>
10083 </table>
10084 </div>
10085 <div class="pagination">
10086 <span class="pagination-info" id="pagination-info"></span>
10087 <div class="pagination-btns" id="pagination-btns"></div>
10088 <div class="flex-row">
10089 <span class="per-page-label">Show</span>
10090 <select class="per-page" id="per-page-sel">
10091 <option value="10">10 per page</option>
10092 <option value="25" selected>25 per page</option>
10093 <option value="50">50 per page</option>
10094 <option value="100">100 per page</option>
10095 </select>
10096 <span class="per-page-label" id="page-range-label"></span>
10097 </div>
10098 </div>
10099 {% endif %}
10100 </section>
10101 </div>
10102
10103 <footer class="site-footer">
10104 oxide-sloc v{{ version }} — local source line analysis workbench ·
10105 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10106 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10107 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10108 </footer>
10109
10110 <script nonce="{{ csp_nonce }}">
10111 (function () {
10112 // ── Theme ──────────────────────────────────────────────────────────────
10113 var storageKey = 'oxide-sloc-theme';
10114 var body = document.body;
10115 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
10116 var toggle = document.getElementById('theme-toggle');
10117 if (toggle) toggle.addEventListener('click', function () {
10118 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
10119 body.classList.toggle('dark-theme', next === 'dark');
10120 try { localStorage.setItem(storageKey, next); } catch(e) {}
10121 });
10122
10123 // ── State ─────────────────────────────────────────────────────────────
10124 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
10125 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
10126 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
10127
10128 // Aggregate stats from first (most recent) row
10129 if (allRows.length) {
10130 var first = allRows[0];
10131 var ce = document.getElementById('agg-code'); if (ce) ce.textContent = Number(first.dataset.code).toLocaleString();
10132 var fe = document.getElementById('agg-files'); if (fe) fe.textContent = first.dataset.files;
10133 var se = document.getElementById('agg-skipped'); if (se) se.textContent = first.dataset.skipped;
10134 }
10135
10136 // ── Branch filter population ──────────────────────────────────────────
10137 (function() {
10138 var branches = {};
10139 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
10140 var sel = document.getElementById('branch-filter');
10141 if (sel) Object.keys(branches).sort().forEach(function(b) {
10142 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
10143 });
10144 })();
10145
10146 // ── Filter ────────────────────────────────────────────────────────────
10147 function getFilteredRows() {
10148 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
10149 var branch = ((document.getElementById('branch-filter') || {}).value || '');
10150 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
10151 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
10152 if (branch && (r.dataset.branch || '') !== branch) return false;
10153 return true;
10154 });
10155 }
10156
10157 // ── Pagination ────────────────────────────────────────────────────────
10158 function renderPage() {
10159 var filtered = getFilteredRows();
10160 var total = filtered.length;
10161 var totalPages = Math.max(1, Math.ceil(total / perPage));
10162 currentPage = Math.min(currentPage, totalPages);
10163 var start = (currentPage - 1) * perPage;
10164 var end = Math.min(start + perPage, total);
10165 var shown = {};
10166 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
10167 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
10168 r.style.display = shown[r.dataset.run] ? '' : 'none';
10169 });
10170 var rl = document.getElementById('page-range-label');
10171 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
10172 var info = document.getElementById('pagination-info');
10173 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
10174 var btns = document.getElementById('pagination-btns');
10175 if (!btns) return;
10176 btns.innerHTML = '';
10177 function makeBtn(lbl, pg, active, disabled) {
10178 var b = document.createElement('button');
10179 b.className = 'pg-btn' + (active ? ' active' : '');
10180 b.textContent = lbl; b.disabled = disabled;
10181 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
10182 return b;
10183 }
10184 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
10185 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
10186 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
10187 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
10188 }
10189
10190 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
10191 window.applyFilters = function() { currentPage = 1; renderPage(); };
10192
10193 // ── Sorting ───────────────────────────────────────────────────────────
10194 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
10195 function doSort(col, type, order) {
10196 var tbody = document.getElementById('history-tbody');
10197 if (!tbody) return;
10198 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
10199 rows.sort(function(a, b) {
10200 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
10201 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
10202 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
10203 return va < vb ? 1 : va > vb ? -1 : 0;
10204 });
10205 rows.forEach(function(r) { tbody.appendChild(r); });
10206 currentPage = 1; renderPage();
10207 }
10208 sortHeaders.forEach(function(th) {
10209 th.addEventListener('click', function(e) {
10210 if (e.target.classList.contains('col-resize-handle')) return;
10211 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
10212 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
10213 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
10214 th.classList.add('sort-' + sortOrder);
10215 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
10216 doSort(col, type, sortOrder);
10217 });
10218 });
10219
10220 // ── Column resize ─────────────────────────────────────────────────────
10221 (function() {
10222 var table = document.getElementById('history-table');
10223 if (!table) return;
10224 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
10225 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
10226 ths.forEach(function(th, i) {
10227 var handle = th.querySelector('.col-resize-handle');
10228 if (!handle || !cols[i]) return;
10229 var startX, startW;
10230 handle.addEventListener('mousedown', function(e) {
10231 e.stopPropagation(); e.preventDefault();
10232 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
10233 handle.classList.add('dragging');
10234 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
10235 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
10236 document.addEventListener('mousemove', onMove);
10237 document.addEventListener('mouseup', onUp);
10238 });
10239 });
10240 })();
10241
10242 // ── Reset view ────────────────────────────────────────────────────────
10243 window.resetView = function() {
10244 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
10245 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
10246 sortCol = null; sortOrder = 'asc';
10247 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
10248 var tbody = document.getElementById('history-tbody');
10249 if (tbody) {
10250 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
10251 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
10252 rows.forEach(function(r) { tbody.appendChild(r); });
10253 }
10254 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
10255 var table = document.getElementById('history-table');
10256 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
10257 currentPage = 1; renderPage();
10258 };
10259
10260 renderPage();
10261
10262 // ── Export helpers ────────────────────────────────────────────────────
10263 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
10264 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
10265 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);}
10266 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;');}
10267 function slocXlsx(fname,sheet,hdrs,rows){
10268 var enc=new TextEncoder();
10269 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;}
10270 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;}
10271 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
10272 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
10273 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
10274 function colRef(c,r){var s='',n=c+1;while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s+r;}
10275 var ss=[],si={};function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
10276 var rx='<row r="1">';
10277 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
10278 rx+='</row>';
10279 rows.forEach(function(row,ri){var rn=ri+2;rx+='<row r="'+rn+'">';row.forEach(function(cell,c){var ref=colRef(c,rn),num=cell!==''&&cell!=null&&!isNaN(Number(cell))&&isFinite(Number(cell))&&/^[+\-]?\d/.test(String(cell));rx+=num?'<c r="'+ref+'"><v>'+xe(cell)+'</v></c>':'<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';});rx+='</row>';});
10280 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
10281 var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'"><sheetViews><sheetView workbookViewId="0"/></sheetViews><sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData></worksheet>';
10282 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><sst xmlns="'+sns+'" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
10283 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><sz val="11"/><b/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></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="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>';
10284 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/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>',
10285 '_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>',
10286 'xl/workbook.xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets><sheet name="'+xe(sheet)+'" sheetId="1" r:id="rId1"/></sheets></workbook>',
10287 '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/styles" Target="styles.xml"/><Relationship Id="rId3" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/></Relationships>',
10288 'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
10289 var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml'];
10290 var zparts=[],zcds=[],zoff=0,znf=0;
10291 order.forEach(function(name){
10292 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
10293 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]);
10294 var entry=new Uint8Array(lha.length+nb.length+sz);
10295 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
10296 zparts.push(entry);
10297 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));
10298 var cde=new Uint8Array(cda.length+nb.length);
10299 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
10300 zcds.push(cde);zoff+=entry.length;znf++;
10301 });
10302 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
10303 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]);
10304 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
10305 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
10306 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
10307 zout.set(new Uint8Array(ea),zpos);
10308 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
10309 }
10310
10311 var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
10312 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;}
10313 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
10314 window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
10315
10316 var csvBtn = document.getElementById('export-csv-btn');
10317 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
10318 var xlsBtn = document.getElementById('export-xls-btn');
10319 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
10320
10321 // ── Remaining CSP-safe event bindings ────────────────────────────────
10322 (function wireEvents() {
10323 var el;
10324 el = document.getElementById('reset-view-btn');
10325 if (el) el.addEventListener('click', window.resetView);
10326 el = document.getElementById('project-filter');
10327 if (el) el.addEventListener('input', window.applyFilters);
10328 el = document.getElementById('branch-filter');
10329 if (el) el.addEventListener('change', window.applyFilters);
10330 el = document.getElementById('per-page-sel');
10331 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
10332 el = document.getElementById('browse-report-btn');
10333 if (el) el.addEventListener('click', function() {
10334 fetch('/pick-directory?kind=reports')
10335 .then(function(r) { return r.json(); })
10336 .then(function(data) {
10337 if (!data.cancelled && data.selected_path) {
10338 var form = document.createElement('form');
10339 form.method = 'POST';
10340 form.action = '/locate-reports-dir';
10341 var input = document.createElement('input');
10342 input.type = 'hidden';
10343 input.name = 'folder_path';
10344 input.value = data.selected_path;
10345 form.appendChild(input);
10346 document.body.appendChild(form);
10347 form.submit();
10348 }
10349 })
10350 .catch(function(e) { alert('Could not open folder picker: ' + e); });
10351 });
10352 })();
10353
10354 (function randomizeWatermarks() {
10355 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10356 if (!wms.length) return;
10357 var placed = [];
10358 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;}
10359 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];}
10360 var half=Math.floor(wms.length/2);
10361 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.width=sz+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;});
10362 })();
10363
10364 (function spawnCodeParticles() {
10365 var container = document.getElementById('code-particles');
10366 if (!container) return;
10367 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'];
10368 for (var i = 0; i < 38; i++) {
10369 (function(idx) {
10370 var el = document.createElement('span');
10371 el.className = 'code-particle';
10372 el.textContent = snippets[idx % snippets.length];
10373 var left = Math.random() * 94 + 2;
10374 var top = Math.random() * 88 + 6;
10375 var dur = (Math.random() * 10 + 9).toFixed(1);
10376 var delay = (Math.random() * 18).toFixed(1);
10377 var rot = (Math.random() * 26 - 13).toFixed(1);
10378 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
10379 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
10380 container.appendChild(el);
10381 })(i);
10382 }
10383 })();
10384 })();
10385 </script>
10386</body>
10387</html>
10388"##,
10389 ext = "html"
10390)]
10391struct HistoryTemplate {
10392 version: &'static str,
10393 entries: Vec<HistoryEntryRow>,
10394 total_scans: usize,
10395 linked_count: usize,
10396 csp_nonce: String,
10397}
10398
10399#[derive(Template)]
10402#[template(
10403 source = r##"
10404<!doctype html>
10405<html lang="en">
10406<head>
10407 <meta charset="utf-8">
10408 <meta name="viewport" content="width=device-width, initial-scale=1">
10409 <title>OxideSLOC | Compare Scans</title>
10410 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10411 <style nonce="{{ csp_nonce }}">
10412 :root {
10413 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
10414 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
10415 --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
10416 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
10417 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
10418 }
10419 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
10420 *{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);}
10421 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
10422 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
10423 .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);}
10424 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
10425 .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));}
10426 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
10427 .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;}
10428 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
10429 .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;}
10430 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
10431 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
10432 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
10433 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
10434 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
10435 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
10436 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
10437 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
10438 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
10439 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
10440 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
10441 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
10442 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
10443 .per-page-label{font-size:13px;color:var(--muted);}
10444 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;}
10445 .filter-input{min-width:180px;cursor:text;}
10446 .table-wrap{width:100%;overflow-x:auto;}
10447 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
10448 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;}
10449 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--accent-2);}
10450 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
10451 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--accent-2);}
10452 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
10453 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(111,155,255,0.3);}
10454 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
10455 tr:last-child td{border-bottom:none;}
10456 tr.selected td{background:var(--sel-bg);}
10457 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
10458 tr:hover:not(.selected) td{background:var(--surface-2);}
10459 tr{cursor:pointer;}
10460 .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);}
10461 .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);}
10462 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
10463 .metric-num{font-weight:700;}
10464 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
10465 .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;}
10466 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
10467 #compare-table td:nth-child(3){white-space:normal;word-break:break-word;overflow:visible;}
10468 .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;}
10469 .btn:hover{background:var(--line);}
10470 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
10471 .btn.primary:hover{opacity:.9;}
10472 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
10473 .filter-row{display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap;}
10474 .filter-row>*{height:30px;box-sizing:border-box;}
10475 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
10476 .submod-overflow-badge{display:inline-flex;align-items:center;font-size:10px;font-weight:700;padding:2px 6px;border-radius:5px;background:var(--surface);border:1px solid var(--line-strong);color:var(--muted);white-space:nowrap;}
10477 .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;}
10478 .btn-back:hover{background:var(--line);}
10479 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
10480 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
10481 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
10482 .pagination-info{font-size:13px;color:var(--muted);}
10483 .pagination-btns{display:flex;gap:6px;}
10484 .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;}
10485 .pg-btn:hover:not(:disabled){background:var(--line);}
10486 .pg-btn.active{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
10487 .pg-btn:disabled{opacity:.35;cursor:default;}
10488 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
10489 .site-footer a{color:var(--muted);}
10490 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
10491 .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;}
10492 .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;}
10493 .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;}
10494 @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));}}
10495 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
10496 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
10497 .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;}
10498 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
10499 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
10500 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
10501 .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);}
10502 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
10503 .stat-chip:hover .stat-chip-tip{opacity:1;}
10504 .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;}
10505 .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%;}
10506 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
10507 .submod-chip{display:inline-flex;align-items:center;font-size:10px;font-weight:700;padding:2px 7px;border-radius:5px;background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.25);color:var(--accent-2);margin:1px 2px 1px 0;white-space:nowrap;}
10508 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
10509 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
10510 .hidden{display:none!important;}
10511 .scope-panel{background:rgba(111,155,255,0.06);border:1.5px solid rgba(111,155,255,0.28);border-radius:12px;padding:12px 16px;margin-bottom:14px;animation:fadeIn .15s ease;}
10512 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
10513 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
10514 .scope-panel-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:10px;display:flex;align-items:center;gap:6px;}
10515 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
10516 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
10517 .scope-option{display:inline-flex;align-items:center;gap:7px;padding:6px 14px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface);cursor:pointer;font-size:12px;font-weight:700;color:var(--text);transition:border-color .12s,background .12s,color .12s;user-select:none;}
10518 .scope-option:hover{background:var(--line);}
10519 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
10520 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
10521 .scope-option-radio{width:13px;height:13px;border-radius:50%;border:1.5px solid var(--line-strong);background:var(--surface-2);flex:0 0 auto;position:relative;transition:border-color .12s;}
10522 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
10523 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
10524 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
10525 .nav-dropdown{position:relative;display:inline-flex;}.nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}.nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}.nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}.nav-dropdown-menu a:last-child{border-bottom:none;}.nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}.nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
10526 </style>
10527</head>
10528<body>
10529 <div class="background-watermarks" aria-hidden="true">
10530 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10531 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10532 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10533 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10534 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10535 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10536 </div>
10537 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10538 <div class="top-nav">
10539 <div class="top-nav-inner">
10540 <a class="brand" href="/">
10541 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10542 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
10543 </a>
10544 <div class="nav-right">
10545 <a class="nav-pill" href="/">Home</a>
10546 <a class="nav-pill" href="/view-reports">View Reports</a>
10547 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
10548 <div class="nav-dropdown">
10549 <button class="nav-dropdown-btn" type="button">Git Tools <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
10550 <div class="nav-dropdown-menu">
10551 <a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
10552 <a href="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Webhooks</a>
10553 </div>
10554 </div>
10555 <div class="server-status-wrap">
10556 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
10557 <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>
10558 </div>
10559 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10560 <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>
10561 <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>
10562 </button>
10563 </div>
10564 </div>
10565 </div>
10566
10567 <div class="page">
10568 {% if total_scans > 0 %}
10569 <div class="summary-strip">
10570 <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>
10571 <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>
10572 <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>
10573 <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>
10574 </div>
10575 {% endif %}
10576 <section class="panel">
10577 <div class="panel-header">
10578 <div>
10579 <h1>Compare Scans</h1>
10580 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
10581 </div>
10582 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
10583 <button class="btn primary" id="compare-btn" disabled>
10584 <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>
10585 Compare <span class="sel-count" id="sel-count">0/2</span>
10586 </button>
10587 <a class="btn-back" href="/">
10588 <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>
10589 Home
10590 </a>
10591 </div>
10592 </div>
10593
10594 {% if entries.is_empty() %}
10595 <div class="empty-state">
10596 <strong>No scans yet</strong>
10597 Run your first analysis from the <a href="/scan">scan page</a>.
10598 </div>
10599 {% else %}
10600 <div class="instruction-bar">
10601 <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>
10602 Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
10603 </div>
10604 <div class="filter-row">
10605 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
10606 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
10607 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
10608 </div>
10609 <div class="scope-panel hidden" id="scope-panel">
10610 <div class="scope-panel-label">
10611 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"></path></svg>
10612 Compare scope — choose what to include
10613 </div>
10614 <div class="scope-options" id="scope-options"></div>
10615 </div>
10616 <div class="table-wrap">
10617 <table id="compare-table">
10618 <colgroup>
10619 <col style="width:3%">
10620 <col style="width:12%">
10621 <col style="width:13%">
10622 <col style="width:9%">
10623 <col style="width:6%">
10624 <col style="width:9%">
10625 <col style="width:8%">
10626 <col style="width:6%">
10627 <col style="width:8%">
10628 <col style="width:14%">
10629 <col style="width:12%">
10630 </colgroup>
10631 <thead>
10632 <tr id="compare-thead">
10633 <th style="text-align:center;padding-left:4px;padding-right:4px;"><div class="col-resize-handle"></div></th>
10634 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
10635 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
10636 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
10637 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
10638 <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>
10639 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
10640 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
10641 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
10642 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
10643 <th>Submodules<div class="col-resize-handle"></div></th>
10644 </tr>
10645 </thead>
10646 <tbody id="compare-tbody">
10647 {% for entry in entries %}
10648 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
10649 data-timestamp="{{ entry.timestamp }}"
10650 data-project="{{ entry.project_label }}"
10651 data-files="{{ entry.files_analyzed }}"
10652 data-code="{{ entry.code_lines }}"
10653 data-comments="{{ entry.comment_lines }}"
10654 data-blank="{{ entry.blank_lines }}"
10655 data-branch="{{ entry.git_branch }}"
10656 data-commit="{{ entry.git_commit }}"
10657 data-submodules="{{ entry.submodule_names_csv }}">
10658 <td style="text-align:center;padding-left:4px;padding-right:4px;"><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
10659 <td>{{ entry.timestamp }}</td>
10660 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
10661 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
10662 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
10663 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
10664 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
10665 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
10666 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
10667 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
10668 <td style="white-space:normal;vertical-align:middle;">{% if !entry.submodule_links.is_empty() %}<div class="submod-chips-cell">{% for sub in entry.submodule_links %}<span class="submod-chip">{{ sub.name }}</span>{% endfor %}</div>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
10669 </tr>
10670 {% endfor %}
10671 </tbody>
10672 </table>
10673 </div>
10674 <div class="pagination">
10675 <span class="pagination-info" id="pagination-info"></span>
10676 <div class="pagination-btns" id="pagination-btns"></div>
10677 <div class="flex-row">
10678 <span class="per-page-label">Show</span>
10679 <select class="per-page" id="per-page-sel">
10680 <option value="10">10 per page</option>
10681 <option value="25" selected>25 per page</option>
10682 <option value="50">50 per page</option>
10683 <option value="100">100 per page</option>
10684 </select>
10685 <span class="per-page-label" id="page-range-label"></span>
10686 </div>
10687 </div>
10688 {% endif %}
10689 </section>
10690 </div>
10691
10692 <footer class="site-footer">
10693 oxide-sloc v{{ version }} — local source line analysis workbench ·
10694 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10695 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10696 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10697 </footer>
10698
10699 <script nonce="{{ csp_nonce }}">
10700 (function () {
10701 // ── Theme ──────────────────────────────────────────────────────────────
10702 var storageKey = 'oxide-sloc-theme';
10703 var body = document.body;
10704 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
10705 var toggle = document.getElementById('theme-toggle');
10706 if (toggle) toggle.addEventListener('click', function () {
10707 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
10708 body.classList.toggle('dark-theme', next === 'dark');
10709 try { localStorage.setItem(storageKey, next); } catch(e) {}
10710 });
10711
10712 // ── State ─────────────────────────────────────────────────────────────
10713 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
10714 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
10715 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
10716
10717 // ── Stat chips ────────────────────────────────────────────────────────
10718 (function() {
10719 var projects = {}, latestTs = '', latestRow = null;
10720 allRows.forEach(function(r) {
10721 var p = r.dataset.project || ''; if (p) projects[p] = true;
10722 var ts = r.dataset.timestamp || '';
10723 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
10724 });
10725 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
10726 if (latestRow) {
10727 var ce = document.getElementById('agg-code'); if (ce) ce.textContent = Number(latestRow.dataset.code).toLocaleString();
10728 var fe = document.getElementById('agg-files'); if (fe) fe.textContent = latestRow.dataset.files;
10729 }
10730 })();
10731
10732 // ── Branch filter population ──────────────────────────────────────────
10733 (function() {
10734 var branches = {};
10735 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
10736 var sel = document.getElementById('branch-filter');
10737 if (sel) Object.keys(branches).sort().forEach(function(b) {
10738 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
10739 });
10740 })();
10741
10742 // ── Filter ────────────────────────────────────────────────────────────
10743 function getFilteredRows() {
10744 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
10745 var branch = ((document.getElementById('branch-filter') || {}).value || '');
10746 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
10747 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
10748 if (branch && (r.dataset.branch || '') !== branch) return false;
10749 return true;
10750 });
10751 }
10752
10753 // ── Pagination ────────────────────────────────────────────────────────
10754 function renderPage() {
10755 var filtered = getFilteredRows();
10756 var total = filtered.length;
10757 var totalPages = Math.max(1, Math.ceil(total / perPage));
10758 currentPage = Math.min(currentPage, totalPages);
10759 var start = (currentPage - 1) * perPage;
10760 var end = Math.min(start + perPage, total);
10761 var shown = {};
10762 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
10763 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
10764 r.style.display = shown[r.dataset.run] ? '' : 'none';
10765 });
10766 var rl = document.getElementById('page-range-label');
10767 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
10768 var info = document.getElementById('pagination-info');
10769 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
10770 var btns = document.getElementById('pagination-btns');
10771 if (!btns) return;
10772 btns.innerHTML = '';
10773 function makeBtn(lbl, pg, active, disabled) {
10774 var b = document.createElement('button');
10775 b.className = 'pg-btn' + (active ? ' active' : '');
10776 b.textContent = lbl; b.disabled = disabled;
10777 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
10778 return b;
10779 }
10780 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
10781 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
10782 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
10783 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
10784 }
10785
10786 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
10787 window.applyFilters = function() { currentPage = 1; renderPage(); };
10788
10789 // ── Sorting ───────────────────────────────────────────────────────────
10790 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
10791 function doSort(col, type, order) {
10792 var tbody = document.getElementById('compare-tbody');
10793 if (!tbody) return;
10794 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
10795 rows.sort(function(a, b) {
10796 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
10797 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
10798 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
10799 return va < vb ? 1 : va > vb ? -1 : 0;
10800 });
10801 rows.forEach(function(r) { tbody.appendChild(r); });
10802 currentPage = 1; renderPage();
10803 }
10804 sortHeaders.forEach(function(th) {
10805 th.addEventListener('click', function(e) {
10806 if (e.target.classList.contains('col-resize-handle')) return;
10807 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
10808 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
10809 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
10810 th.classList.add('sort-' + sortOrder);
10811 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
10812 doSort(col, type, sortOrder);
10813 });
10814 });
10815
10816 // ── Column resize ─────────────────────────────────────────────────────
10817 (function() {
10818 var table = document.getElementById('compare-table');
10819 if (!table) return;
10820 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
10821 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
10822 ths.forEach(function(th, i) {
10823 var handle = th.querySelector('.col-resize-handle');
10824 if (!handle || !cols[i]) return;
10825 var startX, startW;
10826 handle.addEventListener('mousedown', function(e) {
10827 e.stopPropagation(); e.preventDefault();
10828 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
10829 handle.classList.add('dragging');
10830 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
10831 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
10832 document.addEventListener('mousemove', onMove);
10833 document.addEventListener('mouseup', onUp);
10834 });
10835 });
10836 })();
10837
10838 // ── Reset view ────────────────────────────────────────────────────────
10839 window.resetView = function() {
10840 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
10841 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
10842 sortCol = null; sortOrder = 'asc';
10843 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
10844 var tbody = document.getElementById('compare-tbody');
10845 if (tbody) {
10846 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
10847 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
10848 rows.forEach(function(r) { tbody.appendChild(r); });
10849 }
10850 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
10851 var table = document.getElementById('compare-table');
10852 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
10853 currentPage = 1; renderPage();
10854 };
10855
10856 renderPage();
10857
10858 // ── Row selection state ───────────────────────────────────────────────
10859 var selected = [];
10860 function updateCompareBtn() {
10861 var btn = document.getElementById('compare-btn');
10862 var cnt = document.getElementById('sel-count');
10863 if (!btn) return;
10864 btn.disabled = selected.length !== 2;
10865 if (cnt) cnt.textContent = selected.length + '/2';
10866 }
10867
10868 function toggleRow(row) {
10869 var vid = row.dataset.vid || row.dataset.run;
10870 var idx = selected.indexOf(vid);
10871 if (idx >= 0) {
10872 selected.splice(idx, 1);
10873 row.classList.remove('selected');
10874 var b = document.getElementById('badge-' + vid);
10875 if (b) b.textContent = '';
10876 } else {
10877 if (selected.length >= 2) return;
10878 selected.push(vid);
10879 row.classList.add('selected');
10880 }
10881 selected.forEach(function(v, i) {
10882 var b = document.getElementById('badge-' + v);
10883 if (b) b.textContent = i + 1;
10884 });
10885 updateCompareBtn();
10886 buildScopePanel();
10887 }
10888
10889 // ── Scope panel ───────────────────────────────────────────────────────
10890 var selectedScope = 'all';
10891
10892 function buildScopePanel() {
10893 var panel = document.getElementById('scope-panel');
10894 var opts = document.getElementById('scope-options');
10895 if (!panel || !opts) return;
10896 if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
10897
10898 // Collect union of submodules from both selected rows.
10899 var allSubs = {};
10900 selected.forEach(function(vid) {
10901 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
10902 if (!row) return;
10903 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
10904 });
10905 var subList = Object.keys(allSubs).sort();
10906 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
10907
10908 panel.classList.remove('hidden');
10909 opts.innerHTML = '';
10910
10911 function makeOption(value, label, title) {
10912 var div = document.createElement('div');
10913 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
10914 div.dataset.scopeValue = value;
10915 if (title) div.title = title;
10916 var radio = document.createElement('span');
10917 radio.className = 'scope-option-radio';
10918 var lbl = document.createElement('span');
10919 lbl.textContent = label;
10920 div.appendChild(radio);
10921 div.appendChild(lbl);
10922 div.addEventListener('click', function() {
10923 selectedScope = value;
10924 opts.querySelectorAll('.scope-option').forEach(function(o) {
10925 o.classList.toggle('selected', o.dataset.scopeValue === value);
10926 });
10927 });
10928 return div;
10929 }
10930
10931 opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
10932 var sep = document.createElement('span');
10933 sep.className = 'scope-option-sep';
10934 opts.appendChild(sep);
10935 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
10936 subList.forEach(function(s) {
10937 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
10938 });
10939 }
10940
10941 function doCompare() {
10942 if (selected.length !== 2) return;
10943 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
10944 if (selectedScope === 'super') url += '&scope=super';
10945 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
10946 window.location.href = url;
10947 }
10948
10949 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
10950 var cbtn = document.getElementById('compare-btn');
10951 if (cbtn) cbtn.addEventListener('click', doCompare);
10952 var pfEl = document.getElementById('project-filter');
10953 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
10954 var bfEl = document.getElementById('branch-filter');
10955 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
10956 var rvBtn = document.getElementById('reset-view-btn');
10957 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
10958 var ppSel = document.getElementById('per-page-sel');
10959 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
10960
10961 var cmpTbody = document.getElementById('compare-tbody');
10962 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
10963 var row = e.target.closest('.compare-row');
10964 if (row) toggleRow(row);
10965 });
10966
10967 (function randomizeWatermarks() {
10968 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10969 if (!wms.length) return;
10970 var placed = [];
10971 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;}
10972 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];}
10973 var half=Math.floor(wms.length/2);
10974 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.width=sz+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;});
10975 })();
10976
10977 (function spawnCodeParticles() {
10978 var container = document.getElementById('code-particles');
10979 if (!container) return;
10980 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'];
10981 for (var i = 0; i < 38; i++) {
10982 (function(idx) {
10983 var el = document.createElement('span');
10984 el.className = 'code-particle';
10985 el.textContent = snippets[idx % snippets.length];
10986 var left = Math.random() * 94 + 2;
10987 var top = Math.random() * 88 + 6;
10988 var dur = (Math.random() * 10 + 9).toFixed(1);
10989 var delay = (Math.random() * 18).toFixed(1);
10990 var rot = (Math.random() * 26 - 13).toFixed(1);
10991 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
10992 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
10993 container.appendChild(el);
10994 })(i);
10995 }
10996 })();
10997
10998 // ── Submodule chip truncation ─────────────────────────────────────────
10999 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
11000 var chips = cell.querySelectorAll('.submod-chip');
11001 var MAX = 4;
11002 if (chips.length <= MAX) return;
11003 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
11004 var badge = document.createElement('span');
11005 badge.className = 'submod-overflow-badge';
11006 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
11007 badge.textContent = '+' + (chips.length - MAX) + ' more';
11008 cell.appendChild(badge);
11009 cell.style.maxHeight = 'none';
11010 });
11011 })();
11012 </script>
11013</body>
11014</html>
11015"##,
11016 ext = "html"
11017)]
11018struct CompareSelectTemplate {
11019 version: &'static str,
11020 entries: Vec<HistoryEntryRow>,
11021 total_scans: usize,
11022 csp_nonce: String,
11023}
11024
11025#[derive(Template)]
11028#[template(
11029 source = r##"
11030<!doctype html>
11031<html lang="en">
11032<head>
11033 <meta charset="utf-8">
11034 <meta name="viewport" content="width=device-width, initial-scale=1">
11035 <title>OxideSLOC | Scan Delta</title>
11036 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
11037 <style nonce="{{ csp_nonce }}">
11038 :root {
11039 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
11040 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
11041 --nav:#b85d33; --nav-2:#7a371b;
11042 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
11043 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
11044 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
11045 }
11046 body.dark-theme {
11047 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
11048 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
11049 }
11050 *{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);}
11051 .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);}
11052 .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;}
11053 .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));}
11054 .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
11055 .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;}
11056 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
11057 .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;}
11058 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
11059 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
11060 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
11061 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
11062 .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
11063 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
11064 .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;}
11065 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
11066 .hero-body{display:block;}
11067 .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;}
11068 .btn-back:hover{background:var(--line);}
11069 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
11070 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
11071 .muted{color:var(--muted);font-size:14px;}
11072 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
11073 .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;}
11074 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
11075 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
11076 .vpill-arrow{font-size:20px;color:var(--muted);}
11077 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
11078 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
11079 .delta-card{background:var(--surface-2);border:1px solid var(--line);border-radius:14px;padding:22px 22px;display:flex;flex-direction:column;justify-content:center;min-height:150px;position:relative;cursor:default;}
11080 .delta-card.delta-card-wide{padding:22px 24px;}
11081 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
11082 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
11083 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
11084 .delta-card-from{font-size:15px;color:var(--muted);}
11085 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
11086 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
11087 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
11088 .meta-card-project{font-size:15px;font-weight:600;color:var(--muted);font-style:italic;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%;}
11089 .meta-scope-tag{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:800;padding:3px 10px;border-radius:6px;white-space:nowrap;letter-spacing:.03em;text-transform:uppercase;}
11090 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
11091 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
11092 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
11093 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
11094 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
11095 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
11096 .meta-card-commit{display:block;font-family:ui-monospace,monospace;font-size:28px;font-weight:800;letter-spacing:-0.02em;line-height:1.1;color:var(--accent);text-decoration:none;margin-bottom:16px;word-break:break-all;}
11097 .meta-card-commit:hover{color:var(--oxide);}
11098 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
11099 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
11100 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
11101 .meta-value{color:var(--text);font-size:13px;}
11102 .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;}
11103 .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);}
11104 .delta-card:hover .dc-tip{display:block;}
11105 .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;}
11106 .export-btn:hover{background:var(--line);}
11107 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
11108 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
11109 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
11110 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
11111 .delta-card-change.zero{color:var(--muted);background:transparent;}
11112 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
11113 .delta-card-pct.pos{color:var(--pos);}
11114 .delta-card-pct.neg{color:var(--neg);}
11115 .delta-card-pct.zero{color:var(--muted);}
11116 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
11117 .insight-card{background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1;min-width:120px;position:relative;cursor:default;}
11118 .insight-card.insight-flag{border-color:var(--oxide);}
11119 .insight-card:hover .dc-tip{display:block;}
11120 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
11121 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
11122 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
11123 .insight-label.flag{color:var(--oxide);}
11124 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
11125 .insight-val.pos{color:var(--pos);}
11126 .insight-val.neg{color:var(--neg);}
11127 .insight-val.high{color:#c0392a;}
11128 .insight-val.med{color:#926000;}
11129 .insight-val.low{color:var(--pos);}
11130 body.dark-theme .insight-val.high{color:#ff6b6b;}
11131 body.dark-theme .insight-val.med{color:#f0c060;}
11132 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
11133 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
11134 .fc-row{display:flex;align-items:center;gap:8px;}
11135 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
11136 .fc-label{color:var(--muted);}
11137 .fc-modified .fc-count{color:#926000;}
11138 .fc-added .fc-count{color:var(--pos);}
11139 .fc-removed .fc-count{color:var(--neg);}
11140 .fc-unchanged .fc-count{color:var(--muted);}
11141 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
11142 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
11143 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
11144 .chip.modified{background:#fff2d8;color:#926000;}
11145 .chip.added{background:#e8f5ed;color:#1a8f47;}
11146 .chip.removed{background:#fdeaea;color:#b33b3b;}
11147 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
11148 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
11149 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
11150 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
11151 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
11152 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
11153 .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;}
11154 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
11155 .tab-btn:hover:not(.active){background:var(--line);}
11156 .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;}
11157 .btn-reset:hover{background:var(--line);}
11158 .table-wrap{width:100%;overflow-x:auto;}
11159 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
11160 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;}
11161 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
11162 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
11163 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
11164 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
11165 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
11166 td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
11167 tr:last-child td{border-bottom:none;}
11168 tr.row-added td{background:rgba(26,143,71,0.06);}
11169 tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
11170 tr.row-modified td{background:rgba(146,96,0,0.05);}
11171 tr.row-unchanged td{opacity:.6;}
11172 .file-path{font-family:ui-monospace,monospace;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
11173 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
11174 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
11175 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
11176 .status-badge.modified{background:#fff2d8;color:#926000;}
11177 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
11178 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
11179 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
11180 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
11181 .delta-val{font-weight:700;}
11182 .delta-val.pos{color:var(--pos);}
11183 .delta-val.neg{color:var(--neg);}
11184 .delta-val.zero{color:var(--muted);}
11185 .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
11186 .from-to strong{color:var(--text);}
11187 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
11188 .site-footer a{color:var(--muted);}
11189 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
11190 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
11191 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
11192 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
11193 .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;}
11194 .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;}
11195 .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;}
11196 @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));}}
11197 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
11198 .path-link:hover{color:var(--oxide-2);}
11199 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
11200 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
11201 a.vpill-id:hover{color:var(--oxide);}
11202 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
11203 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
11204 .pagination-info{font-size:13px;color:var(--muted);}
11205 .pagination-btns{display:flex;gap:6px;}
11206 .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;}
11207 .pg-btn:hover:not(:disabled){background:var(--line);}
11208 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
11209 .pg-btn:disabled{opacity:.35;cursor:default;}
11210 .per-page-label{font-size:13px;color:var(--muted);}
11211 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;}
11212 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
11213 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
11214 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
11215 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
11216 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
11217 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
11218 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
11219 .tab-btn.tab-unchanged{color:var(--muted);}
11220 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
11221 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
11222 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
11223 .nav-dropdown{position:relative;display:inline-flex;}.nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}.nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}.nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}.nav-dropdown-menu a:last-child{border-bottom:none;}.nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}.nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
11224 .submod-scope-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:10px 16px;background:var(--surface-2);border:1.5px solid var(--line-strong);border-radius:12px;margin:12px 0 18px;}
11225 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
11226 .submod-scope-label{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);flex-shrink:0;white-space:nowrap;}
11227 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
11228 .submod-scope-btn{padding:5px 13px;border-radius:7px;border:1.5px solid var(--line-strong);background:var(--surface);color:var(--text);font-size:12px;font-weight:700;text-decoration:none;white-space:nowrap;transition:background .12s ease,border-color .12s ease,color .12s ease;}
11229 .submod-scope-btn:hover{background:var(--line);}
11230 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
11231 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
11232 </style>
11233</head>
11234<body>
11235 <div class="background-watermarks" aria-hidden="true">
11236 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11237 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11238 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11239 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11240 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11241 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11242 </div>
11243 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11244 <div class="top-nav">
11245 <div class="top-nav-inner">
11246 <a class="brand" href="/">
11247 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
11248 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
11249 </a>
11250 <div class="nav-right">
11251 <a class="nav-pill" href="/">Home</a>
11252 <a class="nav-pill" href="/view-reports">View Reports</a>
11253 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11254 <div class="nav-dropdown">
11255 <button class="nav-dropdown-btn" type="button">Git Tools <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
11256 <div class="nav-dropdown-menu">
11257 <a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
11258 <a href="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Webhooks</a>
11259 </div>
11260 </div>
11261 <div class="server-status-wrap">
11262 <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
11263 <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>
11264 </div>
11265 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
11266 <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>
11267 <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>
11268 </button>
11269 </div>
11270 </div>
11271 </div>
11272
11273 <div class="page">
11274 <section class="hero">
11275 <div class="hero-header">
11276 <div>
11277 <h1 style="margin:0 0 6px;">Scan Delta</h1>
11278 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
11279 {% if let Some(sub) = active_submodule %}
11280 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
11281 {% else if super_scope_active %}
11282 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
11283 {% else %}
11284 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
11285 {% endif %}
11286 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
11287 </div>
11288 </div>
11289 <a class="btn-back" href="/compare-scans">
11290 <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>
11291 Compare Scans
11292 </a>
11293 </div>
11294 {% if has_any_submodule_data %}
11295 <div class="submod-scope-bar">
11296 <span class="submod-scope-label">
11297 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="3"></circle><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"></path></svg>
11298 Scope:
11299 </span>
11300 <div class="submod-scope-divider"></div>
11301 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
11302 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
11303 title="All files — super-repo and all submodules combined">Full scan</a>
11304 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
11305 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
11306 title="Only files that are not part of any submodule">Super-repo only</a>
11307 {% for sub in submodule_options %}
11308 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
11309 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
11310 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
11311 {% endfor %}
11312 </div>
11313 {% endif %}
11314 <div class="hero-body">
11315 <div class="meta-strip">
11316 <div class="delta-card delta-card-meta">
11317 <div class="meta-card-header">
11318 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
11319 <div class="meta-card-project-col">
11320 <div class="meta-card-project">{{ project_name }}</div>
11321 {% if has_any_submodule_data %}
11322 {% if let Some(sub) = active_submodule %}
11323 <span class="meta-scope-tag scope-sub"><svg width="11" height="11" viewBox="0 0 24 24"><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>{{ sub }}</span>
11324 {% else if super_scope_active %}
11325 <span class="meta-scope-tag scope-super"><svg width="11" height="11" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>Super-repo only</span>
11326 {% else %}
11327 <span class="meta-scope-tag scope-full"><svg width="11" height="11" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>Full scan</span>
11328 {% endif %}
11329 {% endif %}
11330 </div>
11331 </div>
11332 {% if !baseline_git_commit.is_empty() %}
11333 <a class="meta-card-commit" href="/runs/{{ baseline_run_id }}/html" target="_blank">{{ baseline_git_commit }}</a>
11334 {% else %}
11335 <a class="meta-card-commit" href="/runs/{{ baseline_run_id }}/html" target="_blank">{{ baseline_run_id_short }}</a>
11336 {% endif %}
11337 <div class="meta-card-rows">
11338 <div class="meta-card-row"><span class="meta-label">Branch:</span>{% if !baseline_git_branch.is_empty() %}<span class="git-chip">{{ baseline_git_branch }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
11339 <div class="meta-card-row"><span class="meta-label">Last commit on:</span>{% if let Some(date) = baseline_git_commit_date %}<span class="meta-value">{{ date }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
11340 <div class="meta-card-row"><span class="meta-label">Last commit by:</span>{% if let Some(author) = baseline_git_author %}<span class="meta-value">{{ author }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
11341 <div class="meta-card-row"><span class="meta-label">Scanned on:</span><span class="meta-value">{{ baseline_timestamp }}</span></div>
11342 {% if let Some(tags) = baseline_git_tags %}
11343 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
11344 {% endif %}
11345 </div>
11346 </div>
11347 <div class="delta-card delta-card-meta">
11348 <div class="meta-card-header">
11349 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
11350 <div class="meta-card-project-col">
11351 <div class="meta-card-project">{{ project_name }}</div>
11352 {% if has_any_submodule_data %}
11353 {% if let Some(sub) = active_submodule %}
11354 <span class="meta-scope-tag scope-sub"><svg width="11" height="11" viewBox="0 0 24 24"><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>{{ sub }}</span>
11355 {% else if super_scope_active %}
11356 <span class="meta-scope-tag scope-super"><svg width="11" height="11" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>Super-repo only</span>
11357 {% else %}
11358 <span class="meta-scope-tag scope-full"><svg width="11" height="11" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>Full scan</span>
11359 {% endif %}
11360 {% endif %}
11361 </div>
11362 </div>
11363 {% if !current_git_commit.is_empty() %}
11364 <a class="meta-card-commit" href="/runs/{{ current_run_id }}/html" target="_blank">{{ current_git_commit }}</a>
11365 {% else %}
11366 <a class="meta-card-commit" href="/runs/{{ current_run_id }}/html" target="_blank">{{ current_run_id_short }}</a>
11367 {% endif %}
11368 <div class="meta-card-rows">
11369 <div class="meta-card-row"><span class="meta-label">Branch:</span>{% if !current_git_branch.is_empty() %}<span class="git-chip">{{ current_git_branch }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
11370 <div class="meta-card-row"><span class="meta-label">Last commit on:</span>{% if let Some(date) = current_git_commit_date %}<span class="meta-value">{{ date }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
11371 <div class="meta-card-row"><span class="meta-label">Last commit by:</span>{% if let Some(author) = current_git_author %}<span class="meta-value">{{ author }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
11372 <div class="meta-card-row"><span class="meta-label">Scanned on:</span><span class="meta-value">{{ current_timestamp }}</span></div>
11373 {% if let Some(tags) = current_git_tags %}
11374 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
11375 {% endif %}
11376 </div>
11377 </div>
11378 </div>
11379 <div class="delta-strip">
11380 <div class="delta-card">
11381 <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
11382 <div class="delta-card-label">Code lines</div>
11383 <div class="delta-card-from">Before: {{ baseline_code }}</div>
11384 <div class="delta-card-to">{{ current_code }}</div>
11385 {% if code_lines_delta_class == "pos" %}<span class="delta-card-change pos">{{ code_lines_delta_str }}</span><div class="delta-card-pct pos">{{ code_lines_pct_str }}</div>
11386 {% else if code_lines_delta_class == "neg" %}<span class="delta-card-change neg">{{ code_lines_delta_str }}</span><div class="delta-card-pct neg">{{ code_lines_pct_str }}</div>
11387 {% else %}<div class="delta-card-pct zero">±0%</div>
11388 {% endif %}
11389 </div>
11390 <div class="delta-card">
11391 <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
11392 <div class="delta-card-label">Files analyzed</div>
11393 <div class="delta-card-from">Before: {{ baseline_files }}</div>
11394 <div class="delta-card-to">{{ current_files }}</div>
11395 {% if files_analyzed_delta_class == "pos" %}<span class="delta-card-change pos">{{ files_analyzed_delta_str }}</span><div class="delta-card-pct pos">{{ files_analyzed_pct_str }}</div>
11396 {% else if files_analyzed_delta_class == "neg" %}<span class="delta-card-change neg">{{ files_analyzed_delta_str }}</span><div class="delta-card-pct neg">{{ files_analyzed_pct_str }}</div>
11397 {% else %}<div class="delta-card-pct zero">±0%</div>
11398 {% endif %}
11399 </div>
11400 <div class="delta-card">
11401 <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
11402 <div class="delta-card-label">Comment lines</div>
11403 <div class="delta-card-from">Before: {{ baseline_comments }}</div>
11404 <div class="delta-card-to">{{ current_comments }}</div>
11405 {% if comment_lines_delta_class == "pos" %}<span class="delta-card-change pos">{{ comment_lines_delta_str }}</span><div class="delta-card-pct pos">{{ comment_lines_pct_str }}</div>
11406 {% else if comment_lines_delta_class == "neg" %}<span class="delta-card-change neg">{{ comment_lines_delta_str }}</span><div class="delta-card-pct neg">{{ comment_lines_pct_str }}</div>
11407 {% else %}<div class="delta-card-pct zero">±0%</div>
11408 {% endif %}
11409 </div>
11410 <div class="delta-card delta-card-wide">
11411 <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>
11412 <div class="delta-card-label">File changes</div>
11413 <div class="file-changes-grid">
11414 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
11415 <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
11416 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
11417 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
11418 </div>
11419 </div>
11420 </div>
11421 <div class="insights-panel">
11422 <div class="insight-card">
11423 <div class="dc-tip up">Sum of code lines added or grown across all files between the two scans. Only counts files where the current scan has more code than the baseline — shrunk files do not contribute here.</div>
11424 <div class="insight-label">Lines Added</div>
11425 <div class="insight-val pos">+{{ code_lines_added }}</div>
11426 <div class="insight-sub">New or grown source lines</div>
11427 </div>
11428 <div class="insight-card">
11429 <div class="dc-tip up">Sum of code lines removed or shrunk across all files between the two scans. Only counts files where the current scan has fewer code lines than the baseline — grown files do not contribute here.</div>
11430 <div class="insight-label">Lines Removed</div>
11431 <div class="insight-val neg">−{{ code_lines_removed }}</div>
11432 <div class="insight-sub">Deleted or shrunk source lines</div>
11433 </div>
11434 <div class="insight-card">
11435 <div class="dc-tip up">Measures total editing activity relative to codebase size. Formula: (lines added + lines removed) ÷ baseline code lines × 100%. Above 20% = high activity, 5–20% = normal velocity, below 5% = stable.</div>
11436 <div class="insight-label">Churn Rate</div>
11437 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
11438 <div class="insight-sub">{% if new_scope %}No prior baseline for this scope{% else if churn_rate_class == "high" %}High activity — verify scope{% else if churn_rate_class == "med" %}Normal development velocity{% else %}Stable baseline{% endif %} · (added + removed) ÷ baseline</div>
11439 </div>
11440 {% if scope_flag %}
11441 <div class="insight-card insight-flag">
11442 <div class="dc-tip up">{% if new_scope %}This scope had no files in the baseline scan — all content is new. Switch to Full scan to compare against the parent repository.{% else %}Triggered when net code growth exceeds 20% of the baseline. This often signals a large feature branch, a bulk import, or a generated-file inclusion. Review the file-level delta below to confirm scope.{% endif %}</div>
11443 <div class="insight-label flag">Scope Signal</div>
11444 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
11445 <div class="insight-sub">{% if new_scope %}New scope — no prior baseline for this selection{% else %}Added > 20% of baseline — large feature addition detected{% endif %}</div>
11446 </div>
11447 {% endif %}
11448 </div>
11449 </div>
11450 </section>
11451
11452 <section class="panel">
11453 <h2>File-level delta</h2>
11454 <div class="filter-tabs-row">
11455 <div class="filter-tabs">
11456 <button class="tab-btn tab-all active" data-filter="all">All</button>
11457 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
11458 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
11459 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
11460 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
11461 </div>
11462 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
11463 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
11464 <div class="export-group">
11465 <button type="button" class="btn-reset" id="delta-reset-btn">↻ Reset</button>
11466 <button type="button" class="export-btn" id="delta-csv-btn">
11467 <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>
11468 CSV
11469 </button>
11470 <button type="button" class="export-btn" id="delta-xls-btn">
11471 <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>
11472 Excel
11473 </button>
11474 <button type="button" class="export-btn" id="delta-charts-btn">
11475 <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>
11476 Charts
11477 </button>
11478 </div>
11479 </div>
11480 </div>
11481
11482 <div class="table-wrap">
11483 <table id="delta-table">
11484 <colgroup>
11485 <col style="width:55%">
11486 <col style="width:7%">
11487 <col style="width:7%">
11488 <col style="width:12%">
11489 <col style="width:6%">
11490 <col style="width:6%">
11491 <col style="width:7%">
11492 </colgroup>
11493 <thead>
11494 <tr id="delta-thead">
11495 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
11496 <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>
11497 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
11498 <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>
11499 <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>
11500 <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>
11501 <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>
11502 </tr>
11503 </thead>
11504 <tbody id="delta-tbody">
11505 {% for row in file_rows %}
11506 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
11507 data-path="{{ row.relative_path }}"
11508 data-language="{{ row.language }}"
11509 data-baseline-code="{{ row.baseline_code }}"
11510 data-current-code="{{ row.current_code }}"
11511 data-code-delta="{{ row.code_delta_str }}"
11512 data-comment-delta="{{ row.comment_delta_str }}"
11513 data-total-delta="{{ row.total_delta_str }}"
11514 data-orig-idx="">
11515 <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
11516 <td class="hide-sm">{{ row.language }}</td>
11517 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
11518 <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
11519 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
11520 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
11521 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
11522 </tr>
11523 {% endfor %}
11524 </tbody>
11525 </table>
11526 </div>
11527 <div class="pagination">
11528 <span class="pagination-info" id="pg-info"></span>
11529 <div class="pagination-btns" id="pg-btns"></div>
11530 <div class="flex-row">
11531 <span class="per-page-label">Show</span>
11532 <select class="per-page" id="per-page-sel">
11533 <option value="10">10 per page</option>
11534 <option value="25" selected>25 per page</option>
11535 <option value="50">50 per page</option>
11536 <option value="100">100 per page</option>
11537 </select>
11538 <span class="per-page-label" id="pg-range-label"></span>
11539 </div>
11540 </div>
11541 </section>
11542 </div>
11543
11544 <footer class="site-footer">
11545 oxide-sloc v{{ version }} — local source line analysis workbench ·
11546 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
11547 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
11548 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
11549 </footer>
11550
11551 <script nonce="{{ csp_nonce }}">
11552 (function () {
11553 var storageKey = 'oxide-sloc-theme';
11554 var body = document.body;
11555 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
11556 var toggle = document.getElementById('theme-toggle');
11557 if (toggle) toggle.addEventListener('click', function () {
11558 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
11559 body.classList.toggle('dark-theme', next === 'dark');
11560 try { localStorage.setItem(storageKey, next); } catch(e) {}
11561 });
11562
11563 (function randomizeWatermarks() {
11564 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
11565 if (!wms.length) return;
11566 var placed = [];
11567 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;}
11568 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];}
11569 var half=Math.floor(wms.length/2);
11570 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.width=sz+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;});
11571 })();
11572
11573 (function spawnCodeParticles() {
11574 var container = document.getElementById('code-particles');
11575 if (!container) return;
11576 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'];
11577 for (var i = 0; i < 38; i++) {
11578 (function(idx) {
11579 var el = document.createElement('span');
11580 el.className = 'code-particle';
11581 el.textContent = snippets[idx % snippets.length];
11582 var left = Math.random() * 94 + 2;
11583 var top = Math.random() * 88 + 6;
11584 var dur = (Math.random() * 10 + 9).toFixed(1);
11585 var delay = (Math.random() * 18).toFixed(1);
11586 var rot = (Math.random() * 26 - 13).toFixed(1);
11587 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
11588 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
11589 container.appendChild(el);
11590 })(i);
11591 }
11592 })();
11593 })();
11594
11595 var activeStatusFilter = 'all';
11596 var deltaPerPage = 25, deltaCurrPage = 1;
11597
11598 function openFolder(path) {
11599 fetch('/open-path?path=' + encodeURIComponent(path)).catch(function(){});
11600 }
11601
11602 function getDeltaFilteredRows() {
11603 return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
11604 return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
11605 });
11606 }
11607
11608 function renderDeltaPage() {
11609 var filtered = getDeltaFilteredRows();
11610 var total = filtered.length;
11611 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
11612 deltaCurrPage = Math.min(deltaCurrPage, totalPages);
11613 var start = (deltaCurrPage - 1) * deltaPerPage;
11614 var end = Math.min(start + deltaPerPage, total);
11615 var shownSet = {};
11616 filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
11617 Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
11618 r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
11619 });
11620 var rl = document.getElementById('pg-range-label');
11621 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
11622 var info = document.getElementById('pg-info');
11623 if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
11624 var btns = document.getElementById('pg-btns');
11625 if (!btns) return;
11626 btns.innerHTML = '';
11627 if (totalPages <= 1) return;
11628 function makeBtn(lbl, pg, active, disabled) {
11629 var b = document.createElement('button');
11630 b.className = 'pg-btn' + (active ? ' active' : '');
11631 b.textContent = lbl; b.disabled = disabled;
11632 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
11633 return b;
11634 }
11635 btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
11636 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
11637 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
11638 btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
11639 }
11640
11641 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
11642
11643 function filterRows(status, btn) {
11644 activeStatusFilter = status;
11645 deltaCurrPage = 1;
11646 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
11647 b.classList.remove('active');
11648 });
11649 if (btn) btn.classList.add('active');
11650 renderDeltaPage();
11651 }
11652
11653 // ── Sorting ──────────────────────────────────────────────────────────────
11654 var sortCol = null, sortOrder = 'asc';
11655 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
11656 (function() {
11657 var tbody = document.getElementById('delta-tbody');
11658 if (!tbody) return;
11659 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
11660 rows.forEach(function(r, i) { r.dataset.origIdx = i; });
11661 })();
11662
11663 function parseDeltaNum(str) {
11664 if (!str || str === '—') return 0;
11665 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
11666 }
11667
11668 sortHeaders.forEach(function(th) {
11669 th.addEventListener('click', function(e) {
11670 if (e.target.classList.contains('col-resize-handle')) return;
11671 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
11672 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
11673 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
11674 th.classList.add('sort-' + sortOrder);
11675 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
11676 var tbody = document.getElementById('delta-tbody');
11677 if (!tbody) return;
11678 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
11679 rows.sort(function(a, b) {
11680 var va, vb;
11681 if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
11682 else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
11683 else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
11684 else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
11685 else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
11686 else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
11687 else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
11688 else { va = ''; vb = ''; }
11689 if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
11690 return va < vb ? 1 : va > vb ? -1 : 0;
11691 });
11692 rows.forEach(function(r) { tbody.appendChild(r); });
11693 deltaCurrPage = 1;
11694 renderDeltaPage();
11695 var activeBtn = document.querySelector('.tab-btn.active');
11696 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
11697 if (activeBtn) activeBtn.classList.add('active');
11698 });
11699 });
11700
11701 // ── Column resize ─────────────────────────────────────────────────────────
11702 (function() {
11703 var table = document.getElementById('delta-table');
11704 if (!table) return;
11705 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
11706 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
11707 ths.forEach(function(th, i) {
11708 var handle = th.querySelector('.col-resize-handle');
11709 if (!handle || !cols[i]) return;
11710 var startX, startW;
11711 handle.addEventListener('mousedown', function(e) {
11712 e.stopPropagation(); e.preventDefault();
11713 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
11714 handle.classList.add('dragging');
11715 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
11716 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
11717 document.addEventListener('mousemove', onMove);
11718 document.addEventListener('mouseup', onUp);
11719 });
11720 });
11721 })();
11722
11723 // ── Reset ─────────────────────────────────────────────────────────────────
11724 window.resetDeltaTable = function() {
11725 sortCol = null; sortOrder = 'asc';
11726 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
11727 var tbody = document.getElementById('delta-tbody');
11728 if (tbody) {
11729 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
11730 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
11731 rows.forEach(function(r) { tbody.appendChild(r); });
11732 }
11733 var table = document.getElementById('delta-table');
11734 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
11735 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
11736 activeStatusFilter = 'all';
11737 deltaCurrPage = 1;
11738 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
11739 var allBtn = document.querySelector('.tab-btn');
11740 if (allBtn) allBtn.classList.add('active');
11741 renderDeltaPage();
11742 };
11743
11744 renderDeltaPage();
11745
11746 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
11747 (function() {
11748 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
11749 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
11750 });
11751 var resetBtn = document.getElementById('delta-reset-btn');
11752 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
11753 var csvBtn = document.getElementById('delta-csv-btn');
11754 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
11755 var xlsBtn = document.getElementById('delta-xls-btn');
11756 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
11757 var chartsBtn = document.getElementById('delta-charts-btn');
11758 if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
11759 var ppSel = document.getElementById('per-page-sel');
11760 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
11761 var pathLink = document.getElementById('project-path-link');
11762 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
11763 })();
11764
11765 // ── Export helpers ────────────────────────────────────────────────────────
11766 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
11767 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
11768 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);}
11769 function slocMakeXlsx(fname,sd,dr){
11770 var enc=new TextEncoder();
11771 // CRC-32 table
11772 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;}
11773 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;}
11774 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
11775 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
11776 // Shared string table
11777 var ss=[],si={};
11778 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
11779 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
11780 // Worksheet builder — each WS() call gets its own row counter R
11781 function WS(){
11782 var R=0,buf=[];
11783 function cl(c){return String.fromCharCode(65+c);}
11784 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
11785 '<v>'+S(v)+'</v></c>';}
11786 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
11787 (st?' s="'+st+'"':'')+'>'+
11788 '<v>'+(+v)+'</v></c>';}
11789 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
11790 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
11791 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
11792 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
11793 '<sheetFormatPr defaultRowHeight="15"/>'+
11794 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
11795 return{sc:sc,nc:nc,row:row,xml:xml};
11796 }
11797 // Language breakdown
11798 var lm={};
11799 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;});
11800 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
11801 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
11802 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
11803 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
11804 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
11805 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
11806 function _fp(b,c,st){if(st==='added'&&b===0)return'new';if(st==='removed')return'-100.0%';if(st==='unchanged')return'0.0%';return b>0?_sp(c-b,b):'';}
11807 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
11808 // Summary sheet
11809 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
11810 r1(s1(0,'OxideSLOC — Scan Delta Report',1));
11811 r1(s1(0,proj,2));
11812 r1(s1(0,sd.bts+' → '+sd.cts,2));
11813 r1('');
11814 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
11815 r1(s1(0,'Code Lines')+n1(1,sd.bc,4)+n1(2,sd.cc,4)+s1(3,sd.cd,dstyle(sd.cd))+s1(4,_sp(sd.cc-sd.bc,sd.bc),_ps(_sp(sd.cc-sd.bc,sd.bc))));
11816 r1(s1(0,'Files Analyzed')+n1(1,sd.bf,4)+n1(2,sd.cf,4)+s1(3,sd.fd,dstyle(sd.fd))+s1(4,_sp(sd.cf-sd.bf,sd.bf),_ps(_sp(sd.cf-sd.bf,sd.bf))));
11817 r1(s1(0,'Comment Lines')+n1(1,sd.bcm,4)+n1(2,sd.ccm,4)+s1(3,sd.cmd,dstyle(sd.cmd))+s1(4,_sp(sd.ccm-sd.bcm,sd.bcm),_ps(_sp(sd.ccm-sd.bcm,sd.bcm))));
11818 r1('');
11819 r1(s1(0,'FILE CHANGES',8));
11820 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
11821 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
11822 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
11823 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
11824 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
11825 if(langs.length){
11826 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
11827 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
11828 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)));});
11829 }
11830 r1('');r1(s1(0,'SCAN METADATA',8));
11831 r1(s1(1,_blabel)+s1(2,_clabel));
11832 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
11833 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
11834 var sh1=W1.xml('<col min="1" max="1" width="24" customWidth="1"/><col min="2" max="4" width="14" customWidth="1"/><col min="5" max="5" width="12" customWidth="1"/>');
11835 // File Delta sheet
11836 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
11837 r2(s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3)+s2(3,'Code ('+_blabel+')',3)+s2(4,'Code ('+_clabel+')',3)+s2(5,'Code Delta',3)+s2(6,'Comment Delta',3)+s2(7,'Total Delta',3)+s2(8,'% Code Chg',3));
11838 dr.forEach(function(r){var b=parseInt(r[3])||0,c=parseInt(r[4])||0,st=r[2]||'',fp=_fp(b,c,st);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]))+s2(8,fp,_ps(fp)));});
11839 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
11840 // Shared strings XML
11841 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
11842 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
11843 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
11844 // XLSX file map
11845 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
11846 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>',
11847 '_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>',
11848 '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>',
11849 '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>',
11850 '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" applyFont="1"/><xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/><xf numFmtId="0" fontId="3" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="left"/></xf><xf numFmtId="3" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="4" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="5" fillId="4" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="6" fillId="0" borderId="0" xfId="0" applyFont="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="7" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>',
11851 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
11852 // ZIP packer — STORED (no compression), compatible with all XLSX readers
11853 var zparts=[],zcds=[],zoff=0,znf=0;
11854 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
11855 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
11856 ].forEach(function(name){
11857 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
11858 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]);
11859 var entry=new Uint8Array(lha.length+nb.length+sz);
11860 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
11861 zparts.push(entry);
11862 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));
11863 var cde=new Uint8Array(cda.length+nb.length);
11864 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
11865 zcds.push(cde);zoff+=entry.length;znf++;
11866 });
11867 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
11868 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]);
11869 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
11870 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
11871 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
11872 zout.set(new Uint8Array(ea),zpos);
11873 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
11874 var xurl=URL.createObjectURL(xblob);
11875 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
11876 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
11877 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
11878 }
11879 function slocCsv(fname,hdrs,rows){var parts=[hdrs.map(slocEscCsv).join(',')];rows.forEach(function(r){parts.push(r.map(slocEscCsv).join(','));});slocDownload(parts.join('\r\n'),fname,'text/csv;charset=utf-8;');}
11880 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
11881 function getExportFilename(ext){return _exportBase+'.'+ext;}
11882
11883 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 }}',bbr:'{{ baseline_git_branch }}',cbr:'{{ current_git_branch }}',btag:'{% if let Some(t) = baseline_git_tags %}{{ t }}{% endif %}',ctag:'{% if let Some(t) = current_git_tags %}{{ t }}{% endif %}',bsha:'{{ baseline_git_commit }}',csha:'{{ current_git_commit }}'};
11884 function _mkScanLabel(pfx,tag,br,sha){var ref=tag||(br||'');if(ref&&sha)return pfx+' ('+ref+' @ '+sha+')';if(ref)return pfx+' ('+ref+')';if(sha)return pfx+' ('+sha+')';return pfx;}
11885 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
11886 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
11887 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
11888 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
11889 function _filePct(b,c,st){if(st==='added'&&b===0)return'new';if(st==='removed')return'-100.0%';if(st==='unchanged')return'0.0%';return b>0?_slPct(c-b,b):'';}
11890 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
11891 function getSummaryExportRows(){return[['Code Lines',String(_sd.bc),String(_sd.cc),_sd.cd,_slPct(_sd.cc-_sd.bc,_sd.bc)],['Files Analyzed',String(_sd.bf),String(_sd.cf),_sd.fd,_slPct(_sd.cf-_sd.bf,_sd.bf)],['Comment Lines',String(_sd.bcm),String(_sd.ccm),_sd.cmd,_slPct(_sd.ccm-_sd.bcm,_sd.bcm)],['Modified Files','0','0',String(_sd.fm),_tfPct(_sd.fm)],['Added Files','0','0',String(_sd.fa),_tfPct(_sd.fa)],['Removed Files','0','0',String(_sd.fr),_tfPct(_sd.fr)],['Unchanged Files','0','0',String(_sd.fu),_tfPct(_sd.fu)]];}
11892 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
11893 function getDeltaExportRows(){var r=[];document.querySelectorAll('#delta-tbody .delta-row').forEach(function(tr){var b=parseInt(tr.getAttribute('data-baseline-code'))||0,c=parseInt(tr.getAttribute('data-current-code'))||0,st=tr.getAttribute('data-status')||'';r.push([tr.getAttribute('data-path')||'',tr.getAttribute('data-language')||'',st,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')||'',_filePct(b,c,st)]);});return r;}
11894 window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
11895 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
11896
11897 // ── Chart HTML report ─────────────────────────────────────────────────────
11898 function slocChartReport(fname, sd, dr) {
11899 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
11900 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
11901 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
11902 function fmt(n){return Number(n).toLocaleString();}
11903 function px(n){return Math.round(n);}
11904 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
11905 // Language map
11906 var lm={};
11907 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;});
11908 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
11909
11910 // Builds onmouse* attrs for interactive tooltip on each SVG element
11911 function barTT(label,val){
11912 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
11913 }
11914
11915 // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
11916 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}];
11917 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
11918 var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
11919 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
11920 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11921 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"/>';}
11922 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
11923 c1mets.forEach(function(m,i){
11924 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
11925 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
11926 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>';
11927 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))+'/>';
11928 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>';
11929 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))+'/>';
11930 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>';
11931 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>';
11932 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>';
11933 });
11934 c1+='</svg>';
11935
11936 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
11937 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}];
11938 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
11939 var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
11940 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
11941 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11942 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11943 mets.forEach(function(m,i){
11944 var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
11945 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
11946 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
11947 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>';
11948 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
11949 if(bw>=52){
11950 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>';
11951 }else{
11952 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
11953 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>';
11954 }
11955 });
11956 c2+='</svg>';
11957
11958 // ── Chart 3: Language Code Delta ─────────────────────────────────────
11959 var c3='';
11960 if(langs.length){
11961 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
11962 var C3W=550,c3LW=124,c3FW=52;
11963 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
11964 var L3rH=30,C3H=langs.length*L3rH+20;
11965 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11966 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11967 langs.forEach(function(l,i){
11968 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
11969 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
11970 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
11971 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
11972 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':''))+'/>';
11973 if(bw>=48){
11974 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>';
11975 }else{
11976 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
11977 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>';
11978 }
11979 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>';
11980 });
11981 c3+='</svg>';
11982 }
11983
11984 // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
11985 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;});
11986 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
11987 var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
11988 var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11989 var ang=-Math.PI/2;
11990 segs.forEach(function(s){
11991 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
11992 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
11993 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
11994 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
11995 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
11996 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)+'%')+'/>';
11997 ang+=sw;
11998 });
11999 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>';
12000 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
12001 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>';});
12002 c4+='</svg>';
12003
12004 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
12005 var ttJs='var tt=document.getElementById("ox-tt");'+
12006 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
12007 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
12008 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
12009 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
12010 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
12011 'function oxHT(){tt.style.display="none";}';
12012
12013 // body max-width keeps charts from inflating beyond design dimensions on
12014 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
12015 // each chart's height blows up proportionally, breaking the one-page layout.
12016 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;}'+
12017 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
12018 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
12019 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
12020 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
12021 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
12022 'svg{display:block;}'+
12023 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
12024 '#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;}'+
12025 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
12026 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
12027 '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
12028 '<div id="ox-tt"><\/div>'+
12029 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
12030 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
12031 '<div class="two-col">'+
12032 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
12033 '<div class="leg"><span><span class="dot" style="background:#AAAAAA"><\/span>Baseline<\/span>'+
12034 '<span><span class="dot" style="background:#C45C10"><\/span>Current<\/span><\/div>'+c1+'<\/div>'+
12035 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
12036 '<\/div>'+
12037 '<div class="two-col">'+
12038 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
12039 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
12040 '<\/div>'+
12041 '<script>'+ttJs+'<\/script>'+
12042 '<\/body><\/html>';
12043 slocDownload(html, fname, 'text/html;charset=utf-8;');
12044 }
12045 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
12046 </script>
12047</body>
12048</html>
12049"##,
12050 ext = "html"
12051)]
12052struct CompareTemplate {
12053 version: &'static str,
12054 project_label: String,
12055 baseline_git_commit: String,
12056 current_git_commit: String,
12057 baseline_run_id: String,
12058 current_run_id: String,
12059 baseline_run_id_short: String,
12060 current_run_id_short: String,
12061 baseline_timestamp: String,
12062 current_timestamp: String,
12063 project_path: String,
12064 baseline_code: u64,
12065 current_code: u64,
12066 code_lines_delta_str: String,
12067 code_lines_delta_class: String,
12068 baseline_files: u64,
12069 current_files: u64,
12070 files_analyzed_delta_str: String,
12071 files_analyzed_delta_class: String,
12072 baseline_comments: u64,
12073 current_comments: u64,
12074 comment_lines_delta_str: String,
12075 comment_lines_delta_class: String,
12076 code_lines_pct_str: String,
12077 files_analyzed_pct_str: String,
12078 comment_lines_pct_str: String,
12079 code_lines_added: i64,
12080 code_lines_removed: i64,
12081 new_scope: bool,
12083 churn_rate_str: String,
12084 churn_rate_class: String,
12085 scope_flag: bool,
12086 files_added: usize,
12087 files_removed: usize,
12088 files_modified: usize,
12089 files_unchanged: usize,
12090 file_rows: Vec<CompareFileDeltaRow>,
12091 baseline_git_author: Option<String>,
12092 current_git_author: Option<String>,
12093 baseline_git_branch: String,
12094 current_git_branch: String,
12095 baseline_git_tags: Option<String>,
12096 current_git_tags: Option<String>,
12097 baseline_git_commit_date: Option<String>,
12098 current_git_commit_date: Option<String>,
12099 project_name: String,
12100 submodule_options: Vec<String>,
12102 has_any_submodule_data: bool,
12104 active_submodule: Option<String>,
12106 super_scope_active: bool,
12108 csp_nonce: String,
12109}
12110
12111#[derive(Template)]
12114#[template(
12115 source = r##"
12116<!doctype html>
12117<html lang="en">
12118<head>
12119 <meta charset="utf-8">
12120 <meta name="viewport" content="width=device-width, initial-scale=1">
12121 <title>OxideSLOC | Sign In</title>
12122 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12123 <style nonce="{{ csp_nonce }}">
12124 :root {
12125 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
12126 --text:#2f241c; --muted:#7b675b; --nav:#b85d33; --nav-2:#7a371b;
12127 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
12128 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
12129 }
12130 *{box-sizing:border-box;}
12131 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);}
12132 .top-nav{background:linear-gradient(180deg,var(--nav),var(--nav-2));padding:0 24px;min-height:56px;display:flex;align-items:center;box-shadow:0 4px 14px rgba(0,0,0,.18);}
12133 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
12134 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
12135 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
12136 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;}
12137 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
12138 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
12139 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
12140 .error{background:var(--err-bg);border:1px solid var(--err-border);color:var(--err-text);border-radius:8px;padding:12px 16px;font-size:14px;margin-bottom:20px;}
12141 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
12142 input[type=password]{width:100%;padding:10px 14px;border:1px solid var(--line-strong);border-radius:8px;background:#fff;color:var(--text);font-size:14px;font-family:ui-monospace,monospace;outline:none;transition:border-color .15s;}
12143 input[type=password]:focus{border-color:var(--oxide);}
12144 .btn{width:100%;padding:11px;border:none;border-radius:8px;background:var(--oxide-2);color:#fff;font-size:15px;font-weight:700;cursor:pointer;margin-top:20px;transition:opacity .15s;}
12145 .btn:hover{opacity:.88;}
12146 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
12147 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
12148 </style>
12149</head>
12150<body>
12151<nav class="top-nav">
12152 <a class="brand" href="/">
12153 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
12154 <span class="brand-title">OxideSLOC</span>
12155 </a>
12156</nav>
12157<main class="page">
12158 <div class="card">
12159 <h1>Sign In</h1>
12160 <p class="subtitle">Enter the API key printed when the server started.</p>
12161 {% if has_error %}
12162 <div class="error">Incorrect API key — please try again.</div>
12163 {% endif %}
12164 <form method="POST" action="/auth/login">
12165 <input type="hidden" name="next" value="{{ next_url|e }}">
12166 <label for="key">API Key</label>
12167 <input id="key" type="password" name="key" autocomplete="current-password"
12168 placeholder="Paste your API key here" autofocus>
12169 <button type="submit" class="btn">Sign In</button>
12170 </form>
12171 <p class="hint">
12172 The API key was printed in the terminal when the server started.<br>
12173 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
12174 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
12175 </p>
12176 </div>
12177</main>
12178</body>
12179</html>
12180"##,
12181 ext = "html"
12182)]
12183struct LoginTemplate {
12184 csp_nonce: String,
12185 has_error: bool,
12186 next_url: String,
12187 lockout_threshold: u32,
12188}